io模型

作者: | 更新日期:

网络编程, 无非是阻塞,非阻塞,异步等等. 这里简单的记录一下.

背景

io模型是网络编程的基础知识.
最近准备设计一个适合自己项目的网络框架, 所以先回顾一下这些基础的网络知识点.

这篇文章大部分内容摘自《Unix 网络编程》, 推荐想深入学习学习网络的人读一下这本书。

IO模型

linux下的IO模型有五种形式, 如下:

下面我们先介绍一下上面提到的五种模型.

阻塞式IO

最流行的IO模型就是阻塞式IO模型.
这里需要阻塞两个阶段: 1. 等待数据报准备好 2. 等待内核数据复制到用户空间.

非阻塞式IO

进程把一个套接字设置成非阻塞是在通知内核:当所请求的IO操作非得把进程投入睡眠才能完成时, 不要把进程投入睡眠, 而是返回一个错误.
这种模型需要用户不断的轮循查询某个操作是否就绪, 往往很消耗CPU, 很少使用.

IO复用模型

我们使用select, poll或者 epoll阻塞在这些系统调用上, 而不是阻塞在真正的IO系统调用上.
好处是我们同一时间可以等待多个IO操作, 缺点是内核数据复制到用户空间时我们还需要等待.

信号驱动式模型

我们设置一个信号和回调函数, 然后主循环可以继续执行做其他事.
当信号到达时, 需要等待数据从内核态复制到用户态.

异步IO模型

前面几种模型都在内核态到用户态传数据时存在等待, 而异步不需要等待.

IO模型总结

上面几种模型总结一下就是阻塞与非阻塞, 同步与异步, 可以参考下面的矩阵图.

同步阻塞 IO

在这个模型中,用户空间的应用程序执行一个系统调用,这会导致应用程序阻塞。
意味着应用程序会一直阻塞,直到系统调用完成为止(数据传输完成或发生错误)。
调用应用程序处于一种不再消费 CPU 而只是简单等待响应的状态,因此从处理的角度来看,这是非常有效的。

同步非阻塞 IO

非阻塞的实现是 I/O 命令可能并不会立即满足,需要应用程序调用许多次来等待操作完成。
这个方法会引入 I/O 操作的延时,因为数据在内核中变为可用到用户调用 read 返回数据之间存在一定的间隔,这会导致整体数据吞吐量的降低。

异步阻塞 I/O

在这种模型中,配置的是非阻塞 I/O,然后使用阻塞 select 系统调用来确定一个 I/O 描述符何时有操作。
使 select 调用非常有趣的是它可以用来为多个描述符提供通知,而不仅仅为一个描述符提供通知。

异步非阻塞 I/O

异步非阻塞 I/O 模型是一种处理与 I/O 重叠进行的模型。
读请求会立即返回,说明 read 请求已经成功发起了。
在后台完成读操作时,应用程序然后会执行其他处理操作。
当 read 的响应到达时,就会产生一个信号或执行一个基于线程的回调函数来完成这次 I/O 处理过程。

IO模型的实现

select 实现

select 的调用过程大概如下:

  1. 使用copy_from_user从用户空间拷贝fd_set到内核空间
  2. 注册回调函数__pollwait
  3. 遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll)
  4. 以tcp_poll为例,其核心实现就是__pollwait,也就是上面注册的回调函数。
  5. __pollwait的主要工作就是把current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列
    对于tcp_poll来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。
    在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。
  6. poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
  7. 如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout是调用select的进程(也就是current)进入睡眠。
    当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。
    如果超过一定的超时时间(schedule_timeout指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。
  8. 把fd_set从内核空间拷贝到用户空间。

poll实现

poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。
poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。

epoll实现

epoll既然是对select和poll的改进,就应该能避免上述的三个缺点。
那epoll都是怎么解决的呢?

在此之前,我们先看一下epoll和select和poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。
而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。

对于第一个缺点,epoll的解决方案在epoll_ctl函数中。
每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。
epoll保证了每个fd在整个过程中只会拷贝一次。

对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数
当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。
epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。

对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048
举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:
LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。
epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

异步 IO

Linux 异步 I/O 是 Linux 内核中提供的一个相当新的功能。
AIO 背后的基本思想是允许进程发起很多 I/O 操作,而不用阻塞或等待任何操作完成。稍后或在接收到 I/O 操作完成的通知时,进程就可以检索 I/O 操作的结果。

在异步非阻塞 I/O 中,我们可以同时发起多个传输操作。
这需要每个传输操作都有惟一的上下文,这样我们才能在它们完成时区分到底是哪个传输操作完成了。
在 AIO 中,这是一个 aiocb(AIO I/O Control Block)结构。
这个结构包含了有关传输的所有信息,包括为数据准备的用户缓冲区。
在产生 I/O (称为完成)通知时,aiocb 结构就被用来惟一标识所完成的 I/O 操作。

点击查看评论

关注公众号,接收最新消息

关注小密圈,学习各种算法

tiankonguse +
穿越