>科技>>正文

epoll事件驱动框架使用注意事项

原标题:epoll事件驱动框架使用注意事项

自己一直订阅云风大哥的blog,今天看到期博文《 epoll 的一个设计问题 》,再追踪其连接看下去,着实让自己惊出一阵冷汗。真可谓不知者无畏,epoll在多线程、多进程环境下想要用好,需要避过的坑点还是挺多的。

这篇博文主要是根据Marek的博客内容进行翻译整理的。

epoll的坑点主要是其最初设计和实现的时候,没有对多线程、多进程这种scale-up和load-balance问题进行考虑,所以随着互联网并发和流量越来越大,越来越多的epoll flag和kernel flag被引入来修补相关问题;而来epoll的用户态空间操作接口是file deor,内核态管理接口是file deon,有些情况下两者不是对应关系,会导致程序的行为很奇怪。

一、多线程环境scale-up和load-balance 1.1 多线程accept

在比如HTTP/1.0的类似应用中,TCP都采用短连接的方式工作,那么accept()工作将会很重甚至成为整个系统的瓶颈所在,为了能够利用多核的并行处理,通常需要在多个执行单元(此处考虑多线程)上面并行运行accept()服务。但是:

(1) 简单的电平触发模式

这是最简单的方式,在多个线程中共享同一个bound socket file deor,然后各个线程在自己的服务中将其和一个epoll-event相关联,启动accept()服务。

epoll默认情况下是和select一样执行Level-Triggle,多线程模式下使用电平触发模式会有“惊群”效应,所有侦听这个套接字的线程都会被唤醒,而多个线程同时执行accept()只会有一个线程成功,其他线程全部返回EAGAIN。

(2) 边缘触发模式

虽然epoll的边缘触发工作状态下内核保证只会通知一次(一个线程),但是在边缘触发模式下,根据epoll的手册告知我们,无论是读还是写操作都要直到底层返回EAGAIN的时候本轮操作才可以结束,否则可能事件的数据没有操作完,但是在边缘触发情况下有不会继续发送信号,那么待处理数据会一直滞留下去。

所以在多线程即使使用边缘触发也会有竞争问题:线程A的epoll_wait返回后,线程A不断的调用accept()处理连接请求,当内核的accept queue队列中的请求恰好处理完时候,内核会重新将该socket置为不可读状态,以便可以重新被触发;此时如果新来了一个连接,那么另外一个线程B可能被唤醒,然后执行accept()操作,不过此时之前的线程A还需要重新再执行一次accept()以确认accept queue已经被处理完了,此时如果线程A成功accept的话,线程B就被惊醒了。

而且情况更为严重的是在考虑负载均衡的情况下,其他线程有可能被饿死,绝大多数情况的连接都被之前唤醒线程的最后一次确认性accept()给实际消费了。

(3) 新内核的解决方式

在4.5+内核版本上,epoll提供了EPOLLEXCLUSIVE标识,该标识会保证一个事件发生时候只有一个线程会被唤醒,以避免多侦听下的“惊群”问题。

如果不支持该标识,还可以使用Edge-Triggle + EPOLLONESHOT方式,启用该标识的时候epoll会在epoll_wait返回的时候自动禁止该描述符对应的事件通知,然后调用accept()消费事件,然后用户需要手动执行epoll_ctl(EPOLL_CTL_MOD)再次手动使能,等于会有一次额外epoll_ctl系统调用的开销。这种方式会让负载在多个执行单元上均衡的分布,不过任一时候只能有一个工作线程调用accept(),限制了真正并行的吞吐量。

根本性的解决方式是使用新内核的SO_REUSEPORT,可以使得应用程序在同一个端口上面创建多个socket,从而避免上面共享socket带来的种种问题。

1.2 多线程read

如果为每个线程维持一个自己的工作队列,在自己的队列中独自侦听自己的socket,那么数据的有序和完整性是很容保证的,毕竟socket只会在一个工作线程中出现并使用。不过这种做法的缺点是每个线程的工作负载可能是不均衡的,这种事先划分队列的情况可能导致某些线程的事件处理延迟,而某些线程空闲原地打转。

多个线程使用同一个队列自然是在负载均衡、处理效率上是最理想的,可以称之为”combined queue”模型,他们共享同一个epoll set,然后大家都尝试去获取active socket,不过也可能产生问题:

电平触发的惊群自然不必多说。而即使使用了EPOLLEXCLUSIVE保证每次只有一个线程获得对应读事件,但是如果第一个线程没有读完数据,那么下一次被唤醒的就可能是其他的工作线程,这种情况处理起数据就很糟糕了,毕竟在电平触发的情况下,我们不知道有多少数据可以读。

边缘触发也会导致数据完整性的竞争条件,其竞争的位置在于缓冲队列读完了,在内核将该事件置为可触发状态,此时内核又收到数据了,那么内核可能会唤醒其他线程来接收这个数据,而原本线程接收的数据就不完整了。

唯一解决的方式,就是采用EPOLLONESHOT的方式,在确认数据接收完全,本线程不需要再次使用socket的时候,再次手动触发事件使能。

二、epoll中用户态和内核态管理不一致的问题

这个问题被诟病了很久,在使用的时候必须特别的小心才可以。在通常情况下,用户态调用close()关闭file deor的时候,内核的file deion的引用计数会递减,而当内核发现其没有再被引用的时候,会清理该file deion上面的epoll事件侦听。

在通常的使用情况下,这确实没有什么问题,但是在遇到dup、fork、IO重定向等非常规操作的时候,file deor的生命周期和file deion的生命周期就会不同,从而很容易发生问题:假设通过dup用户态就有两个fd共享同一个底层的file deion,此时关闭原先注册epoll event事件的fd而不调用epoll_ctl取消事件侦听,那么底层的epoll event事件订阅就没有真正被取消;此时上层的应用程序看来现象就是即便关闭了fd,但是epoll_wait()还是会不断返回关闭了的fd的事件信息,更糟糕的是 fd已经关闭,我们无法通过epoll_ctl再次取消这个事件侦听了 ,因为fd是epoll控制底层事件的唯一入口,即便相同引用底层的其他fd也不行,对此你无能为力。

作者的伪代码很容易说明问题:

rfd, wfd = pipe() write(wfd, "a") # Make the "rfd" readableepfd = epoll_create()epoll_ctl(efpd, EPOLL_CTL_ADD, rfd, (EPOLLIN, rfd))rfd2 = dup(rfd) close(rfd)r = epoll_wait(epfd, - 1ms) # still recv event!!!

所以,当你在epoll中关闭一个fd的时候,也定要事先调用epoll_ctl(EPOLL_CTL_DEL)取消事件侦听,否则你唯一能补救的就是:通过epfd强制将整个epoll_set的事件都废除掉,然后再从头重新建立事件侦听机制。

针对上面的epoll种种问题,Marek给出的建议就是:

如果可以不要在多线程中使用epoll的时候做负载均衡,因为往往实际得到的效率收获甚微;不要在多线程中共享、同时操作socket。避免fork,如果必须的话:在execv之前请关闭所有注册了epoll事件侦听的file deor。在调用dup/dup2/dup3和close之前,必须使用epoll_ctl(EPOLL_CTL_DEL)取消事件侦听!

关于上面的内容,前半部分其实已经在Nginx中有所涉及了,比如Nginx的accept_mutex、SO_REUSEPORT机制等,后半部分在以后使用epoll时候必须时刻提醒自己脑袋清醒,要么就使用成熟的基于epoll封装的事件库!

本文完!

PHPer升级为大神并不难!返回搜狐,查看更多

责任编辑:

声明:本文由入驻搜狐号的作者撰写,除搜狐官方账号外,观点仅代表作者本人,不代表搜狐立场。
阅读 ()
投诉
免费获取