06、IO多路复用器 select, epoll发展历程,工作原理,区别

什么是IO多路复用器

首先这里的IO指的是网络IO,也就是网络连接,如果把一个IO想象成一条路,这些路是连接到内核的,就程序自身而言它是不知道这些路上有没有数据到达的。

为了知道这一点,程序就得遍历每一条路,并且这个遍历还要基于用户态内核态的切换才能实现,因此遍历的成本是比较高的。

如果有一种方法,能够实现一次遍历,进程就能知道它关注的路有没有数据到达,那就能很大程度上节约资源提高性能了,多路复用器就是用来实现这个的。也就是多路复用器可以让单个线程监控多个连接,一旦某一个连接有数据来了,就能够通知程序进行相应的读写操作

IO多路复用器发展历程

简单来说:BIO时期:一个线程一个连接->NIO时期:一个线程通过死循环轮询有数据的连接,通知程序处理->多路复用器产生:select引入 -> 多路复用器优化:引入poll -> 再优化引入epoll

1、BIO时期

前置知识:每个连接会建立一个socket,每个socket都有一个它自己的等待队列,当socket阻塞时就会把任务放到等待队列中,当它唤醒时就会调用等待队列中的任务

BIO时期客户端向内核发起连接时,在进程回应之前,这个连接的socket是阻塞的,导致要想建立多个连接就得开启多个线程。而线程开启是要消耗内核的,因此数量有限,并不能支持高并发

2、NIO时期

为了解决上述问题,内核做了优化,线程是非阻塞的了,也就是说可以用一个线程来处理多个连接,那么就可以建立一个死循环来轮询连接(这里实际上轮询的是socket的文件描述符fd,本质就是连接,为了方便理解后续也统一描述为连接,有兴趣可了解底层源码),看这个连接有没有数据到达,如果没有就下一个,如果有那么就开始处理

3、select引入

NIO时期听起来好像很不错了,但是实际上如果有1000个连接,那么就要遍历1000遍才能知道每个进程的连接是否有数据了。这里要注意这1000个连接是针对整个服务器而言的,服务器中有各个进程,这些连接是分别对应各个进程的,一个进程可能会关注好几个连接,所以只有连接遍历完每个进程才知道自己关注的连接有没有数据进来。而这些遍历是是需要基于用户态内核态的切换的(这中间还涉及数据的拷贝等流程,具体这里不展开说了,有兴趣可额外了解),所以遍历的成本是很高的。

因此引入了IO多路复用器,一开始想到的办法是:预先传入一个连接列表,如果列表中的连接都没有数据,那么就挂起进程;如果某一个连接收到数据,那就唤醒对应的进程。这就是select的设计思想。

select的执行过程举例来讲就是:假如有3个socket连接到进程A上,那么进程A要关注的连接就有3个,在调用了select之后,操作系统会把进程A的引用分别加入到这三个连接(socket)的等待队列中,当任何一个socket收到数据后,网卡会发出中断信息,执行中断程序,socket等待队列中的进程A就会被唤醒。但是因为没有地方记录是哪些socket收到了数据,所以进程A是不知道具体哪个连接有数据的,所以进程A还需要把3个连接都遍历一遍,来找到有数据的连接(可能是1个也可能是多个连接有数据),得到有数据的连接后,select的工作就完成了,再由进程自身来完成后续的数据的读写操作

NIO时期要遍历,select还是要遍历,那select的优化是优化了什么?

如果你没有这个疑问就跳过。上述所说的1000个连接的遍历,是指通过遍历去找有没有socket有数据来了,并且唤醒对应的进程。但是同样的,唤醒后的进程仍然不知道自己关注的socket中是哪些有数据,所以是还要再遍历一遍的,这第二次遍历就是select也要做的遍历,所以select的优化在于它省掉了第一次遍历

4、poll引入

由上述的select过程可以知道,select也是需要再遍历自己关注的连接的。每次调用select是需要将进程引入添加到每个监听的socket的等待队列中的,并且还是需要遍历,所以仍然需要较大的成本的。因此规定了select的最大监听数量,默认是只能监听1024个socket

后来引入基于链表存储的poll多路复用器,没有了最大监听数的限制,但是本质上并没有多大改变,所以在性能上和select差别不大

5、epoll

看到这里请思考一个问题:select的缺点在哪儿?考虑清楚后往下看

--------------------------------

由上述表述可知,select有两个弊端:

(1)成本高:每次调用都要把进程的引用加入到所有监听的socket的等待队列中,每次唤醒又要将其从等待队列删除,还要将有数据连接的socket传给内核,是有一定的开销的。(前面没有详细描述网络数据传输到内核的过程,实际上中间经历了多次数据拷贝,是占用了较多资源的)

(2)还要遍历:因为进程被唤醒后,是不知道哪个socket有数据的,所以还要再遍历一次才知道有数据的socket,然后才能传给内核进行读写操作

针对这两个弊端,想想如果你要做优化,你会怎么做?

---------------------------------

我们先不说解决成本的问题,这个解决得靠内核的优化,从设计上看解决第2个问题:怎么不遍历就知道哪些socket有数据?

(ps: 如果不想了解这些过程,可以直接跳到后面答案,但是个人觉得想要掌握某个知识点,不能靠背,而应该理解其发展过程,明白原理)

首先看数据接收的过程,socket接收到数据后,网卡会发出一个中断信号,然后CPU才去调用中断程序,所以一开始其实是知道哪个socket有数据的,因为只有它有数据了,才会使网卡发出中断信号。如果能有一个空间(每个进程单独1个),同时监听上这个socket,当它有数据的时候通过监听事件,把这个socket放入这个空间里面,这样,后续进程去访问这个空间,就知道有哪些socket是有数据的了,而不用再遍历一遍。epoll就是这样实现的

epoll的核心在于3个方法:epoll_create,epoll_ctl,epoll_wait

开始时进程A会调用epoll_create方法来创建一个eventpoll对象,这个对象内部包含了一个等待队列和就绪列表,这个就绪列表就是上述所说的存放有数据的socket的空间。然后通过epoll_ctl来添加或者删除要监听的socket,也就是说如果某个新的连接打到进程A上,就会调用epoll_ctl来添加监听事件到这个socket上,具体就是内核会把eventpoll的引用添加到这个socket的等待队列中,当socket有数据时,中断程序就会操作eventpoll对象,给eventpoll对象的就绪列表添加这个socket的引用,同时也会唤醒eventpoll等待队列中的进程A,这样进程A只要访问就绪列表就知道是哪些socket有数据了。当没有socket有数据时,就会通过调用epoll_wait来阻塞线程

这里可以结合linux epoll源码来理解:从linux源码看epoll - 无毁的湖光-Al的个人空间 - OSCHINA - 中文开源技术交流社区

(这个过程如果不理解,可以看下述参考文献,了解更加详细的过程,自己总结一下,然后再回来看这里的表述会更好)

select,poll,epoll的区别

poll与select没有本质上的区别,只是因为基于链表实现的结构,没有监控连接数的限制

select和epoll的区别:

(1)epoll将维护等待队列和线程阻塞两个操作分开了:epoll_ctl,epoll_wait

之前没有提到,select低效的原因之一在于它把维护等待队列和线程阻塞的操作放在一起了,也就是说只要从等待队列中添加或者删除都会阻塞线程。大多数场景下并不需要同时执行这两个操作,将其分开后,有需要才调用更加节省开销

第二点区别,如果你明白了前面对于epoll的优化历程,应该能列举的出,尝试着表述出来

-------------------------------------------

(2)epoll会创建一个区域(就绪列表)用来存放就绪的socket,而不用再次遍历,效率更高

参考文献

【epoll原理】Epoll的本质(内部实现原理)_Lailikes的博客-CSDN博客_epoll底层原理

【select\poll\epoll区别】select、poll、epoll之间的区别(搜狗面试) - aspirant - 博客园

【linux epoll源码】从linux源码看epoll - 无毁的湖光-Al的个人空间 - OSCHINA - 中文开源技术交流社区