一、内核接收网络数据全过程
这一步,贯穿网卡、中断、进程调度的知识,叙述阻塞recv下,内核接收数据全过程。
如下图所示,进程在recv阻塞期间,计算机收到了对端传送的数据(步骤①)。数据经由网卡传送到内存(步骤②),然后网卡通过中断信号通知cpu有数据到达,cpu执行中断程序(步骤③)。此处的中断程序主要有两项功能,先将网络数据写入到对应socket的接收缓冲区里面(步骤④),再唤醒进程A(步骤⑤),重新将进程A放入工作队列中。
内核接收数据全过程
唤醒进程的过程如下图所示。
唤醒进程
以上是内核接收数据全过程
这里留有两个思考题,大家先想一想。
其一,操作系统如何知道网络数据对应于哪个socket?
其二,如何同时监视多个socket的数据?
第一个问题:因为一个socket对应着一个端口号,而网络数据包中包含了ip和端口的信息,内核可以通过端口号找到对应的socket。当然,为了提高处理速度,操作系统会维护端口号到socket的索引结构,以快速读取。
第二个问题是多路复用的重中之重,是本文后半部分的重点!
二、同时监视多个socket的简单方法
服务端需要管理多个客户端连接,而recv只能监视单个socket,这种矛盾下,人们开始寻找监视多个socket的方法。epoll的要义是高效的监视多个socket。从历史发展角度看,必然先出现一种不太高效的方法,人们再加以改进。只有先理解了不太高效的方法,才能够理解epoll的本质。
假如能够预先传入一个socket列表,如果列表中的socket都没有数据,挂起进程,直到有一个socket收到数据,唤醒进程。这种方法很直接,也是select的设计思想。
为方便理解,我们先复习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
select的实现思路很直接。假如程序同时监视如下图的sock1、sock2和sock3三个socket,那么在调用select之后,操作系统把进程A分别加入这三个socket的等待队列中。
当任何一个socket收到数据后,中断程序将唤起进程。下图展示了sock2接收到了数据的处理流程。
ps:recv和select的中断回调可以设置成不同的内容。
所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面。如下图所示。
但是简单的方法往往有缺点,主要是:
-
上下文切换开销:每次调用
select
时,都需要将文件描述符集合从用户态拷贝到内核态,以及在select
调用返回后,可能需要再次拷贝回用户态。这个拷贝过程随着文件描述符数量的增加而线性增长,导致上下文切换的开销。 -
唤醒后的遍历:当
select
被唤醒时,程序并不知道哪些文件描述符真的准备好了,因此必须遍历整个文件描述符集合来检查哪个是真正就绪的。 -
最大监视数量限制:
select
通常有一个监视文件描述符数量的上限,默认值通常是 1024,这个限制在FD_SETSIZE
宏中定义。 -
使用数组实现的 fd_set:在某些实现中,
select
使用数组(bitmap)来跟踪文件描述符的状态,这限制了它可以处理的文件描述符的数量。 -
不可重用的 fd_set:每次调用
select
时,都需要重新初始化和设置fd_set
,因为内核不会修改这个集合,而是返回一个全新的集合。 -
O(n) 时间复杂度:
select
需要遍历整个文件描述符集合来检查哪些文件描述符已经就绪,这导致它的时间复杂度为 O(n),其中 n 是监视的文件描述符数量。 -
线性遍历:
select
在检查哪些 socket 准备好时,需要线性遍历整个文件描述符集合,这在有大量 socket 需要监视时效率很低。 -
水平触发:
select
默认是水平触发(Level-triggered)的,这意味着只要文件描述符的状态没有被改变(例如,数据没有被读取),它在多次调用select
时可能会被多次报告为就绪。 -
锁的问题:在多线程环境中,使用
select
可能需要额外的锁来同步对文件描述符集合的访问,这增加了复杂性和开销。
poll
基于结构体存储
fd struct pollfd
{ int fd;
short events;
short revents; //可重用
}
解决了select的3,4两点缺点
epoll
epoll是在select出现N多年后才被发明的,是select和poll的增强版本。epoll通过以下一些措施来改进效率。
措施一:功能分离
select低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一。如下图所示,每次调用select都需要这两步操作,然而大多数应用场景中,需要监视的socket相对固定,并不需要每次都修改。epoll将这两个操作分开,先用epoll_ctl维护等待队列,再调用epoll_wait阻塞进程。显而易见的,效率就能得到提升。
为方便理解后续的内容,我们先复习下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){
//处理
}
}
功能分离,使得epoll有了优化的可能。
措施二:就绪列表
select低效的另一个原因在于程序不知道哪些socket收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历。如下图所示,计算机共有三个socket,收到数据的sock2和sock3被rdlist(就绪列表)所引用。当进程被唤醒后,只要获取rdlist的内容,就能够知道哪些socket收到数据。
epoll的原理和流程
本节会以示例和图表来讲解epoll的原理和流程。
创建epoll对象
如下图所示,当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(也就是程序中epfd所代表的对象)。eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。
创建一个代表该epoll的eventpoll对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为eventpoll的成员。
维护监视列表
创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如下图,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。
当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。
接收数据
当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加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的本质已经有一定的了解。但我们还留有一个问题,eventpoll的数据结构是什么样子?
再留两个问题,就绪队列应该应使用什么数据结构?eventpoll应使用什么数据结构来管理通过epoll_ctl添加或删除的socket?
如下图所示,eventpoll包含了lock、mtx、wq(等待队列)、rdlist等成员。rdlist和rbr是我们所关心的。
就绪列表的数据结构
就绪列表引用着就绪的socket,所以它应能够快速的插入数据。
程序可能随时调用epoll_ctl添加监视socket,也可能随时删除。当删除时,若该socket已经存放在就绪列表中,它也应该被移除。
所以就绪列表应是一种能够快速插入和删除的数据结构。双向链表就是这样一种数据结构,epoll使用双向链表来实现就绪队列(对应上图的rdllist)。
索引结构
既然epoll将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的socket。至少要方便的添加和移除,还要便于搜索,以避免重复添加。红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好。epoll使用了红黑树作为索引结构(对应上图的rbr)。
epoll的优点:
不需要轮询,时间复杂度为O(1)
epoll_create 创建一个白板 存放fd_events
epoll_ctl 用于向内核注册新的描述符或者是改变某个文件描述符的状态。
已注册的描述符在内核中会被维护在一棵红黑树上。
epoll_wait 通过回调函数内核会将 I/O 准备好的描述符加入到一个链表中管理,
进程调用 epoll_wait() 便可以得到事件完成的描述符
两种触发模式:
LT:水平触发 当 epoll_wait() 检测到描述符事件到达时,将此事件通知进程,进程可以不立即处理该事件,下次调用 epoll_wait() 会再次通知进程。是默认的一种模式,并且同时支持 Blocking 和 No-Blocking。
ET:边缘触发 和 LT 模式不同的是,通知之后进程必须立即处理事件。 下次再调用 epoll_wait() 时不会再得到事件到达的通知。很大程度上减少了 epoll 事件被重复触发的次数, 因此效率要比 LT 模式高。只支持 No-Blocking,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
三组 I/O 复用函数的比较
select、poll、epoll
3 组函数都通过某种结构体变量来告诉内核监听哪些文件描述符上的哪些事件,并使用该结构体类型的参数来获取内核处理的结果。select
的函数类型 fd_set
没有将文件描述符和事件绑定,它仅仅是一个文件描述符集合,因此 select
需要提供 3 个这种类型的参数来分别传入和输出可读、可写及集合的在线修改,应用程序下次调用 select
前不得不重置这 3 个 fd_set
集合。poll
参数类型 pollfd
把文件描述符和事件都定义其中,任何时间都被统一处理,从而使得编程结构简介得多。并且内核每次修改的时 epollfd
结构体的 revents
成员,而 event
成员保持不变,因此下次调用 poll
时应用程序无须重置 pollfd
类型的事件集参数。每次 select
和 poll
调用都返回整个用户注册的事件集和,复杂度高。epoll
则采用与 select
和 poll
完全不同的方式来管理用户注册的事件。在内核中维护一个事件表,并提供一个独立的系统调用 epoll_ctl
来控制往其中添加、删除、修改事件。这样,无须反复从用户空间读入这些事件。epoll_wait
系统调用的 events
参数仅用来返回就绪的事件,这使得应用程序所以就绪文件描述符的时间复杂度达到 O(1)。
select、poll
采用的是轮询的方式来执行事件。
epoll_wait
采用回调的方式。当内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列。
所以在选择使用 select
还是 epoll
上,当场景为连接量少,并且都比较活跃时,选择 select | poll
会比 epoll
更好