记一次多进程epoll程序的“惊群”效应

一、有这么一个程序:

网易邮箱的SMTP流量有一个网关代理程序,叫做smtpproxy。和其它工作在应用层的代理程序没有太大区别,smtpproxy是负责:

  • 监听代理服务器的25号端口SMTP协议端口),一旦有客户端连接上来,smtpproxy会连接上某台后端的Real Server真实邮箱服务器,proxy和realsvr之间走内部协议来传递数据。
  • Realsvr服务器有多台,支持类似Nginx Weight权重值和健康监测。

等诸多特性的一个程序,由邮件部运维部反垃圾小组研发和维护。

  • 插播1:为啥不用简单的端口转发,或者用LVSNginx等开源套路呢?主要是为了利用网易邮箱系统内部协议的某些特性。
  • 插播2:网易邮箱的用户体量非常大,每天的SMTP请求总次数达7亿次,在服务高峰期的网络并发请求数会非常高,对该代理程序的性能要求很高。

这个smtpproxy程序前后经过了几次大的版本迭代。最初用Python Twisted框架来实现,性能上无法满足高峰期的并发和性能需要,再加上Twisted框架在某些条件下有内存泄露问题,后来用C实现了socket+Linux epoll方式来重构了。尽管epoll很高效(关于epoll请参见另一篇文章《浅谈网络事件的响应机制》,但此时smtpproxy仍只是单进程版本,也就是说只有一个主进程,里面有一个循环不断地处理epoll_wait()就绪的网络事件。在并发量非常高的时候,不能充分利用多核CPU,还会使得一些不幸排队靠后的Socket fd描述符的网络时间处理不及时(没错,epoll也有排队这一说,但和select/poll的队列轮询是不同的概念:当就绪的事件很多时,epoll_wait()函数返回的是这批就绪事件的fd列表,工作进程遍历这个列表来处理网络事件)。这对于SMTP协议来说,很容易导致一些Socket Client超时断开,本次邮件的投递就失败了。

于是,决定将smtpproxy程序从单进程版本改写为多进程版本。随着重构工作的深入,就碰到了传说中的“惊群效应”问题。


二、“惊群”效应:

对于多进程版本,我的程序框架是这样设计的:

  1. 主进程先监听服务端口,listen_fd = socket(...);
  2. 接着,主程序创建epoll fd描述符,epoll_fd = epoll_create(...);
  3. 然后,主程序开始fork(),每个子进程就是一个工作进程。每个工作进程进入一个大循环,等待建立Socket accept()新连接,然后再使用epoll_wait(...)高效地去处理网络I/O事件。

先说结果,用C按照这个设计模式实现出了一个多进程版本的smtpproxy,性能的确比单进程版本更高,但却遇到了传说中的“惊群”现象:主进程fork()N个工作进程,当listen_fd有新的accept()请求过来,操作系统会唤醒全部N个工作进程,因为不同于单进程版本,这N个工作进程都在epoll_wait()同一个listen_fd,操作系统无法判断由谁来accept()这个新连接请求,索性干脆全部叫醒。。。当然,最终只会有一个进程成功地accept(),其它N-1个进程accept()失败。大神们认为这些工作进程都是被“吓醒”的,所以亲切地称之为Thundering Herd(惊群)

打个比方,一家麦当劳餐厅有4个服务窗口,每个窗口各有一名服务员。当大门口进来一位新客人(相当于有一个新的Socket Client要来connect()本服务器,服务器的操作系统底层收到了这个connect()请求),“欢迎光临!”这时餐厅大门的感应式门铃自动响了,这4名服务员都听到了门铃声,都抬起了头(相当于操作系统唤醒了所有工作进程)并希望将客人招呼到自己所在的服务窗口去(所有工作进程都在执行accept(),希望得到本次connect()连接)。但结果可想而知,客人最终只会走向其中某一个窗口(这次connect()请求最终被某个进程抢先一步accept()成功),而其他3个窗口的服务员只能“失望叹息”(这一声无奈的叹息就相当于accept()返回EAGAIN错误),然后又埋头继续忙自己的事去。

在这个过程中,每有一个connect()就要唤醒所有进程去accept(),并且注定会有N-1个进程失败,白白付出本次开销。

UNIX网络编程卷1》提到“当某一时刻只有一个连接过来时,N个睡眠进程会被同时叫醒,但只有一个进程可获得连接。如果每次唤醒的进程数目太多,会影响一部分系统性能”。也就是说,“惊群”效应并不只是发生在使用了epoll的程序上,但凡fork()多进程去阻塞式地accept()同一个Socket fd描述符,都会存在这个问题,并且带来资源浪费。那么,有木有好的解决方法呢?


三、探索解决方案:

在网上读了N多帖子,阅读了多款优秀开源程序的源代码,再结合自己的实验,总结如下:

  • 在实际情况中,当发生惊群时,并非全部工作进程都被唤醒,而是一部分进程。这部分被唤醒的进程依旧只能有一个成功accept(),其它皆失败。资源浪费依然存在。
  •  如《Unix网络编程》所说,基于Linux epoll机制的服务器端程序在多进程时也会收到惊群问题的困扰,包括lighttpdNginx等,不同程序会采用不同的解决方案来处理惊群问题。
  • lighttpd的解决思路:无视惊群。lighttpd采用的是Watcher/Worker模式,优化了fork()epoll_create()的位置(让每个子进程自己去epoll_create()epoll_wait(),主动捕获accept()抛出的错误并忽视。有点鸵鸟政策的味道,依旧会有多个lighttpd工作进程会被唤醒并失败。
  • nginx的解决思路:避免惊群。Nginx使用了全局互斥锁,每个工作进程在epoll_wait()之前先去申请锁,得到锁了才继续处理,得不到锁则等待,并设置了一个负载均衡算法来权衡各个进程的任务量(当某个工作进程的任务里达到总设置量的7/8时,不再尝试去申请锁)
  • 国内某优秀商业邮箱MTA服务器端程序(其实就是网易邮箱Coremail系统的MTA模块,我的日常工作所维护的一个系统):虽然拿不到源代码,但在这个问题上它选择采用Leader/Followers线程模式,各个线程地位平等,轮流做Leader来响应请求。
  • 对比lighttpdnginx两套不同的解决方案。前者实现方便,代码逻辑简单,但那部分无谓的进程唤醒依旧会造成资源浪费,有网友测试后认为这部分开销并不大(参见原贴《测试Lighttpd accept的惊群现象》;后者实现逻辑较复杂,引入互斥锁和负载均衡算法也会带来一些额外的开销,未找到数据来对比这些开销哪一个更大,但负载均衡确实是一个好事,不是吗。
  • 坊间流传Linux 2.6.x之后的内核已经解决了accept()时的惊群问题,参见论文《ACCEPT() SCALABILITY ON LINUX》。但也有观点认为其实不然,这篇论文中提到的改进并不能彻底解决实际生产环境中出现的惊群问题,因为大多数多进程epoll程序都是在fork()之后再epoll_wait(listen_fd,...)去等待/处理事件,这样子当listen_fd有新的accept请求时,工作进程们还是会被唤醒。论文提到的改进主要是Linux内核别让accept()成为原子操作,避免被多个进程都调用。有知乎帖子讨论Linux 3.x内核是否已解决惊群问题,推荐一读。
  • 而在Linux 4.5之后的内核,已经提供了EPOLL_EXCLUSIVE参数,大概是在TCP三次握手最后一个ACK报文调用sock_def_readable时只唤醒一个等待源,这样就在内核层面避免了“惊群”问题了。

四、smtpproxy采用的解决方案:

综合lighttpdnginx等软件处理惊群效应时的解决方案以及smtpproxy的实际需求,最终选择了参考lighttpdWatcher/Workers模型来重构多进程版epoll smtpproxy程序。核心流程则变成:

  1. 主进程先监听服务端口,并设置为ReUseNon-Blocking等特性。listen_fd = socket(...);setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR,...);setnonblocking(listen_fd);listen(listen_fd,...);
  2. 接着,主进程开始fork(),派生出N个子进程之后,主进程变成一个Watcher,只做子进程维护、信号处理等全局性工作。
  3. 每一个子进程则是一个工作进程Worker,先创建属于本进程自己的epoll fd描述符epoll_fd = epoll_create(...);,接着将listen_fd添加到epoll_fd的监控列表中,并开始进入大循环,epoll_wait()等待并处理网络事件。注意,epoll_create()这一步是在fork()之后的。
  4. 如果每个Worker采用多线程+互斥锁来提高大循环的处理速度,效率能提升多少?是否得不偿失(线程频繁切换等给操作系统带来的额外开销如何)?这个思路在nginx源码中有出现,但本次重构smtpproxy未去实践。

由于服务器的内核版本仍低于4.5(无法利用到EPOLL_EXCLUSIVE特性),采用这套方案在第3步依旧会出现惊群效应,主动捕获错误并无视,这一点与lighttpd一样。


五、小结:

在如今的web、邮箱、游戏等领域的Linux服务器端程序的开发中,epoll大行其道,也确实是一个很优秀的东西,但一旦多进程就容易碰到各种问题,惊群只是其中之一。

对于本次重构smtpproxy来说,多进程epoll的新版本程序在线上的表现更加优秀,借着这次分享希望能给有需要的童鞋带来一些启发,如有错漏恳请指正。

 

六、参考文章:

http://www.cnblogs.com/Anker/p/3265058.html

http://bbs.chinaunix.net/forum.php?mod=viewthread&tid=946261

https://www.zhihu.com/question/20122137

http://www.iteye.com/topic/382107

http://static.usenix.org/event/usenix2000/freenix/full_papers/molloy/molloy.pdf

https://www.zhihu.com/question/24169490

http://www.man7.org/linux/man-pages/man2/epoll_ctl.2.html

网易云新用户大礼包:https://www.163yun.com/gift

本文来自网易实践者社区,经作者陈俊平授权发布。