什么是IO多路复用?
IO即为网络I/O,多路即为多个TCP连接,复用即为共用一个线程或者进程,模型最大的优势是系统开销小,不必创建也不必维护过多的线程或进程。
IO复用(IOmultiplexing)使得内核一旦发现进程指定的一个或多个IO条件就绪(也就是说输入已准备好被读取,或者描述符已能承接更多的输出),它就通知进程。
应用场景
I/O 多路复用通常用于以下场景的网络应用:
- 当客户端处理多个描述符时(通常是交互式输入和网络套接字)
- 当客户端同时处理多个套接字时(这是可能的,但很少见)
- 如果 TCP 服务器同时处理侦听套接字及其连接的套接字
- 如果服务器同时处理 TCP 和 UDP
- 如果服务器处理多个服务,可能还有多个协议
I/O 多路复用不限于网络编程。许多重要的应用程序发现需要这些技术。
输入/输出模型
我们首先检查 Unix 下可用的五种 I/O 模型的基本区别:
[1]blockingIO - 阻塞I/O
[2]nonblockingIO - 非阻塞I/O
[3]IOmultiplexing – I/O多路复用(select和poll)
[4]signaldrivenIO - 信号驱动I/O
[5]asynchronousIO - 异步I/O(POSIX aio函数)
输入操作通常有两个不同的阶段:
等待数据准备好。这涉及等待数据到达网络。当数据包到达时,它被复制到内核中的缓冲区中。
将数据从内核复制到进程。这意味着将(准备好的)数据从内核缓冲区复制到我们的应用程序缓冲区。
阻塞 I/O 模型
最流行的 I/O 模型是阻塞 I/O 模型(我们在前面部分的所有示例中都使用了该模型)。默认情况下,所有套接字都是阻塞的。场景如下图所示:
使用UDP而不是TCP作为例子的原因在于就UDP而言,数据准备好读取的概念比较简单:要么整个数据报已经收到,要么还没有。然而对于TCP来说,诸如套接字低水位标记( low-water mark)等额外变量开始起作用,导致这个概念变得复杂。
在这个例子中,我们把recvfrom函数视为系统调用,因为我们正在区分应用进程和内核。不论它如何实现(在源自Berkeley的内核上是作为系统调用,在System V内核上是作为调用系统调用getmsg的函数),一般都会从在应用进程空间中运行切换到在内核空间中运行,一段时间之后再切换回来。
非阻塞 I/O 模型
当套接字设置为非阻塞时,我们是在告诉内核"当我请求的 I/O 操作在不让进程进入睡眠状态的情况下无法完成时,不要让进程进入睡眠状态,而是返回一个错误"。
- 对于前三个recvfrom,没有要返回的数据,内核立即返回一个错误EWOULDBLOCK。
- 我们第四次调用recvfrom,数据报准备好了,它被复制到我们的应用程序缓冲区,并recvfrom成功返回。然后我们处理数据。
当应用程序处于循环调用recvfrom这样的非阻塞描述符时,它被称为轮询。应用程序不断地轮询内核以查看某些操作是否已准备好。这通常会浪费 CPU 时间,但偶尔会遇到这种模型,通常在专用于一个功能的系统上。
非阻塞IO图示:
视频演示:非阻塞模式下,前三次
recvfrom返回 EWOULDBLOCK 错误,第四次数据就绪后成功读取并处理。该演示展示了应用程序不断轮询内核的过程。
I/O 复用模型
有了IO复用(IO multiplexing),我们就可以调用select或poll,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的IO系统调用上。
我们阻塞于select调用,等待数据报套接字变为可读。当select返回套接字可读这一条件时,我们调用recvfrom把所读数据报复制到应用进程缓冲区。
与阻塞型IO比较,IO复用并不显得有什么优势,事实上由于使用select需要两个而不是单个系统调用,IO复用还稍有劣势。使用select的优势在于我们可以等待多个描述符就绪。
异步 I/O 模型
异步 I/O由 POSIX 规范定义,各种标准中出现的实时功能的各种差异共同形成了当前的 POSIX 规范。
我们也可以用信号,让内核在描述符就绪时发送SIGIO信号通知我们。我们称这种模型为信号驱动式IO ( signal-driven I/O)。
我们首先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,我们的进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个sIGIo信号。我们随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它读取数据报。
无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。
各种I/O模型的比较
对比上述5种不同的IO模型。可以看出,前4种模型的主要区别在于第一阶段,因为它们的第二阶段是一样的:在数据从内核复制到调用者的缓冲区期间,进程阻塞于recvfrom调用。相反,异步I/O模型在这两个阶段都要处理,从而不同于其他4种模型。
同步IO和异步IO对比
POSIX把这两个术语定义如下:
同步IO操作(synchronous IO opetation)导致请求进程阻塞,直到IO操作完成;
异步IO操作( asynchronous I/O opetation)不导致请求进程阻塞。
select
select 是操作系统提供的系统调用函数,select()用来等待文件描述词(普通文件、终端、伪终端、管道、FIFO、套接字及其他类型的字符型)状态的改变。是一个轮循函数,循环询问文件节点,可设置超时时间,超时时间到了就跳过代码继续往下执行。
#include<sys/select.h>#include<sys/time.h>#defineFD_SETSIZE1024#defineNFDBITS(8*sizeof(unsignedlong))#define__FDSET_LONGS(FD_SETSIZE/NFDBITS)// fd_set结构体定义(位图实现)typedefstruct{unsignedlongfds_bits[__FDSET_LONGS];}fd_set;// select函数原型intselect(intmax_fd,// 最大文件描述符+1fd_set*readset,// 读描述符集合fd_set*writeset,// 写描述符集合fd_set*exceptset,// 异常描述符集合structtimeval*timeout// 超时时间);// 返回值:就绪描述符的数目// 操作宏FD_ZERO(fd,fd_set*fds)// 清空集合FD_SET(intfd,fd_set*fds)// 将给定的描述符加入集合FD_ISSET(intfd,fd_set*fds)// 判断指定描述符是否在集合中FD_CLR(intfd,fd_set*fds)// 将给定的描述符从集合中删除select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:
单个进程可监视的fd数量被限制,即能监听端口的大小有限。一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048。
对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低:当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。
需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
select效果图示:
poll
相关函数定义
#include<poll.h>intpoll(structpollfd*fds,nfds_t nfds,inttimeout);#include<signal.h>#include<poll.h>intppoll(structpollfd*fds,nfds_t nfds,conststructtimespec*tmo_p,constsigset_t*sigmask);// pollfd结构体structpollfd{intfd;// 文件描述符shortevents;// 关注的事件(输入)shortrevents;// 返回的事件(输出)};和select用三组文件描述符不同的是,poll只有一个pollfd数组,数组中的每个元素都表示一个需要监听IO操作事件的文件描述符。events参数是我们需要关心的事件,revents是所有内核监测到的事件。
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:
大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
poll还有一个特点是"水平触发",如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
epoll
相关函数定义如下
#include<sys/epoll.h>// 创建epoll实例intepoll_create(intsize);intepoll_create1(intflags);// 注册/修改/删除监听事件intepoll_ctl(intepfd,intop,intfd,structepoll_event*event);// 等待事件就绪intepoll_wait(intepfd,structepoll_event*events,intmaxevents,inttimeout);intepoll_pwait(intepfd,structepoll_event*events,intmaxevents,inttimeout,constsigset_t*sigmask);- epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是"高速"模式。
- LT模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作
- ET模式下,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论fd中是否还有数据可读。所以在ET模式下,read一个fd的时候一定要把它的buffer读完,或者遇到EAGAIN错误。
epoll效果演示:
视频演示:epoll 使用回调机制,只有活跃的文件描述符才会触发事件通知。演示了 ET(边缘触发)和 LT(水平触发)两种模式的区别,以及 epoll 的高效性。
边缘触发模式ET:
当被监控的Socket描述符上有可读事件发生时,服务器只会从 epoll_wait中苏醒一次,即使进程没有调用read函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完,只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
水平触发模式LT:
当被监控的Socket上有可读事件发生时,服务器不断地从 epoll_wait中苏醒,直到内核缓冲区数据被read函数读完才结束,目的是告诉我们有数据,只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户。
epoll的优点:
没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你"活跃"的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
epoll缺点:
epoll只能工作在linux下。
select和epoll的区别:
select和poll采用轮询的方式检查就绪事件,每次都要扫描整个文件描述符,复杂度O(N);
epoll采用回调方式检查就绪事件,只会返回有事件发生的文件描述符的个数,复杂度O(1);
select 只工作在低效的LT模式,epoll可以在ET高效模式工作;
epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现;
select单个进程可监视的fd数量有限,即能监听端口的大小有限,64位是2048;epoll没有最大并发连接的限制,能打开的fd的上限远大于2048 (1G的内存上能监听约10万个端口);
select 内核需要将消息传递到用户空间,都需要内核拷贝动作;epoll通过内核和用户空间共享一块内存来实现的。