大话 Select、Poll、Epoll

言归正传,在介绍select、poll、epoll前,有必要说说linux(2.6+)内核的事件wakeup callback机制,这是IO多路复用机制存在的本质。Linux通过socket睡眠队列来管理所有等待socket的某个事件的process,同时通过wakeup机制来异步唤醒整个睡眠队列上等待事件的process,通知process相关事件发生。通常情况,socket的事件发生的时候,其会顺序遍历socket睡眠队列上的每个process节点,调用每个process节点挂载的callback函数。在遍历的过程中,如果遇到某个节点是排他的,那么就终止遍历,总体上会涉及两大逻辑:(1)睡眠等待逻辑;(2)唤醒逻辑。

(1)睡眠等待逻辑:涉及select、poll、epoll_wait的阻塞等待逻辑

[1]select、poll、epoll_wait陷入内核,判断监控的socket是否有关心的事件发生了,如果没,则为当前process构建一个wait_entry节点,然后插入到监控socket的sleep_list
[2]进入循环的schedule直到关心的事件发生了
[3]关心的事件发生后,将当前process的wait_entry节点从socket的sleep_list中删除。

(2)唤醒逻辑:

[1]socket的事件发生了,然后socket顺序遍历其睡眠队列,依次调用每个wait_entry节点的callback函数
[2]直到完成队列的遍历或遇到某个wait_entry节点是排他的才停止。
[3]一般情况下callback包含两个逻辑:1.wait_entry自定义的私有逻辑;2.唤醒的公共逻辑,主要用于将该wait_entry的process放入CPU的就绪队列,让CPU随后可以调度其执行。

下面就上面的两大逻辑,分别阐述select、poll、epoll的异同,为什么epoll能够比select、poll高效。

3 大话Select—1024

在一个高性能的网络服务上,大多情况下一个服务进程(线程)process需要同时处理多个socket,我们需要公平对待所有socket,对于read而言,那个socket有数据可读,process就去读取该socket的数据来处理。于是对于read,一个朴素的需求就是关心的N个socket是否有数据”可读”,也就是我们期待”可读”事件的通知,而不是盲目地对每个socket调用recv/recvfrom来尝试接收数据。我们应该block在等待事件的发生上,这个事件简单点就是”关心的N个socket中一个或多个socket有数据可读了”,当block解除的时候,就意味着,我们一定可以找到一个或多个socket上有可读的数据。另一方面,根据上面的socket wakeup callback机制,我们不知道什么时候,哪个socket会有读事件发生,于是,process需要同时插入到这N个socket的sleep_list上等待任意一个socket可读事件发生而被唤醒,当时process被唤醒的时候,其callback里面应该有个逻辑去检查具体那些socket可读了。

于是,select的多路复用逻辑就清晰了,select为每个socket引入一个poll逻辑,该poll逻辑用于收集socket发生的事件,对于可读事件来说,简单伪码如下:

poll()
{
    //其他逻辑
    if (recieve queque is not empty)
    {
        sk_event |= POLL_IN;
    }
   //其他逻辑
}

接下来就到select的逻辑了,下面是select的函数原型:5个参数,后面4个参数都是in/out类型(值可能会被修改返回)

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

当用户process调用select的时候,select会将需要监控的readfds集合拷贝到内核空间(假设监控的仅仅是socket可读),然后遍历自己监控的socket sk,挨个调用sk的poll逻辑以便检查该sk是否有可读事件,遍历完所有的sk后,如果没有任何一个sk可读,那么select会调用schedule_timeout进入schedule循环,使得process进入睡眠。如果在timeout时间内某个sk上有数据可读了,或者等待timeout了,则调用select的process会被唤醒,接下来select就是遍历监控的sk集合,挨个收集可读事件并返回给用户了,相应的伪码如下:

for (sk in readfds)
{
    sk_event.evt = sk.poll();
    sk_event.sk = sk;
    ret_event_for_process;
}

通过上面的select逻辑过程分析,相信大家都意识到,select存在两个问题:

[1] 被监控的fds需要从用户空间拷贝到内核空间
    为了减少数据拷贝带来的性能损坏,内核对被监控的fds集合大小做了限制,并且这个是通过宏控制的,大小不可改变(限制为1024)。
[2] 被监控的fds集合中,只要有一个有数据可读,整个socket集合就会被遍历一次调用sk的poll函数收集可读事件
    由于当初的需求是朴素,仅仅关心是否有数据可读这样一个事件,当事件通知来的时候,由于数据的到来是异步的,我们不知道事件来的时候,有多少个被监控的socket有数据可读了,于是,只能挨个遍历每个socket来收集可读事件。

到这里,我们有三个问题需要解决:

(1)被监控的fds集合限制为1024,1024太小了,我们希望能够有个比较大的可监控fds集合 (2)fds集合需要从用户空间拷贝到内核空间的问题,我们希望不需要拷贝 (3)当被监控的fds中某些有数据可读的时候,我们希望通知更加精细一点,就是我们希望能够从通知中得到有可读事件的fds列表,而不是需要遍历整个fds来收集。

4 大话poll—鸡肋

select遗留的三个问题中,问题(1)是用法限制问题,问题(2)和(3)则是性能问题。poll和select非常相似,poll并没着手解决性能问题,poll只是解决了select的问题(1)fds集合大小1024限制问题。下面是poll的函数原型,poll改变了fds集合的描述方式,使用了pollfd结构而不是select的fd_set结构,使得poll支持的fds集合限制远大于select的1024。poll虽然解决了fds集合大小1024的限制问题,但是,它并没改变大量描述符数组被整体复制于用户态和内核态的地址空间之间,以及个别描述符就绪触发整体描述符集合的遍历的低效问题。poll随着监控的socket集合的增加性能线性下降,poll不适合用于大并发场景。

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

5 大话epoll—终极武功

select遗留的三个问题,问题(1)是比较好解决,poll简单两三下就解决掉了,但是poll的解决有点鸡肋。要解决问题(2)和(3)似乎比较棘手,要怎么解决呢?我们知道,在计算机行业中,有两种解决问题的思想:

[1] 计算机科学领域的任何问题, 都可以通过添加一个中间层来解决
[2] 变集中(*)处理为分散(分布式)处理

下面,我们看看,epoll在解决select的遗留问题(2)和(3)的时候,怎么运用这两个思想的。

5.1 fds集合拷贝问题的解决

对于IO多路复用,有两件事是必须要做的(对于监控可读事件而言):1. 准备好需要监控的fds集合;2. 探测并返回fds集合中哪些fd可读了。细看select或poll的函数原型,我们会发现,每次调用select或poll都在重复地准备(集中处理)整个需要监控的fds集合。然而对于频繁调用的select或poll而言,fds集合的变化频率要低得多,我们没必要每次都重新准备(集中处理)整个fds集合。

于是,epoll引入了epoll_ctl系统调用,将高频调用的epoll_wait和低频的epoll_ctl隔离开。同时,epoll_ctl通过(EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL)三个操作来分散对需要监控的fds集合的修改,做到了有变化才变更,将select或poll高频、大块内存拷贝(集中处理)变成epoll_ctl的低频、小块内存的拷贝(分散处理),避免了大量的内存拷贝。同时,对于高频epoll_wait的可读就绪的fd集合返回的拷贝问题,epoll通过内核与用户空间mmap(内存映射)同一块内存来解决。mmap将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换。

另外,epoll通过epoll_ctl来对监控的fds集合来进行增、删、改,那么必须涉及到fd的快速查找问题,于是,一个低时间复杂度的增、删、改、查的数据结构来组织被监控的fds集合是必不可少的了。在linux 2.6.8之前的内核,epoll使用hash来组织fds集合,于是在创建epoll fd的时候,epoll需要初始化hash的大小。于是epoll_create(int size)有一个参数size,以便内核根据size的大小来分配hash的大小。在linux 2.6.8以后的内核中,epoll使用红黑树来组织监控的fds集合,于是epoll_create(int size)的参数size实际上已经没有意义了。

5.2 按需遍历就绪的fds集合

通过上面的socket的睡眠队列唤醒逻辑我们知道,socket唤醒睡眠在其睡眠队列的wait_entry(process)的时候会调用wait_entry的回调函数callback,并且,我们可以在callback中做任何事情。为了做到只遍历就绪的fd,我们需要有个地方来组织那些已经就绪的fd。为此,epoll引入了一个中间层,一个双向链表(ready_list),一个单独的睡眠队列(single_epoll_wait_list),并且,与select或poll不同的是,epoll的process不需要同时插入到多路复用的socket集合的所有睡眠队列中,相反process只是插入到中间层的epoll的单独睡眠队列中,process睡眠在epoll的单独队列上,等待事件的发生。同时,引入一个中间的wait_entry_sk,它与某个socket sk密切相关,wait_entry_sk睡眠在sk的睡眠队列上,其callback函数逻辑是将当前sk排入到epoll的ready_list中,并唤醒epoll的single_epoll_wait_list。而single_epoll_wait_list上睡眠的process的回调函数就明朗了:遍历ready_list上的所有sk,挨个调用sk的poll函数收集事件,然后唤醒process从epoll_wait返回。 于是,整个过来可以分为以下几个逻辑:

(1)epoll_ctl EPOLL_CTL_ADD逻辑

[1] 构建睡眠实体wait_entry_sk,将当前socket sk关联给wait_entry_sk,并设置wait_entry_sk的回调函数为epoll_callback_sk
[2] 将wait_entry_sk排入当前socket sk的睡眠队列上

回调函数epoll_callback_sk的逻辑如下:

[1] 将之前关联的sk排入epoll的ready_list
[2] 然后唤醒epoll的单独睡眠队列single_epoll_wait_list

(2)epoll_wait逻辑

[1] 构建睡眠实体wait_entry_proc,将当前process关联给wait_entry_proc,并设置回调函数为epoll_callback_proc
[2] 判断epoll的ready_list是否为空,如果为空,则将wait_entry_proc排入epoll的single_epoll_wait_list中,随后进入schedule循环,这会导致调用epoll_wait的process睡眠。
[3] wait_entry_proc被事件唤醒或超时醒来,wait_entry_proc将被从single_epoll_wait_list移除掉,然后wait_entry_proc执行回调函数epoll_callback_proc

回调函数epoll_callback_proc的逻辑如下:

[1] 遍历epoll的ready_list,挨个调用每个sk的poll逻辑收集发生的事件,对于监控可读事件而已,ready_list上的每个sk都是有数据可读的,这里的遍历必要的(不同于select/poll的遍历,它不管有没数据可读都需要遍历一些来判断,这样就做了很多无用功。)
[2] 将每个sk收集到的事件,通过epoll_wait传入的events数组回传并唤醒相应的process。

(3)epoll唤醒逻辑 整个epoll的协议栈唤醒逻辑如下(对于可读事件而言):

[1] 协议数据包到达网卡并被排入socket sk的接收队列
[2] 睡眠在sk的睡眠队列wait_entry被唤醒,wait_entry_sk的回调函数epoll_callback_sk被执行
[3] epoll_callback_sk将当前sk插入epoll的ready_list中
[4] 唤醒睡眠在epoll的单独睡眠队列single_epoll_wait_list的wait_entry,wait_entry_proc被唤醒执行回调函数epoll_callback_proc
[5] 遍历epoll的ready_list,挨个调用每个sk的poll逻辑收集发生的事件
[6] 将每个sk收集到的事件,通过epoll_wait传入的events数组回传并唤醒相应的process。

epoll巧妙的引入一个中间层解决了大量监控socket的无效遍历问题。细心的同学会发现,epoll在中间层上为每个监控的socket准备了一个单独的回调函数epoll_callback_sk,而对于select/poll,所有的socket都公用一个相同的回调函数。正是这个单独的回调epoll_callback_sk使得每个socket都能单独处理自身,当自己就绪的时候将自身socket挂入epoll的ready_list。同时,epoll引入了一个睡眠队列single_epoll_wait_list,分割了两类睡眠等待。process不再睡眠在所有的socket的睡眠队列上,而是睡眠在epoll的睡眠队列上,在等待”任意一个socket可读就绪”事件。而中间wait_entry_sk则代替process睡眠在具体的socket上,当socket就绪的时候,它就可以处理自身了。

上一篇:hive的join优化


下一篇:TCP/IP协议栈在Linux内核中的运行时序分析