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

栏目: 后端 · 发布时间: 7年前

内容简介:epoll事件驱动框架使用注意事项

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

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

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

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

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

1.1 多线程accept

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

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

这是最简单的方式,在多个线程中共享同一个bound socket file descriptor,然后各个线程在自己的服务中将其和一个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 descriptor的时候,内核的file description的引用计数会递减,而当内核发现其没有再被引用的时候,会清理该file description上面的epoll事件侦听。

在通常的使用情况下,这确实没有什么问题,但是在遇到dup、fork、IO重定向等非常规操作的时候,file descriptor的生命周期和file description的生命周期就会不同,从而很容易发生问题:假设通过dup用户态就有两个fd共享同一个底层的file description,此时关闭原先注册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" readable
epfd = 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 descriptor。在调用dup/dup2/dup3和close之前,必须使用epoll_ctl(EPOLL_CTL_DEL)取消事件侦听!

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

本文完!


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

微服务设计

微服务设计

[英] Sam Newman / 崔力强、张 骏 / 人民邮电出版社 / 2016-5 / 69.00元

本书全面介绍了微服务的建模、集成、测试、部署和监控,通过一个虚构的公司讲解了如何建立微服务架构。主要内容包括认识微服务在保证系统设计与组织目标统一上的重要性,学会把服务集成到已有系统中,采用递增手段拆分单块大型应用,通过持续集成部署微服务,等等。一起来看看 《微服务设计》 这本书的介绍吧!

URL 编码/解码
URL 编码/解码

URL 编码/解码

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换