浅析I/O多路转接epoll技术
前面的两篇博客我们已经为大家介绍了select和poll函数,但是在学习中我们发现select和poll存在效率上的问题。而今天的主角epoll函数真的是让人惊艳的设计,它是在2.5.44内核中被引进的,它几乎具备了之前所说的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法。包括现在最火的nginx服务器底层使用的也是epoll多路转接
epoll函数
要想在知道他为什么这么高效之前我们先来看看这个函数是如何使用的,所谓要学会跑就得先学会走就是这个道理
epoll函数有三个相关的系统调用:
int epoll_create(int size);
参数是一个int类型的整数,这个数字随便填,在2.6以后就被忽略了相当于是一个历史遗留问题。这里的返回值比较重要,epoll返回一个句柄,这个句柄能帮我们找到之后要使用的所有epoll机制。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数介绍:
- epfd:传入epoll_create函数的返回值,也就是文件句柄
- fd:传入你所要关心的文件描述符
- op:你想对要关心的文件描述符做什么操作。EPOLL_CTL_ADD选项注册新的fd到epfd中、EPOLL_CTL_MOD 选项修改已经注册的fd的监听事件、EPOLL_CTL_DEL选项从epfd中删除一个fd
- event:可以看出这个参数和所关心的事件有关
events是一个位图,其中设置你所希望关心的事件。data中填充你所关心的fd
事件 | 描述 |
---|
EPOLLIN | 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭); |
EPOLLOUT | 表示对应的文件描述符可以写 |
EPOLLPRI | 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来) |
EPOLLERR | 表示对应的文件描述符发生错误(默认被关心) |
EPOLLHUP | 表示对应的文件描述符被挂断(默认被关心) |
EPOLLET | 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的 |
EPOLLONESHOT | 只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里. |
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
参数介绍:
- epfd:epoll句柄
- events:是分配好的epoll_event结构体数组,epoll将会把发生的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存).
- maxevents:告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size.
- timeout:是超时时间 (毫秒,0会立即返回,-1是永久阻塞). 如果函数调用成功,返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时, 返回小于0表示函数失败
浅谈epoll函数是如何做到高效的
ps:接下来可能说到的有些知识有的同学可能不太懂,不过现在搜索引擎这么强大,相信你能找到你不懂知识的答案的。
其实笔者在拿到epoll的这三个函数时是懵的,因为第一个create函数就让我产生了极大的困惑,那么我们就从第一个函数说起,看看创建这个句柄到底干了什么。
话说Linux下一切皆文件,在操作系统的内核中也同样如此。epoll向内核注册了一个虚拟文件系统,这个文件系统用来管理被监测的文件描述符,而epoll_create函数时为我们创建一个属于该文件系统的文件并返回。每个由epoll_create创建的文件都会得到一个struct eventpoll结构体,这个结构体被保存在file结构体的private_data中。这个结构体是用来干啥的呢?一起来看看他的成员
struct eventpoll { spinlock_t lock; struct mutex mtx; wait_queue_head_t wq; wait_queue_head_t poll_wait; struct list_head rdllist; struct rb_root rbr; struct epitem *ovflist; struct user_struct *user;
};
其余的东西不用太关心,但是有俩个东西非常重要。一个是红黑树的头节点,一个是就绪文件描述符链表。这俩个东西就是让epoll机制效率极大提升的神器。
我简单描叙一下这些部分都是用来干什么的。
- struct eventpoll这个结构体在笔者看来他就像是一个事件管理器。之所以这么说是因为他管理着epoll系统中的红黑树,就绪队列和等待队列。
- 红黑树:红黑树是一颗二叉搜索树,也叫次平衡树。他插入删除查找的效率都是nlogn。这颗红黑树中存储着所有添加到epoll中的需要监控的事件
- rdllist:这个链表中存放的是已经就绪的事件
- wait_queue:这个队列中存放着被检测的事件,一但有事件就绪,那么就通过回调机制告诉上级,并让上级将就绪的事件移动到rdllist中。
这里额外需要提到的是,在epoll中每个事件都被一个epitem结构体描述:
struct epitem{ struct rb_node rbn;struct list_head rdllink;struct epoll_filefd ffd; struct eventpoll *ep; struct epoll_event event;
}
ffd中存放所关心事件的fd和file结构体,其余的参数都比较好理解。
为什么epoll是高效的
现在我们来谈一谈为什么epoll是高效的,其实通过上面的模型你大体已经可以发现他高效的原因:
- 查询就绪事件速度:想一想我们之前poll和select最大的瓶颈在哪里?没错,就是有事件就绪后一遍遍的遍历。而现在有了就绪队列之后呢,查询的速度变成了惊人的O(1),其实也就是说处于此队列中的事件一定就绪了,这归功于epoll的回调机制,这种机制与相应的文件描述符绑定在一起,当文件描述符就绪时就调用某个函数,将此事件添加到就绪队列中
- 不在需要每次都拷贝数据到内核:select和poll中每次都要将位图或者数组进行拷贝,而epoll不是完全不拷贝,而是每次只拷贝少量数据。你所关心的事件从头到尾拷贝到内核并注册到红黑树只需要一次。就绪队列每次需要拷贝到用户空间一次,不过代价真的变得非常小了。
这是比较重要的两条原因,其实epoll已经解决了文件描述符有上限和接口设计不友好的等等问题。并且使用红黑树在进行不重复的插入和进行删除时都比数组查询的ON要快的多。
有的同学会说,epoll底层不是使用了内存映射么?这里为什么需要进行数据拷贝呢?注意注意注意!笔者之前点开b站有些自称为epoll深度解析的大佬张口就是内存映射,事实上epoll底层并没有使用映射这种机制,有的人也会质疑我凭什么你说没有就没有。质疑是种好习惯,为了找到事情的真相,下篇博客不如我们就来探究epoll的底层是如何实现的吧。
探究epoll的工作模式
在说epoll的工作模式之前我们先来举两个栗子来帮助我们更简单的理解epoll的工作模式。
- 栗子一:你的妈妈是亲妈,你放假时特别喜欢玩游戏,而饭好时你妈妈就会叫你吃饭。叫了你一次之后,你没有去,你妈妈又来喊了你一次,你还是没有去,过了一会你妈妈又来喊了一次…
- 栗子二:你的妈妈是后妈,你放假时特别喜欢玩游戏,而饭好时你妈妈就会叫你吃饭。叫了你一次之后,你没有去,全剧终。
上面俩个栗子其实对应了epoll的两张工作模式,前者称为水平触发Level Triggered 工作模式,后者称为边缘触发Edge Triggered工作模式,默认的情况下水平触发是epoll默认的工作模式,那么在epoll中怎么理解呢?
水平触发
- 当epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分.
- 就绪描述符中有2k数据,只读1K数据, 缓冲区中还剩1K数据, 在第二次调用 epoll_wait 时, epoll_wait仍然会立刻返回并通知socket读事件就绪,直到缓冲区上所有的数据都被处理完, epoll_wait 才不会立刻返回.
- 支持阻塞读写和非阻塞读写
边缘触发
- 当epoll检测到socket上的事件就绪的时候,必须立即进行处理
- 上面的栗子中你先处理1k的数据,缓存区中还存在1k的数据,而你下次调用epoll_wait时epoll_wait就不会在返回了,也就是说在ET模式下文件描述符上的事件就绪后只有一次处理机会。
- ET模式下比LT性能更高,因为epoll的返回次数变少了,nginx默认的模式就是ET模式
- 只支持非阻塞的读写
对比LT和ET:其实假如LT模式下每次提示都立刻处理,且每次都将数据读完避免多次提示那么效率与ET也不会差太多
理解ET模式和非阻塞文件描述符
使用 ET 模式的 epoll,需要将文件描述设置为非阻塞.。这个不是接口上的要求,,而是 “工程实践” 上的要求。
假设场景,服务器接受到一个10k的请求,会向客户端返回一个应答数据.。如果客户端收不到应答,不会发送第二个10k请求
如果服务端写的代码是阻塞式的read,并且一次只 read 1k 数据的话(read不能保证一次就把所有的数据都读出来,参考 man 手册的说明,可能被信号打断),剩下的9k数据就会待在缓冲区中
所以, 为了解决上述问题(阻塞read不一定能一下把完整的请求读完), 于是就可以使用非阻塞轮训的方式来读缓冲区,保证一定能把完整的请求都读出来.
而如果是LT没这个问题. 只要缓冲区中的数据没读完, 就能够让 epoll_wait 返回文件描述符读就绪.
epoll的使用场景一般为:对于多连接, 且多连接中只有一部分连接比较活跃时, 比较适合使用epoll.
epoll的惊群问题
产生惊群问题的原因:
- 在多线程或者多进程环境下,有些人为了提高程序的稳定性,往往会让多个线程或者多个进程同时在epoll_wait监听的socket描述符。当一个新的链接请求进来时,操作系统不知道选派那个线程或者进程处理此事件,则干脆将其中几个线程或者进程给唤醒,而实际上只有其中一个进程或者线程能够成功处理accept事件,其他线程都将失败,且errno错误码为EAGAIN。这种现象称为惊群效应,结果是肯定的,惊群效应肯定会带来资源的消耗和性能的影响。
如何解决:
- 多线程:不建议让多个线程同时在epoll_wait监听的socket,而是让其中一个线程epoll_wait监听的socket,当有新的链接请求进来之后,由epoll_wait的线程调用accept,建立新的连接,然后交给其他工作线程处理后续的数据读写请求,这样就可以避免了由于多线程环境下的epoll_wait惊群效应问题
- 多进程:在同一时刻,永远都只有一个子进程在监听的socket上epoll_wait,其做法是,创建一个全局的pthread_mutex_t,在子进程进行epoll_wait前,则先获取锁
总结
本节我们大概的介绍了epoll高效的原因,但是相信很多同学还是处于朦朦胧胧的状态,那么我们下节就从epoll的源码入手,深度刨析一下epoll的底层到底是怎么实现的。