搞懂select和epoll
搞懂select和epoll 笔记
看了大佬的文章,看懂了网络数据如何传输到计算机内存,服务器的select和epoll模型
思考笔记如下,把这三个铭记在心 : 为什么
,是什么
,怎么做
1-网卡是怎么接收数据的
从硬件角度上看:
网卡接受到网线传输的数据,然后DMA传输、IO通路选择等硬件操作,最后传输到计算机内存中,然后通知CPU去取数据。
网卡硬件产生的信号优先级很高,于是产生了中断操作,CPU让操作系统暂停当前任务,做完中断信息的操作后再返回。
同时我们为了理解后面的select和epoll,我们需要理解操作系统的调度模式,其中的阻塞
指的是进程在等待某事件(如接收到网络数据)发生之前的等待状态,recv
、select
和epoll
都是阻塞方法。
这里的阻塞是不会消耗计算机CPU资源的,为什么,来看看下面的代码,一个最基本的socket编程流程
//创建socket
int s = socket(AF_INET, SOCK_STREAM, 0);
//绑定
bind(s, ...)
//监听
listen(s, ...)
//接受客户端连接
int c = accept(s, ...)
//接收客户端数据
recv(c, ...);
//将数据打印出来
printf(...)
先创建socket
然后绑定ip端口、监听,接受连接,recv
接受数据,打印数据...
这里recv
是一个阻塞函数,当执行到这里会一直阻塞等待数据。
2-传统阻塞模式recv
在这里讲一讲阻塞原理:
操作系统为了实现多任务,实现各种进程调度,使用了运行态,等待态等,CPU程序正在执行为运行态,而等待态为阻塞模式,上面代码在recv的时候就会进入阻塞态,等待新数据,如果有新数据到来,就会转换成运行状态执行代码。
下面讲解上面代码的运行流程:
工作队列
操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。运行状态是进程获得cpu使用权,正在执行代码的状态;等待状态是阻塞状态,比如上述程序运行到recv时,程序会从运行状态变为等待状态,接收到数据后又变回运行状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务。
下图中的计算机中运行着A、B、C三个进程,其中进程A执行着上述基础网络程序,一开始,这3个进程都被操作系统的工作队列所引用,处于运行状态,会分时执行。
等待队列
操作系统在执行到socket的时候会创建一个由文件系统管理的socket对象,这个socket对象包含了发送缓冲区、接收缓冲区、等待队列等成员。等待队列是个非常重要的结构,它指向所有需要等待该socket事件的进程。
当程序执行到recv时,操作系统会将进程A从工作队列添加引用到该socket的等待队列中(如下图)。由于工作队列只剩下了进程B和C,依据进程调度,cpu会轮流执行这两个进程的程序,不会执行进程A的程序。所以进程A被阻塞,不会往下执行代码,也不会占用cpu资源。
唤醒进程
当socket接收到数据后,操作系统将该socket等待队列上的进程重新放回到工作队列,该进程变成运行状态,继续执行代码。也由于socket的接收缓冲区已经有了数据,recv可以返回接收到的数据。
3-改进的select模式
两个思考题,先想想。
其一,操作系统如何知道网络数据对应于哪个socket?
根据socket的IP地址和端口号,让网格数据传给对应的socket
其二,如何同时监视多个socket的数据?
监听多个socket的简单方法
服务端需要管理多个客户端连接,而recv只能监视单个socket,这种矛盾下,人们开始寻找监视多个socket的方法
假如能够预先传入一个socket列表,如果列表中的socket都没有数据,挂起进程,直到有一个socket收到数据,唤醒进程。这种方法很直接,也是select的设计思想。
先准备一个数组(下面代码中的fds),让fds
存放着所有需要监视的socket
。然后调用select
,如果fds
中的所有socket
都没有数据,select
会阻塞,直到有一个socket
接收到数据,select
返回,唤醒进程。用户可以遍历fds
,通过FD_ISSET
判断具体哪个socket
收到数据,然后做出处理。
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int fds[] = 存放需要监听的socket
while(1){
int n = select(..., fds, ...)
for(int i=0; i < fds.count; i++){
if(FD_ISSET(fds[i], ...)){
//fds[i]的数据处理
}
}
}
select的实现思路很直接。假如程序同时监视如下图的sock1、sock2和sock3三个socket,那么在调用select之后,操作系统把进程A分别加入这三个socket的等待队列中。
当任何一个socket收到数据后,中断程序将唤起进程。下图展示了sock2接收到了数据的处理流程。
所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面。如下图所示。
经由这些步骤,当进程A被唤醒后,它知道至少有一个socket接收了数据。程序只需遍历一遍socket列表,就可以得到就绪的socket。
总结select/poll
- 情况1. 为了能够让一个进程,监控多个socket.
就把一个socket列表,传入到selector选择器上。
这样selector,循环遍历socket列表。
把就绪状态的 socket 选择出来,然后进行处理。
什么是就绪状态:假设socket的读缓冲区有可读的数据,那么此socket就处于就绪状态。
就绪状态的socket,可以这样处理,
当读缓冲区有数据时,就读数据。
当写缓冲区有空间可以写时,就写数据。
当ServerSocket有新建了连接时,就接收 clinetSocket ,并且把clinetSocket添加到seletor选择器中。
完成以上处理后,再次遍历socket列表,按照以上逻辑反复处理。
- 情况2. 遍历一次socket列表,没有一个socket处于就绪状态,那么进程A将阻塞。
具体流程:遍历socket列表,发现socket处于未就绪状态。那么就在该socket的等待队列中,添加进程A的引用。
当遍历完整个socket列表,却没有任何一个socket处于就绪状态。那么就把进程A从工作队列移除,让进程处于阻塞状态。
由于CPU每次执行完指令,都会检查是否有中断信号。
假设10S以后,socket列表中,有一个socket处于就绪状态。
那么网卡对应的硬件,会向CPU发一个中断信号。CPU收到一个中断信号,从而调用对应的中断程序。
此中断程序,会把进程A唤醒,加入到工作队列。
并且遍历socket列表,将进程A的引用,从所有的等待队列中移除。
selector再次遍历socket列表,把就绪的 socket选出来,然后进行读或者写的操作。
再来看等待队列的作用:就是当socket就绪时,能够通过等待队列的进程引用,找到对应的进程。
虽然select的这个模式很简单,但是缺点也很多:
其一,每次调用select都需要将进程加入到所有监视socket的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个fds列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定select的最大监视数量,默认只能监视1024个socket。
其二,进程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次。
有没有减少遍历的方法?有没有保存就绪socket的方法?这两个问题便是epoll技术要解决的。
先思考,再往下看
4-epoll模式
epoll是在select出现N多年后才被发明的,是select和poll的增强版本。epoll通过以下一些措施来改进效率。
epoll模型的改进措施:
措施一:功能分离
select低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一。如下图所示,每次调用select都需要这两步操作,然而大多数应用场景中,需要监视的socket相对固定,并不需要每次都修改。epoll将这两个操作分开,先用epoll_ctl维护等待队列,再调用epoll_wait阻塞进程。显而易见的,效率就能得到提升。
epoll使用的数据结构:epoll维护了一个红黑树
和一个链表
,需要监听的socket添加到红黑树上,哪个socket有事件过来,就把它加到链表上,然后发给用户通知。用户直接遍历这个链表,挨个处理即可。
epoll的用法。如下的代码中,先用epoll_create创建一个epoll对象epfd,再通过epoll_ctl将需要监视的socket添加到epfd中,最后调用epoll_wait等待数据。
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
while(1){
int n = epoll_wait(...)
for(接收到数据的socket){
//处理
}
}
措施二:就绪列表(双链表)
select低效的另一个原因在于程序不知道哪些socket收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历。如下图所示,计算机共有三个socket,收到数据的sock2和sock3被rdlist(就绪列表)所引用。当进程被唤醒后,只要获取rdlist的内容,就能够知道哪些socket收到数据。
epoll的原理和流程
创建epoll对象
如下图所示,当某个进程调用epoll_create方法时,内核会创建一个eventpoll(事件监听)对象(也就是程序中epfd所代表的对象)。eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。
创建eventpoll是必须的,这个对象有就绪列表,红黑树,等待列表。就绪列表存放就绪的socket,红黑树存放所有正在监听的socket引用,等待列表放正在等待的进程。
维护监视列表
创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如下图,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。
当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。
接收数据
当socket收到数据后,中断程序会给eventpoll的rdlist
就绪列表(双链表)
添加socket引用。如下图展示的是sock2和sock3收到数据后,中断程序让rdlist
引用这两个socket。
eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。
当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。
阻塞和唤醒进程
假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。
当socket接收到数据,中断程序
一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。也因为rdlist
的存在,进程A可以知道哪些socket发生了变化。
上面的总结
epoll不会让每个 socke t的等待队列都添加进程A引用,而是在等待队列,添加 eventPoll对象的引用。
当socket就绪时,中断程序会操作eventPoll,在eventPoll中的就绪列表(rdlist),添加scoket引用。
这样的话,进程A只需要不断循环遍历rdlist,从而获取就绪的socket。
从代码来看每次执行到epoll_wait,其实都是去遍历 rdlist。
如果rdlist为空,那么就阻塞进程。
当有socket处于就绪状态,也是发中断信号,再调用对应的中断程序。
此时中断程序,会把socket加到rdlist,然后唤醒进程。进程再去遍历rdlist,获取到就绪socket。
epoll_wait
有一个time_out参数,在这个等待时间time里,如果就绪队列有socket需要处理,就调用阻塞的进程运行,如果一直为空,当计时器到了,进程变为非阻塞继续去干活。
epoll的实现细节
如图所示:图片来源:《深入理解Nginx:模块开发与架构解析(第二版)》,陶辉
就绪列表的数据结构
就绪列表引用着就绪的socket,所以它应能够快速的插入数据,双链表
。
程序可能随时调用epoll_ctl添加监视socket,也可能随时删除。当删除时,若该socket已经存放在就绪列表中,它也应该被移除。
所以就绪列表应是一种能够快速插入和删除的数据结构。双向链表就是这样一种数据结构,epoll使用双向链表来实现就绪队列(对应上图的rdllist)。
索引结构
既然epoll将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的socket。至少要方便的添加和移除,还要便于搜索,以避免重复添加。
在epoll机制中,eventpoll使用红黑树来存储关注的事件。具体来说,每个epoll对象都有一个独立的eventpoll结构体,通过它管理存放通过epoll_ctl添加的事件集合。这些事件以epitem为结点挂载到红黑树上,从而实现对大量事件的高效管理。当相应事件发生时,内核会通过回调机制将事件对应的epitem结点加入就绪队列(通常是双向链表)。因此,红黑树在epoll中起到了关键的作用,帮助快速查找、添加、删除和修改关注的事件。
关于时间复杂度
select返回后还要来个for loop去检查所有fd,所以时间复杂度是O(n)
epoll O(1)是因为epoll的回调机制,在执行epoll_ctl时,除了把event放到对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪rdlist链表里。所以,当一个socket上有数据到了,内核除了把网卡上的数据copy到内存中,还会把该socket插入到准备就绪链链表里。