epoll分析

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u010318270/article/details/88561506

一、用户态epoll用法

#include <sys/epoll.h>

int epoll_create(int size);
// 创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。
// 当创建好epoll句柄后,它就会占用一个fd值。在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
// 返回值:非负的文件描述符(fd)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 事件注册函数
// epfd:epoll_create()的返回值
// op:表示动作
EPOLL_CTL_ADD:注册新的fd到epfd;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除一个fd;
// fd:需要监听的fd
// event:告诉内核需要监听什么事件
	typedef union epoll_data {
	   void        *ptr;
	   int          fd;
	   __uint32_t   u32;
	   __uint64_t   u64;
	} epoll_data_t;

	struct epoll_event {
	   __uint32_t   events;      /* Epoll events */
	   epoll_data_t data;        /* User data variable */
	};
// events表示对应的文件描述符可以进行的操作
EPOLLIN:可读;
EPOLLOUT:可写;
EPOLLRDHUP:[TODO];
EPOLLPRI:有紧急的数据可读;
EPOLLERR:发生错误;
EPOLLHUP:被挂断;
EPOLLET:将EPOLL设置为边缘触发模式(Edge Triggered);
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket,需要再次把这个socket加入到EPOLL队列里。
// 返回值:成功返回0
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
// 等待事件的产生
// events:从内核得到事件的集合
// maxevents:告诉内核这个events有多大,该值不能大于epoll_create()时的size
// timeout:超时时间,毫秒,0表示立即返回,-1表示不确定也就是永久阻塞
// 返回值:需要处理的fd数目,0表示timeout

二、Linux的socket事件wakeup callback机制
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随后可以调度其执行。

三、epoll解决了哪些问题
1、fds集合拷贝问题的解决
对于IO多路复用,有两件事是必须要做的(对于监控可读事件而言):
(1)准备好需要监控的fds集合;
(2)探测并返回fds集合中哪些fd可读了。

每次调用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集合是必不可少的。
Linux2.6.8之前的内核,epoll使用hash来组织fds集合,于是在创建epoll fd的时候,epoll需要初始化hash的大小。于是epoll_create(int size)有一个参数size,以便内核根据size的大小来分配hash的大小。Linux2.6.8以后的内核中,epoll使用红黑树来组织监控的fds集合,于是epoll_create(int size)的参数size实际上已经没有意义了。
2、按需遍历就绪的fds集合
socket睡眠队列的唤醒逻辑中,socket事件发生时,会调用每个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排入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就绪的时候,它就可以处理自身了。
 

四、ET(Edge Triggered 边沿触发) vs LT(Level Triggered 水平触发) 

1、ET vs LT 概念
(1)ET 边沿触发
socket的接收缓冲区状态变化时触发读事件,即空的接收缓冲区刚接到数据时触发读事件
socket的发送缓冲区状态变化时触发写事件,即满的发送缓冲区刚发送数据时触发写事件
仅在缓冲区状态变化时触发事件,比如数据缓冲区从无到有的时候(不可读->可读)
(2)LT 水平触发
socket接收缓冲区不为空,有数据可读时,则读事件一直触发
socket发送缓冲区不满,可以继续写入数据,则写事件一直触发

epoll唤醒逻辑的第五步
[5]遍历epoll的ready_list,挨个调用每个sk的poll逻辑收集发生的事件挂在ready_list上的sk什么时候会被移除掉呢,sk从ready_list移除的时机正是区分两种事件模式的本质。
ready_list是否为空是epoll_wait是否返回的条件。于是,在两种模式下,步骤5如下:

ET 边沿触发
[5]遍历epoll的ready_list,将sk从ready_list中移除,然后调用该sk的poll逻辑收集发生的事件
LT 水平触发
[5.1]遍历epoll的ready_list,将sk从ready_list中移除,然后调用该sk的poll逻辑收集发生的事件
[5.2]如果该sk的poll函数返回了关心的事件(对于可读事件来说,就是POLL_IN事件),那么该sk被重新加入到epoll的ready_list中

对于可读事件而言,在ET模式下,如果某个socket有新的数据到达,那么该sk就会被排入epoll的ready_list,从而epoll_wait就一定能收到可读事件的通知(调用sk的poll逻辑一定能收集到可读事件)。于是,我们通常理解的缓冲区状态变化(从无到有)的理解是不准确的,准确的理解应该是是否有新的数据到达缓冲区。

在LT模式下,某个sk被探测到有数据可读,那么该sk会被重新加入到ready_list,那么在该sk的数据被全部取走前,下次调用epoll_wait就一定能够收到该sk的可读事件(调用sk的poll逻辑一定能收集到可读事件),从而epoll_wait就能返回。

2、ET vs LT 性能
对于可读事件而言,LT比ET多了两个操作:
(1)对ready_list遍历的时候,对于收集到的可读事件sk会重新放入ready_list
(2)下次epoll_wait的时候会再次遍历上次重新放入的sk,如果sk本身没有数据可读了,那么这次遍历就变得多余了。

在服务端有海量活跃socket的时候,LT模式下,epoll_wait返回的时候,会有海量的socket sk重新放入ready_list。如果用户在第一次epoll_wait返回的时候,将有数据的socket都处理掉了,那么下次epoll_wait的时候,上次epoll_wait重新放入ready_list的
sk再次遍历就有点多余,这时LT模式确实会带来一些性能损失。

先不说第一次epoll_wait返回的时候,用户进程能否都将有数据返回的socket处理掉。在用户处理的过程中,如果该socket有新的数据上来,那么协议栈发现sk已经在ready_list中了,就不需要再次放入ready_list,也就是在LT模式下,对该sk的再次遍历不是多余的,是有效的。

epoll高效的场景在于,服务器有海量socket,但是活跃socket较少的情况下才会体现出epoll的高效、高性能。因此,在实际的应用场合,绝大多数情况下,ET模式在性能上并不会比LT模式具有压倒性的优势。

3、ET vs LT 复杂度
对于可读事件,在阻塞模式下,是无法识别队列空的事件的,并且事件通知机制仅仅是通知有数据,并不会通知有多少数据。
于是,在阻塞模式下,在epoll_wait返回的时候,我们对某个socket_fd调用recv或read读取并返回一些数据的时候,我们不能再次直接调用recv或read,因为,如果socket_fd已经无数据可读的时候,进程就会阻塞在该socket_fd的recv或read调用上,这样就影响了IO多路复用的逻辑(我们希望阻塞在所有被监控socket的epoll_wait调用上,而不是单独某个socket_fd上),造成其他socket饿死,即使有数据来,也无法处理。

接下来,我们只能再次调用epoll_wait来探测一些socket_fd,看是否还有数据可读。在LT模式下,如果socket_fd还有数据可读,那么epoll_wait就一定能够返回,接着我们就可以对该socket_fd调用recv或read读取数据。然而,在ET模式下,尽管socket_fd还有数据可读,但是如果没有新的数据上来,epoll_wait是不会通知可读事件的,这个时候epoll_wait阻塞住了。这下子坑爹了,明明有数据你不处理,非要等新的数据来了再处理,那么就只能死扛了。

正是因为有这样的缺点,ET强制需要在非阻塞模式下使用。在ET模式下,epoll_wait返回socket_fd有数据可读,我们必须要读完所有数据才能离开。因为如果读不完,epoll不会再通知你了,虽然有新数据到来时会再次通知,但是我们并不知道新数据会不会来,什么时候会来。在ET模式下,采用非阻塞模式,listen_fd返回可读的时候,我们需要不断的accept直到EAGAIN

另外一个场景,ET模式下,假设同时有三个请求到达,epoll_wait返回listen_fd可读,这时如果仅仅accept一次拿走一个请求去处理,那么就会留下两个请求,如果这个时候一直没有新的请求到达,那么再次调用epoll_wait是不会通知listen_fd可读的,于是epoll_wait只能睡眠到超时才返回,遗留下来的两个请求一直得不到处理,处于饿死状态。

epoll_wait原本的语意是:监控并探测socket是否有数据可读(对于读事件而言)。LT模式保留了其原本的语音,只要socket还有数据可读,它就能不断反馈,于是我们想什么时候读取处理都可以,我们永远有再次poll的机会去探测是否有数据可以处理,这样带来了编程上的很大方便,不容易死锁造成某些socket饿死。相反,ET模式修改了epoll_wait原本的语意,变成了:监控并探测socket是否有新的数据可读。

4、ET vs LT epoll_wait返回条件

(1)ET模式-读操作
[1]当接收缓存buffer内待读数据增加的时候(由空变为不空的时候,或者有新的数据进入缓冲buffer)
[2]调用epoll_ctl(EPOLL_CTL_MOD)来改变socket_fd的监控事件,也就是重新mod socket_fd的EPOLLIN事件,并且接收缓存buffer内还有数据没读取。
因为epoll_ctl(ADD或MOD)会调用sk的poll逻辑来检查是否有关心的事件,如果有就会将该sk加入到epoll的ready_list中,下次调用epoll_wait的时候就会遍历到该sk,然后会重新收集到关心的事件返回。

(2)ET模式-写操作
[1]发送缓存buffer内待发送的数据减少的时候(由满状态变为不满状态的时候,或者有部分数据被发出去的时候)
[2]调用epoll_ctl(EPOLL_CTL_MOD)来改变socket_fd的监控事件,也就是重新mod socket_fd的EPOLLOUT事件,并且发送缓存buffer内还没满的时候。

(3)LT模式-读操作
[1]接收缓冲buffer内有可读数据的时候

(4)LT模式-写操作
[1]发送缓存buffer还没满的时候

猜你喜欢

转载自blog.csdn.net/u010318270/article/details/88561506