80-33 IO 模型-多路复用 epoll
背景
epoll 是在 2.6 内核中提出的,是之前的 select 和 poll 的增强版本。相对于 select 和 poll 来说,epoll 更加灵活,没有描述符限制。epoll 使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的 copy 只需一次。
epoll 接口
epoll 操作过程需要三个接口,分别如下:
1 |
|
epoll_create:创建一个 struct eventpoll 的内核对象
1 |
|
struce eventpoll
1 |
|
- wq:等待队列,如果当前进程没有数据需要处理,会把当前进程描述符和回调函数 default_wake_functon 构造一个等待队列项,放入当前 wq 对待队列,软中断数据就绪的时候会通过 wq 来找到阻塞在 epoll 对象上的用户进程。
- rbr:一棵红黑树,管理用户进程下添加进来的所有 socket 连接。
- rdllist:就绪的描述符的链表。当有的连接数据就绪的时候,内核会把就绪的连接放到 rdllist 链表里。这样应用进程调用 epoll_wait 时,只需要判断链表就能找出就绪进程,而不用去遍历整棵树。
epoll_ctl:向 eventpoll 对象中添加或删除一些套接字,将 epitem 添加到 eventpoll 的红黑树中。
1 |
|
epoll_ctl 函数主要负责把服务端和客户端建立的 socket 连接注册到 eventpoll 对象里,会做三件事:
创建一个 epitem 对象,主要包含两个字段,分别存放 socket fd 即连接的文件描述符,和所属的 eventpoll 对象的指针;
将一个数据到达时用到的回调函数添加到 socket 的进程等待队列中,注意,跟阻塞 IO 模式不同的是,这里添加的 socket 的进程等待队列结构中,只有回调函数,没有设置进程描述符,因为在 epoll 中,进程是放在 eventpoll 的等待队列中,等待被 epoll_wait 函数唤醒,而不是放在 socket 的进程等待队列中;
将第 1 步创建的 epitem 对象插入红黑树;
简单说:epoll_ctl()
:向 epoll 对象中添加或删除一些套接字,将 epitem 添加到 epoll 的红黑树中。所有添加到 epoll 中的事件都会与设备(网卡)驱动程序建立回调关系,当相对应的事件发生时会调用这个回调方法,这个回调方法在内核中叫ep_poll_callback
,它将发生的事件的 epitem 结构体添加到 rdlist 双链表中
struct epitem
在 epoll 中,对于每一个事件,都会建立一个 epitem 结构体,如下所示:
1 |
|
当调用 epoll_wait()
检查是否有事件发生时,只需要检查 eventpoll
对象中的 rdlist
双向链表中是否有 epitem
元素即可。如果 rdllist
链表不为空,则把发生的事件复制到用户态,同时把事件的数量返回给用户。
struct epoll_event
1 |
|
- events:是一组标记,系统所感兴趣的事件和可能发生的返回事件,如EPOLLIN(可读)、EPOLLOUT(可写)、EPOLLET(设置为边缘触发模式)、EPOLLONESHOT(一次性的)、EPOLLRDHUP(对端断开连接或者关闭写操作的一种表示)等。
- data:用于存储用户数据,可以是一个指针,也可以是一个整型的标识符。
epoll_wait:等待 epoll 中监听文件描述符就绪的 I/O 事件
1 |
|
epoll_wait 函数的动作比较简单,检查 eventpoll 对象的就绪的连接 rdllist 上是否有数据到达,如果没有就把当前的进程描述符添加到 eventpoll 的进程等待队列里,然后阻塞当前进程,等待数据到达时通过回调函数被唤醒。
当 eventpoll 监控的连接上有数据到达时,通过下面几个步骤唤醒对应的进程处理数据:
socket 的数据接收队列有数据到达,会通过进程等待队列的回调函数 ep_poll_callback 唤醒红黑树中的节点 epitem;
ep_poll_callback 函数将有数据到达的 epitem 添加到 eventpoll 对象的就绪队列 rdllist 中;
ep_poll_callback 函数检查 eventpoll 对象的进程等待队列上是否有等待项,通过回调函数 default_wake_func 唤醒这个进程,进行数据的处理;
当进程醒来后,继续从 epoll_wait 时暂停的代码继续执行,把 rdlist 中就绪的事件返回给用户进程,让用户进程调用 recv 把已经到达内核 socket 等待队列的数据拷贝到用户空间使用。
从阻塞 IO 到 epoll 的实现中,我们可以看到 wake up 回调函数机制被频繁的使用,至少有三处地方:
- 一是阻塞 IO 中数据到达 socket 的等待队列时,通过回调函数唤醒进程,
- 二是 epoll 中数据到达 socket 的等待队列时,通过回调函数 ep_poll_callback 找到 eventpoll 中红黑树的 epitem 节点,并将其加入就绪列队 rdllist,
- 三是通过回调函数 default_wake_func 唤醒用户进程 ,并将 rdllist 传递给用户进程,让用户进程准确读取数据。
从中可知,这种回调机制能够定向准确的通知程序要处理的事件,而不需要每次都循环遍历检查数据是否到达以及数据该由哪个进程处理,提高了程序效率,在日常的业务开发中,我们也可以借鉴下这一机制。
原理
讲解完了 epoll 的机理,我们便能很容易掌握 epoll 的用法了。一句话描述就是:三步曲。
第一步:
epoll_create()
系统调用。此调用返回一个 epoll 实例,该实例是 eventpoll 结构体,结构体中含有关键成员(存放 epoll 中的事件的红黑树根节点 rbr、保存 epoll_wait 返回的事件的双向链表 rdllist),同时返回一个引用该实例的 fd。所以在使用完 epoll 后,必须调用 close() 关闭对应的文件描述符。第二步:
epoll_ctl()
系统调用。通过此调用绑定 fd 到 epoll 实例,将 fd 添加到 epoll 实例的监听列表(红黑树),同时为 fd 设置一个回调函数监听事件 event。- 当 fd 上发生相应事件时,会调用回调函数,将 fd 添加到 epoll 实例的就绪队列(rdlist 双链表)上。
第三步:
epoll_wait()
系统调用。通过此调用收集收集在 epoll 监控中已经发生的事件。只需要判断就绪列表(rdlist 双链表)是否为空即可。这样,在没有描述符就绪时,epoll 能更早地让出系统资源。
工作模式
epoll 对文件描述符的操作有两种模式:LT(level trigger)和 ET(edge trigger):
LT 模式(默认模式):只要该 fd 还有数据可读,每次 epoll_wait 都会返回它的事件,提醒用户程序去操作。
ET 模式:只会提示一次,直到下次再有数据流入之前都不会再提示,无论 fd 中是否还有数据可读。 所以在 ET 模式下,read 一个 fd 的时候一定要把它的 buffer 读完,即读到 read 返回值小于请求值或遇到 EAGAIN 错误。
ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
看到这有人会问,这两种模式的使用会造成哪种不一样的结果?
如果此时一个客户端同时发来了 5 个数据包,按正常的逻辑,只需要唤醒一次 epoll ,把当前 socket 加一次到 ready_list 就行了,不需要加 5 次。然后用户程序可以把 socket 接收队列的所有数据包都读完。
但假设用户程序就读了一个包,然后处理报错了,后面不读了,那后面的 4 个包咋办?
如果是 ET 模式,就读不了了,因为没有把 socket 加入到 ready_list 的触发条件了。除非这个客户端发了新的数据包过来,这样才会再把当前 socket 加入到 ready_list,在新包过来之前,这 4 个数据包都不会被读到。
而 LT 模式不一样,因为每次读完有感兴趣的事件发生之后,会把当前 socket 再加入到 ready_list,所以下次肯定能读到这个 socket,所以后面的 4 个数据包会被访问到,不论客户端是否发送新包。
优缺点
优点:
在
epoll_ctl()
函数中,为每个文件描述符都指定了回调函数,基于回调函数把就绪事件放到就绪队列中,因此,把时间复杂度从0(n)降到了0(1)只需要在
epoll_ct1()
时传递一次文件描述符,epoll_wait()
不需要再次传递文件描述符。epoll 基于 红黑树 + 双链表 存储 没有最大连接数的限制,不存在 C1OK 问题。
注意:epoll 没有使用 MMAP 零拷贝技术。
epoll 所支持的 FD 上限是最大可以打开文件的数目,这个数字一般远大于 1024,举个例子,在 1GB 内存的机器上大约是 10 万左右,具体数目可以下面语句查看,一般来说这个数目和系统内存关系很大。
1 |
|
伪代码
1 |
|
代码
c
服务器代码如下所示:
1 |
|
客户端也用 epoll 实现,控制 STDIN_FILENO、STDOUT_FILENO、和 sockfd 三个描述符,程序如下所示:
1 |
|
Reference
- https://www.cnblogs.com/Anker/p/3263780.html
- https://segmentfault.com/a/1190000003063859#item-3-11
- https://www.cnblogs.com/shuqin/p/11772651.html
- https://zhuanlan.zhihu.com/p/427512269
- https://cloud.tencent.com/developer/article/1005481
- https://www.163.com/dy/article/HO0L979A0518R7MO.html
- https://www.modb.pro/db/1773185223680921600
- https://www.jb51.net/program/290127gwn.htm