一、IO多路复用基本概念
select、poll、epoll都是IO多路复用的机制。IO多路复用就是通过一种机制,让一个进程/线程可以监视多个描述符,一旦某个描述符就绪(一般是读写就绪),能够通知应用程序进行相应的读写操作。
I/O多路复用在英文叫 I/O multiplexing,这里面的 multiplexing 指的其实是在单个进程/线程通过记录跟踪每一个文件描述符的状态来同时管理多个I/O流。发明它的原因,是尽可能地提高服务器的吞吐能力。
I/O复用虽然能同时监听多个文件描述符,当其本质上还是同步IO模型,因为需要在读写事件就绪后程序自己负责进行读写事件的处理,而这个读写过程是阻塞的。如果要实现并发,只能使用多进程/多线程等编程手段了。与多进程/多线程技术相比,I/O多路复用技术最大的优势就是系统开销小,系统不必创建大量进程/线程,也不必维护这些进程/线程,从而大大减少了系统的开销。
IO多路复用使用场景
1)当客户处理多个描述符时(一般是交互式输入和网络套接口),必须使用I/O复用。
2)当一个客户同时处理多个套接口时,这种情况是可能的,但很少出现。
3)如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
4)如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
5)如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。
二、三组 I/O 多路复用函数的比较
这三组I/O多路复用系统调用都能同时监听多个文件描述符。它们将等待由timeout参数指定的超时时间,直到一个或多个文件描述符上有事件发生时返回,返回值是就绪的文件描述符的数量,如果返回0,则表示没有事件发生。
下面我们主要从事件集合、最大支持文件描述符数量、工作模式和底层实现原理等4个方面进一步比较它们的异同。
事件集合
这3组函数都通过某种结构体变量来告诉内核监听哪些文件描述符上的哪些事件,并使用该结构体类型的参数来获取内核的处理结果。
select:使用 fd_set 结构体来存放被监听的文件描述符的,本质上是使用一个位图结构来存放这些被监听的文件描述符的,因此select能够监听的文件描述符数量是有限制的。同时,fd_set 没有将文件描述符和事件进行绑定,它仅仅是一个文件描述符集合,因此,select需要提供3个fd_set类型的参数来分别传入和传出可读、可写及异常事件。一方面,使得select不能处理更多类型的事件,另一方面,由于内核对fd_set集合的在线修改,使得下次再调用select()函数前不得不重置这3个fd_set集合,这使得编程变成很麻烦,并且容易出错。
poll:使用 struct pollfd结构体来存放被监听的文件描述符,它比select“聪明”的地方就在于它把文件描述符和与其关联的事件都定义在这个结构体中了,从而使得编程接口变得简洁很多,同时内核每次修改的都是pollfd结构体的revents成员,而events成员保持不变,因此下次调用poll()函数时应用程序无须重置pollfd类型的事件集参数。
由于每次select 和 poll 调用都是返回整个用户监听的事件集合(其中包括就绪的和未就绪的),所以应用程序索引就绪文件描述符的时间复杂度为O(n)。
epoll:采用与select 和 poll 完全不同的方式来管理用户注册的事件。它在内核中维护一个事件表,并提供了一个独立的系统调用函数 epoll_ctl来控制往该内核事件表中添加、删除、修改事件。这样,每次调用epoll_wait()函数时,都是直接从内核事件表中取得用户注册的事件,而无须反复从用户空间将这些注册事件读入到内核区中,节省了复制的系统开销。epoll_wait 系统调用中的 events 指针参数仅用来返回就绪的事件,这使得应用程序索引就绪文件描述符的时间复杂度为O(1)。需要注意的是,epoll 和 poll一样,也是将文件描述符和与其关联的事件是绑定在一起的,这样做的好处是,编程接口变得简洁,不像select那样复杂。
最大支持文件描述符数量
poll 和 epoll 分别用 nfds 和 maxevents 参数指定最多监听多少个文件描述符。这两个数值都能达到系统允许打开的最大文件描述符数目,即 65 535(cat /proc/sys/fs/file-max)。而select允许监听的最大文件描述符数量通常是有限的。虽然用户可以修改这个限制,但是这可能会导致不可预期的后果。
工作模式
select 和 poll 都只能工作在相对低效的LT(水平触发)模式,而epoll 虽然默认也是工作在LT模式下,但是它还可以工作在更高效的ET(边缘触发)模式下。并且 epoll 还支持 EPOLLONESHOT事件。该事件能进一步减少可读、可写和异常事件被触发的次数。
底层实现原理
select 和 poll 都是采用轮询的方式,即每次调用都要扫描整个注册的文件描述符,并将其中就绪文件描述符的数量返回给应用程序,因此它们检测就绪文件描述符的事件复杂度为O(n)。
而epoll则不同,它采用的是回调的方式,内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入到内核就绪事件队列。当调用epoll_wait 系统调用时,无须轮询整个内核事件表中的文件描述符,而只需检测就绪事件队列是否有内容,如有,内核则将该就绪队列中的内容拷贝到用户空间,因此epoll检测就绪文件描述符的时间复杂度为O(1)。
相关视频推荐
6种epoll的设计方法(单线程epoll、多线程epoll、多进程epoll)
epoll实战揭秘-支撑亿级IO的底层基石
网络原理tcp/udp,网络编程epoll/reactor,面试中正经“八股文”
需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享
三、三组 I/O 多路复用的优缺点
3.1 select
【优点】
1、select的可移植性好,因为在某些Unix系统上并不支持poll 和 epoll(极少)。
2、select 对于超时时间提供了更好的精度:微秒,而 poll 和 epoll 都是毫秒级。
【缺点】
1、select 支持监听的文件描述符fd的数量有限制,默认是1024个。(最大数量限制)
2、select 需要维护一个用来存放文件描述符fd的数据结构(fd_set),每次调用select都需要把fd集合从用户区拷贝到内核区,而select系统调用结束后,又需要把fd集合从内核区拷贝到用户区,这个系统开销在fd数量很多时会很大。(内存复制开销)
3、每次调用select系统调用时,都需要在内核遍历传入的整个文件描述符集合,逐个检测,查看是否有就绪的文件描述符,然后返回就绪文件描述符的个数。也就是说,select对文件描述符是线性扫描的,当注册的文件描述符fd的数量很多时,效率会较低,时间复杂度为O(n)。(时间复杂度)
3.2 poll
poll的实现原理和select非常相似,但是相比select,它做了一些改进的地方。首先是存放文件描述符的数据结构(pollfd),它将文件描述符和与其对应的事件关联起来了,使得编程接口变得简洁了;其次,它没有了最大文件描述符的限制,原因是它是基于链表结构来存储的。
【优点】(对比select而言)
1、没有最大文件描述符数量的限制(相对select而言)。(基于链表存储)poll 主要是解决了这个最大文件描述符数量的限制问题。
当然,它还是有上限的,这个上限是操作系统所支持的能开启的最大文件描述符数量(cat /proc/sys/fs/file-max)。
2、优化了编程接口。select()函数有5个参数,而poll()减少到了3个参数。并且每次调用select函数前,都必须重置该函数中的3个fd_set类型的参数值,而poll不需要重置。
【缺点】
1、poll 同样需要维护一个用来存放文件描述符的数据结构(pollfd),当注册的文件描述符无数量很多时,会使得用户区和内核区之间传递该数据结构的复制开销很大。(内存复制开销)
每次调用poll系统调用时,都需要把文件描述符fd集合从用户区拷贝到内核区,然后poll系统调用返回前,又需要把文件描述符fd集合从内核区拷贝到用户区,这个内存拷贝的系统开销在fd数量很多的时候会很大。
<说明> 系统调用函数的执行是发生在内核区的,而用户程序的执行是发生在用户区的,所以会存在内核区与用户区之间的内存复制的系统开销。
2、与select一样,每次poll系统调用时,需要在内核遍历传入的整个文件描述符集合,逐个检测,查看是否有就绪的文件描述符,然后返回就绪文件描述符的个数。也就是说,poll也是线性扫描的方式,当注册的文件描述符fd的数量很多时,效率会较低,时间复杂度为O(n)。(时间复杂度)
3、poll 只能工作在水平触发(LT)模式下。(工作模式)
水平触发模式下,当描述符处于就绪状态下,内核通知了应用程序,但是应用程序没有进行处理,那么下次调用poll时仍会向应用程序发出通知。
<注意> select 和 poll 都需要在返回后,通过遍历整个文件描述符集合来获取就绪的文件描述符。事实上,在网络连接中,同时连接的大量客户端在某一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的递增,其效率也会线性递减。
3.3 epoll
epoll 是在Linux 2.6内核版本中提出的,是之前select和poll的增强版本。
epoll使用一个epoll文件描述符管理多个被监听的文件描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户区和内核区只需要拷贝一次被监听的文件描述符的数据结构(epoll_event)即可。
epoll 既解决了select的最大文件描述符数量限制的问题,又解决了poll的内存复制开销大、时间复杂度大的问题(前提条件:文件描述符数量很大的情况下)。
【优点】(对比select和poll)
1、和poll一样,没有最大文件描述符数量的限制(相对select而言)。
2、epoll 虽然也需要维护用来存放文件描述符的数据结构(epoll_event),但是它只需要将该数据结构拷贝进内核区一次,不需要重复拷贝。
epoll只在调用 epoll_ctl 系统调用时拷贝一次要监听的文件描述符数据结构到内核区,在调用 epoll_wait系统调用时不需要再把所有要监听的文件描述符fd重复拷贝进内核区。而select和poll每次调用都需要把所有要监听的fd重新拷贝到内核区。这就解决了内存复制开销的问题。
3、epoll 采用回调方式来检测就绪文件描述符。
epoll 通过epoll_ctl系统调用注册一个文件描述符,一旦该文件描述符就绪,内核就会采用callback回调机制来进行通知,并将该就绪描述符放入就绪事件链表中。然后在epoll_wait系统调用中,当接收到有通知信号到来时,就会去检测就绪事件链表是否有内容,如果有内容,就将就绪事件链表的内容从内核区拷贝到用户区,最后epoll_wait系统调用返回就绪描述符的个数。也就是说,epoll只会对活跃的文件描述符进行管理,而不需要像select和poll那样,每次调用都要线性扫描全部的文件描述符,导致效率呈现线性下降。
【缺点】
目前只有Linux操作系统支持epoll,不支持跨平台使用。而Unix操作系统上是使用kqueue。
四、几个需要注意的问题
4.1 用户态将文件描述符参传入内核的方式
select:创建3个文件描述符集的数据结构(fd_set)并拷贝到内核中,分别监听读、写、异常事件。受单个进程/线程可以打开的文件描述符数量限制,默认是1024个文件描述符。poll:将传入的文件描述符数据结构(struct pollfd结构体数组)拷贝到内核中进行监听。epoll:执行epoll_create系统调用时会在内核的缓冲区中建立一颗红黑树以及就绪链表(该链表用于存储已就绪的文件描述符)。接着用户执行的epoll_ctl系统调用添加文件描述符,即在红黑树上增加相应的结点。4.2 内核态检测文件描述符就绪状态的方式
select:采用轮询方式,线性扫描所有用户关注的文件描述符,如果检测到某个文件描述符已就绪,就修改用户传进来的数据结构fd_set的值。poll:同样采用轮询方式,线性扫描所有用户关注的文件描述符,如果检测到某个文件描述符就绪,内核就修改文件描述符fd对应的revents的值,并将其加入到内核的等待队列中。epll:采用回调方式。在执行epoll_ctl的ADD操作时,不仅将文件描述符放入红黑树上,并且还注册了回调函数,如果某个文件描述符已就绪,它会主动调用回调函数,该回调函数将文件描述符放入到就绪链表中。4.3 找到就绪文件描述符并传递给用户态的方式
select:将之前传入到内核态的数据结构(fd_set)重新拷贝传出到用户态,并返回就绪的文件描述符数量。但是用户程序并不知道哪些文件描述符是处于就绪态,因此需要在用户程序中对所有的文件描述符再一次进行遍历来判断。poll:将之前传入到内核态的数据结构(pollfd数组)重新拷贝传出到用户态,并返回就绪的文件描述符数量。用户程序同样不知道哪些文件描述符是处于就绪态,需要遍历判断。epoll:epoll只需要检测就绪事件链表中有无数据即可,如有,则只需将就绪链表的数据拷贝传出到用户态,并返回就绪的文件描述符数量。由于返回的就是就绪态的文件描述符,因此用户程序不需要通过遍历来判断,而是直接处理即可。4.4 重复监听文件描述符的处理方式
select:将新的监听文件描述符集合拷贝传入内核中,继续以上步骤。poll:将新的struct pollfd结构体数组拷贝传入内核中,继续以上步骤。epoll:无需重新构建红黑树,直接沿用已存在的即可。4.5 三种IO多路复用的适用场景
select、poll:适合在连接数少并且连接都十分活跃的情况下。
epoll:适用在连接数很多,活跃连接较少的情况下。
表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll要好,毕竟epoll的通知机制需要调用很多的函数回调,这也是一笔不小的系统开销。select、poll的低效是因为每次它们都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善。
五、总结
select,poll,epoll都是IO多路复用的实现机制。它们本质上还是同步IO,而不是异步IO,因为它们都需要在读写事件就绪后自己负责进行读写操作,而这个读写过程是阻塞的;而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核空间拷贝到用户空间。select、poll的底层实现中需要自己不断地轮询所有fd集合,直到文件描述符就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用 epoll_wait 不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是当某个文件描述符就绪时,主动调用回调函数,把就绪的文件描述符放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的 时候只须判断一下就绪链表是否为空就行了,这就节省了大量的CPU时间,这就是回调机制带来的性能提升。select、poll每次调用(调用select()、poll()函数)都要把所有文件描述符fd集合从用户态拷贝到内核态,而epoll是在初始调用epoll_create时在内核区先开辟好缓存区,然后在调用epoll_ctl时,将待注册的文件描述符从用户态拷贝到内核态,并且只需要拷贝这一次,在每次调用epoll_wait时,不再需要重复拷贝,这就节省了内存复制带来的系统开销。为了便于阅读,我们将这3组I/O多路复用的系统调用的区别总结成一个图表,如下图所示:
select、poll 和 epoll的区别
六、面试题
1、在epoll IO多路复用中,某个socket读到一半,在这个socket上又有读事件来了怎么办?
答:为了避免在同一个socket上再次监听到同一个可读事件,可以在对应的描述符中添加 EPOLL_ONESHOT事件,其效果是监听到一次事件后就将对应的描述符从监听集合中移除,也就不会再被追踪到了。读操作完成后再把对应的文件描述符重新加入监听集合。
2、LT和ET模式下的阻塞与非阻塞?
答:在ET(水平触发)模式下,也是epoll的默认模式,epoll_wait返回可读事件,表明socket一定收到了数据,我们可以使用read函数来读取数据。如果指定读取的数据大于缓冲区数据,无论socket是阻塞还是非阻塞,read函数不会阻塞,会返回实际读取到的数据大小。在read之后再次调用read,如果socket是阻塞的,read将阻塞,直到接收到数据才返回。此时,如果指定读取的数据小于缓冲区中数据,epoll_wait 会继续被触发,因为还有读缓冲区中还有数据没有被读取完。
在ET(边缘触发)模式下,只有新的数据到来时才会触发。如果指定读取的数据小于缓冲区中的数据,epoll_wait 不会被继续触发。因此,使用ET模式时,有数据到来时,必须循环读取读缓冲区中的数据,直到read返回-1,并且errno错误码为EAGAIN,才算读取完了全部缓冲区中的内容。
对于监听的listen_fd,最好使用LT模式,如果使用ET模式会导致高并发情况下,有的客户端会连接不上。如果非要使用ET模式,可以在while循环中调用accept()函数。对于读写的conn_fd,LT模式下,阻塞和非阻塞效果都一样,因为在阻塞模式下,如果数据读取不完全则返回继续触发,反之读取完则返回继续等待。建议将文件描述符设置为非阻塞。对于读写的conn_fd,ET模式下,必须使用非阻塞IO,并要求一次性地完整读写完全部数据。因为如果不一次性读取完缓冲区中的全部数据,缓冲区剩余数据不会被 epoll_wait 再次触发。