内容简介:epoll事件驱动框架使用注意事项
自己一直订阅云风大哥的blog,今天看到期博文《 epoll 的一个设计问题 》,再追踪其连接看下去,着实让自己惊出一阵冷汗。真可谓不知者无畏,epoll在多线程、多进程环境下想要用好,需要避过的坑点还是挺多的。
这篇博文主要是根据Marek的博客内容进行翻译整理的。
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封装的事件库!
本文完!
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Web Services原理与研发实践
顾宁刘家茂柴晓路 / 机械工业出版社 / 2006-1 / 33.00元
本书以web services技术原理为主线,详细解释、分析包括XML、XML Schema、SOAP、WSDL、UDDI等在内在的web Services核心技术。在分析、阐述技术原理的同时,结合作者在Web Services领域的最新研究成果,使用大量的实例帮助读者深刻理解技术的设计思路与原则。全书共有9章,第1章主要介绍web Services的背景知识;第2-7章着重讲解webServic......一起来看看 《Web Services原理与研发实践》 这本书的介绍吧!