【Linux网络编程】多路转接IO(二)epoll
目录
epoll初识
epoll的相关系统调用
epoll的工作原理
epoll的优点
epoll的工作方式
水平触发 Level Triggered 工作模式
边缘触发 Edge Triggered 工作模式
对比LT和ET
理解 ET 模式和非阻塞文件描述符
epoll的惊群问题
基于LT模式的epoll代码样例
epoll初识
按照man手册的说法是为了处理大量句柄而做了改进的poll
Epoll 是 Linux 特有的高性能 I/O 多路复用机制,专为处理大量文件描述符设计,克服了 select/poll 的性能瓶颈。
epoll的相关系统调用
#include <sys/epoll.h>// 创建 epoll模型
int epoll_create(int size);// 控制 epoll 模型(增删改)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);// 等待事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
(1)int epoll_create(int size);
epoll_create的返回值是一个文件描述符,因此在使用完毕后,也要调用close进行关闭。
(2)int epoll_ctl(int epfd,int op, int fd, struct epoll_event*event);epoll事件注册函数。
- 第一个参数是epoll_create的返回值(epoll句柄);
- 第二个参数表示动作,增加,删除,修改;
- 第三个参数是需要监听的文件描述符fd;
- 第四个参数表示告诉内核要监听哪些事情;
第二个参数的取值:
- EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
- EPOLL_CTL_MOD:修改已经注册的 fd 的监听事件;
- EPOLL_CTL_DEL:从 epfd 中删除一个 fd;
第四个参数的类型:
重要的两个成员是events表示要关心的事件,还有一个fd表示文件描述符。
其中events表示要关心的事件,使用以下宏来表示:
- EPOLLIN : 表示对应的文件描述符可以读 (包括对端 SOCKET 正常关闭);
- EPOLLOUT : 表示对应的文件描述符可以写;
- EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);
- EPOLLERR : 表示对应的文件描述符发生错误;
- EPOLLHUP : 表示对应的文件描述符被挂断;
- EPOLLET : 将 EPOLL 设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的;
- EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个 socket 的话, 需要再次把这个 socket 加入到 EPOLL 队列里。
(3)int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
参数events是输出型参数,就绪的文件描述符和事件通过这个参数给我们返回。
maxevents 告之内核这个 events 有多大,这个 maxevents 的值不能大于创建epoll_create()时的 size
参数 timeout 是超时时间 (毫秒,0 会立即返回,-1 是永久阻塞)。
如果函数调用成功,返回对应 I/O 上已准备好的文件描述符数目,如返回 0 表示已超时, 返回小于 0 表示函数失败。
epoll的工作原理
首先当我们调用epoll_create创建一个epoll模型时,内核会为我们创建一颗红黑树。使用这颗红黑树来管理文件描述符和其对应要关心的事件。
红黑树传送门:【C++篇】红黑树的实现_红黑树代码实现-CSDN博客
同时内核还会维护一个就绪队列,当某个或几个文件描述符就绪时,就会将对应的节点链入到就绪队列中,同时通知上层,将 就绪的文件描述符通知给上层。
在网络协议栈中,存在一种回调机制:当底层特定的文件描述符上有数据时,会自动进行回调,找到对应的红黑树节点,并将该节点链入到就绪队列中,这个过程的事件复杂度是O(1)的。
可以总结为如下图所示:
epoll_ctl就是对这颗红黑树进行增删改,而epoll_wait就是从就绪队列中获取就绪的文件描述符。
关于就绪队列,其本质就是一个基于事件就绪的生产者消费者模型,当有事件就绪时,就会通知上层将事件拿走,如果没有事件就绪时(就绪队列为空),就会等待timeout的时间。
当我们调用epoll_wait 获取就绪的事件时,需要我们传入一个struct epoll_event的数组,内核会依次拷贝就绪队列中的事件到我们传入的数组events中,下标从0开始。同时epoll_wait的返回值表示事件就绪的个数。因此,在应用层处理事件就绪的时候,一般不需要非法检测。
还有,epoll接口是线程安全的,其内部已经加锁了。
细节点:当我们调用epoll_ctl向底层的红黑树中插入节点的时候,还会向底层注册一个回调方法,当该文件描述符就绪的时候,会立即找到对应的红黑树节点,然后将该节点链入到就绪队列中。
epoll的优点
接口使用方便:虽然拆分成了三个函数,但是反而使用起来更方便高效。不需要每次循环都设置关注的文件描述符,也做到了输入输出参数分离开。相当于弥补了select的缺点。
数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频繁(而 select/poll 都是每次循环都要进行拷贝)
事件回调机制:避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中,epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪。这个操作时间复杂度 O(1)。即使文件描述符数目很多,效率也不会受到影响。
没有数量限制:文件描述符数目无上限。
epoll的工作方式
epoll 有 2 种工作方式:水平触发(LT)和边缘触发(ET)
水平触发 Level Triggered 工作模式
当 epoll 检测到 socket 上事件就绪的时候,可以不立刻进行处理,或者只处理一部分。
在第二次调用epoll_wait 时,epoll_wait 仍然会立刻返回并通知 socket 读事件就绪。
直到缓冲区上所有的数据都被处理完,epoll_wait 才不会立刻返回。
支持阻塞读写和非阻塞读写。
边缘触发 Edge Triggered 工作模式
当 epoll 检测到 socket 上事件就绪时, 必须立刻处理。
也就是说, ET 模式下, 文件描述符上的事件就绪后, 只有一次处理机会。
使用ET的工作方式,就要求我们必须把缓冲区中的数据读完,所以我们上层在调用read的时候,必须循环读取,但是我们不知道什么时候读完,所以可能会出现数据读完了,循环再次调用read,但此时缓冲区中的数据为空,这时程序就会阻塞在read这里,直到缓冲区中有数据了。
所以使用ET的工作方式,必须支持非阻塞的读写。当数据读取完后,全局的错误码errno会被设置为EAGAIN或EWOULDBLOCK。所以我们可以根据错误码,判断是否读取完数据。
ET 的性能比 LT 性能更高( epoll_wait 返回的次数少了很多)。
select 和 poll 其实也是工作在 LT 模式下。epoll 既可以支持 LT,也可以支持 ET。
对比LT和ET
LT 是 epoll 的默认行为。
使用 ET 能够减少 epoll 触发的次数。但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完。
相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些。但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话,其实性能也是一样的。另一方面,ET 的代码复杂程度更高了。
理解 ET 模式和非阻塞文件描述符
使用 ET 模式的 epoll,需要将文件描述设置为非阻塞。这个不是接口上的要求,而是 "工程实践" 上的要求。
假设这样的场景:服务器接收到一个 10k 的请求,会向客户端返回一个应答数据。如果客户端收不到应答,不会发送第二个 10k 请求。
如果服务端写的代码是阻塞式的 read,并且一次只 read 1k 数据的话(read 不能保证一次就把所有的数据都读出来,参考 man 手册的说明,可能被信号打断),剩下的 9k 数据就会待在缓冲区中。
此时由于 epoll 是 ET 模式,并不会认为文件描述符读就绪。epoll_wait 就不会再次返回。剩下的 9k 数据会一直在缓冲区中。直到下一次客户端再给服务器写数据,epoll_wait 才能返回。
但是问题来了:
- 服务器只读到 1k 个数据,要 10k 读完才会给客户端返回响应数据。
- 客户端要读到服务器的响应, 才会发送下一个请求。
- 客户端发送了下一个请求,epoll_wait 才会返回,才能去读缓冲区中剩余的数据。
所以, 为了解决上述问题(阻塞 read 不一定能一下把完整的请求读完), 于是就可以使用非阻塞轮询的方式来读缓冲区,保证一定能把完整的请求都读出来。
而如果是 LT 没这个问题。只要缓冲区中的数据没读完,就能够让 epoll_wait 返回文件描述符读就绪。
epoll的惊群问题
惊群问题是指在多进程/多线程中,当多个进程/线程同时等待同一个事件(如一个socket连接),当该事件发生时,所有的进程/线程被唤醒,但最终只有一个进程/线程能成功处理该事件,而其他进程/线程被唤醒后发现无事可做,造成资源浪费的现象。
在epoll中,惊群问题主要出现在以下场景:
多进程共享同一个监听socket:多个进程(例如通过fork创建的子进程)共享同一个监听套接字,并且每个进程都使用epoll_wait来等待该套接字上的连接事件(EPOLLIN)。当一个新的连接到来时,所有进程的epoll_wait都会被唤醒,但只有一个进程能够成功调用accept获取这个连接,其他进程的accept将返回EAGAIN或EWOULDBLOCK错误(在非阻塞模式下)或者阻塞(在阻塞模式下),这导致不必要的上下文切换和资源浪费。
多线程下的解决方案:
这种情况,不建议让多个线程同时在epoll_wait监听的socket,而是让其中一个线程epoll_wait监听的socket,当有新的链接请求进来之后,由epoll_wait的线程调用accept,建立新的连接,然后交给其他工作线程处理后续的数据读写请求,这样就可以避免了由于多线程环境下的epoll_wait惊群效应问题。
多进程下的解决方案:
在多个进程中使用一个共享锁,在调用accept之前先获取锁,这样只有一个进程能够进入accept。但是这样会导致其他进程在锁上阻塞,而且锁的竞争也会带来开销,因此不推荐。
在Linux 4.5及以后的内核中,可以在epoll_ctl添加监听套接字时,在events中设置EPOLLEXCLUSIVE标志。这样,当一个新的连接到来时,内核只会唤醒一个等待在epoll_wait上的进程(或线程),从而避免惊群。
基于LT模式的epoll代码样例
仓库连接:Epoll_Server · 小鬼/linux学习 - 码云 - 开源中国