【遇到问题】
手头原来有一个单进程的linux epoll服务器程序,近来希望将它改写成多进程版本,主要原因有:
- 在服务高峰期间 并发的 网络请求非常海量,目前的单进程版本的程序有点吃不消:单进程时只有一个循环先后处理epoll_wait()到的事件,使得某些不幸排队靠后的socket fd的网络事件处理不及时(担心有些socket客户端等不耐烦而超时断开);
- 希望充分利用到服务器的多颗CPU;
但随着改写工作的深入,便第一次碰到了“惊群”问题,一开始我的程序设想如下:
- 主进程先监听端口, listen_fd = socket(...);
- 创建epoll,epoll_fd = epoll_create(...);
- 然后开始fork(),每个子进程进入大循环,去等待new accept,epoll_wait(...),处理事件等。
接着就遇到了“惊群”现象:当listen_fd有新的accept()请求过来,操作系统会唤醒所有子进程(因为这些进程都epoll_wait()同一个listen_fd,操作系统又无从判断由谁来负责accept,索性干脆全部叫醒……),但最终只会有一个进程成功accept,其他进程accept失败。外国IT友人认为所有子进程都是被“吓醒”的,所以称之为Thundering Herd(惊群)。
打个比方,街边有一家麦当劳餐厅,里面有4个服务小窗口,每个窗口各有一名服务员。当大门口进来一位新客人,“欢迎光临!”餐厅大门的感应式门铃自动响了(相当于操作系统底层捕抓到了一个网络事件),于是4个服务员都抬起头(相当于操作系统唤醒了所有服务进程)希望将客人招呼过去自己所在的服务窗口。但结果可想而知,客人最终只会走向其中某一个窗口,而其他3个窗口的服务员只能“失望叹息”(这一声无奈的叹息就相当于accept()返回EAGAIN错误),然后埋头继续忙自己的事去。
这样子“惊群”现象必然造成资源浪费,那有木有好的解决办法呢?
【寻找办法】
- 实际情况中,在发生惊群时,并非全部子进程都会被唤醒,而是一部分子进程被唤醒。但被唤醒的进程仍然只有1个成功accept,其他皆失败。
- 所有基于linux epoll机制的服务器程序在多进程时都受惊群问题的困扰,包括 lighttpd 和nginx 等程序,各家程序的处理办法也不一样。
- lighttpd的解决思路:无视惊群。采用Watcher/Workers模式,具体措施有优化fork()与epoll_create()的位置(让每个子进程自己去epoll_create()和epoll_wait()),捕获accept()抛出来的错误并忽视等。这样子一来,当有新accept时仍将有多个lighttpd子进程被唤醒。
- nginx的解决思路:避免惊群。具体措施有使用全局互斥锁,每个子进程在epoll_wait()之前先去申请锁,申请到则继续处理,获取不到则等待,并设置了一个负载均衡的算法(当某一个子进程的任务量达到总设置量的7/8时,则不会再尝试去申请锁)来均衡各个进程的任务量。
- 一款国内的优秀商业MTA服务器程序(不便透露名称):采用Leader/Followers线程模式,各个线程地位平等,轮流做Leader来响应请求。
- 对比lighttpd和nginx两套方案,前者实现方便,逻辑简单,但那部分无谓的进程唤醒带来的资源浪费的代价如何仍待商榷(有网友测试认为这部分开销不大 http://www.iteye.com/topic/382107)。后者逻辑较复杂,引入互斥锁和负载均衡算分也带来了更多的程序开销。所以这两款程序在解决问题的同时,都有其他一部分计算开销,只是哪一个开销更大,未有数据对比。
- 坊间也流传Linux 2.6.x之后的内核,就已经解决了accept的惊群问题,论文地址 http://static.usenix.org/event/usenix2000/freenix/full_papers/molloy/molloy.pdf 。
- 但其实不然,这篇论文里提到的改进并未能彻底解决实际生产环境中的惊群问题,因为大多数多进程服务器程序都是在fork()之后,再对epoll_wait(listen_fd,...)的事件,这样子当listen_fd有新的accept请求时,进程们还是会被唤醒。论文的改进主要是在内核级别让accept()成为原子操作,避免被多个进程都调用了。
【采用方案】
多方考量,最后选择参考lighttpd的Watcher/Workers模型,实现了我需要的那款多进程epoll程序,核心流程如下:
- 主进程先监听端口, listen_fd = socket(...); ,setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR,...),setnonblocking(listen_fd),listen(listen_fd,...)。
- 开始fork(),到达子进程数上限(建议根据服务器实际的CPU核数来配置)后,主进程变成一个Watcher,只做子进程维护和信号处理等全局性工作。
- 每一个子进程(Worker)中,都创建属于自己的epoll,epoll_fd = epoll_create(...);,接着将listen_fd加入epoll_fd中,然后进入大循环,epoll_wait()等待并处理事件。千万注意, epoll_create()这一步一定要在fork()之后。
- 大胆设想(未实现):每个Worker进程采用多线程方式来提高大循环的socket fd处理速度,必要时考虑加入互斥锁来做同步,但也担心这样子得不偿失(进程+线程频繁切换带来的额外操作系统开销),这一步尚未实现和测试,但看到nginx源码中貌似有此逻辑。
【小结】
纵观现如今的Linux服务器程序开发(无论是游戏服务器/WebServer服务器/balabala各类应用服务器),epoll可谓大行其道,当红炸子鸡一枚。它也确实是一个好东西,单进程时的事件处理能力就已经大大强于poll/select,难怪Nginx/Lighttpd等生力军程序都那么喜欢它。
但毕竟只有一个进程的话,晾着服务器的多个CPU实在是罪过,为追求更高的机器利用率和更短的请求响应处理时间,还是折腾着搞出了多进程epoll。从新程序在线上服务器上的表现看,效果也确实不错 ,开心。