關於 多程式epoll 與 “驚群”問題

工程師WWW發表於2013-11-27
【遇到問題】
    手頭原來有一個單程式的linux epoll伺服器程式,近來希望將它改寫成多程式版本,主要原因有:
  1. 在服務高峰期間併發的網路請求非常海量,目前的單程式版本的程式有點吃不消:單程式時只有一個迴圈先後處理epoll_wait()到的事件,使得某些不幸排隊靠後的socket fd的網路事件處理不及時(擔心有些socket客戶端等不耐煩而超時斷開)
  2. 希望充分利用到伺服器的多顆CPU;

    但隨著改寫工作的深入,便第一次碰到了“驚群”問題,一開始我的程式設想如下:
  1. 主程式先監聽埠, listen_fd = socket(...);
  2. 建立epoll,epoll_fd = epoll_create(...);
  3. 然後開始fork(),每個子程式進入大迴圈,去等待new  accept,epoll_wait(...),處理事件等。

    接著就遇到了“驚群”現象:listen_fd有新的accept()請求過來,作業系統會喚醒所有子程式(因為這些程式都epoll_wait()同一個listen_fd,作業系統又無從判斷由誰來負責accept,索性乾脆全部叫醒……),但最終只會有一個程式成功accept,其他程式accept失敗。外國IT友人認為所有子程式都是被“嚇醒”的,所以稱之為Thundering Herd(驚群)。
    打個比方,街邊有一家麥當勞餐廳,裡面有4個服務小視窗,每個視窗各有一名服務員。當大門口進來一位新客人,“歡迎光臨!”餐廳大門的感應式門鈴自動響了(相當於作業系統底層捕抓到了一個網路事件),於是4個服務員都抬起頭(相當於作業系統喚醒了所有服務程式)希望將客人招呼過去自己所在的服務視窗。但結果可想而知,客人最終只會走向其中某一個視窗,而其他3個視窗的服務員只能“失望嘆息”(這一聲無奈的嘆息就相當於accept()返回EAGAIN錯誤),然後埋頭繼續忙自己的事去。
    這樣子“驚群”現象必然造成資源浪費,那有木有好的解決辦法呢?


【尋找辦法】
    看了網上N多帖子和網頁,閱讀多款優秀開源程式的原始碼,再結合自己的實驗測試,總結如下:
  1.  實際情況中,在發生驚群時,並非全部子程式都會被喚醒,而是一部分子程式被喚醒。但被喚醒的程式仍然只有1個成功accept,其他皆失敗。
  2. 所有基於linux epoll機制的伺服器程式在多程式時都受驚群問題的困擾,包括 lighttpd 和 nginx 等程式,各家程式的處理辦法也不一樣。
  3. lighttpd的解決思路:無視驚群。採用Watcher/Workers模式,具體措施有優化fork()與epoll_create()的位置(讓每個子程式自己去epoll_create()和epoll_wait()),捕獲accept()丟擲來的錯誤並忽視等。這樣子一來,當有新accept時仍將有多個lighttpd子程式被喚醒。
  4. nginx的解決思路:避免驚群具體措施有使用全域性互斥鎖,每個子程式在epoll_wait()之前先去申請鎖,申請到則繼續處理,獲取不到則等待,並設定了一個負載均衡的演算法(當某一個子程式的任務量達到總設定量的7/8時,則不會再嘗試去申請鎖)來均衡各個程式的任務量。
  5. 一款國內的優秀商業MTA伺服器程式(不便透露名稱):採用Leader/Followers執行緒模式,各個執行緒地位平等,輪流做Leader來響應請求。
  6. 對比lighttpd和nginx兩套方案,前者實現方便,邏輯簡單,但那部分無謂的程式喚醒帶來的資源浪費的代價如何仍待商榷(有網友測試認為這部分開銷不大 http://www.iteye.com/topic/382107)。後者邏輯較複雜,引入互斥鎖和負載均衡算分也帶來了更多的程式開銷。所以這兩款程式在解決問題的同時,都有其他一部分計算開銷,只是哪一個開銷更大,未有資料對比。
  7. 坊間也流傳Linux 2.6.x之後的核心,就已經解決了accept的驚群問題,論文地址 http://static.usenix.org/event/usenix2000/freenix/full_papers/molloy/molloy.pdf 。
  8. 但其實不然,這篇論文裡提到的改進並未能徹底解決實際生產環境中的驚群問題,因為大多數多程式伺服器程式都是在fork()之後,再對epoll_wait(listen_fd,...)的事件,這樣子當listen_fd有新的accept請求時,程式們還是會被喚醒。論文的改進主要是在核心級別讓accept()成為原子操作,避免被多個程式都呼叫了。


【採用方案】
    多方考量,最後選擇參考lighttpd的Watcher/Workers模型,實現了我需要的那款多程式epoll程式,核心流程如下:
  1. 主程式先監聽埠, listen_fd = socket(...); ,setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR,...),setnonblocking(listen_fd),listen(listen_fd,...)。
  2. 開始fork(),到達子程式數上限(建議根據伺服器實際的CPU核數來配置)後,主程式變成一個Watcher,只做子程式維護和訊號處理等全域性性工作。
  3. 每一個子程式(Worker)中,都建立屬於自己的epoll,epoll_fd = epoll_create(...);,接著將listen_fd加入epoll_fd中,然後進入大迴圈,epoll_wait()等待並處理事件。千萬注意,epoll_create()這一步一定要在fork()之後
  4. 大膽設想(未實現):每個Worker程式採用多執行緒方式來提高大迴圈的socket fd處理速度,必要時考慮加入互斥鎖來做同步,但也擔心這樣子得不償失(程式+執行緒頻繁切換帶來的額外作業系統開銷),這一步尚未實現和測試,但看到nginx原始碼中貌似有此邏輯。



【小結】
    縱觀現如今的Linux伺服器程式開發(無論是遊戲伺服器/WebServer伺服器/balabala各類應用伺服器),epoll可謂大行其道,當紅炸子雞一枚。它也確實是一個好東西,單程式時的事件處理能力就已經大大強於poll/select,難怪Nginx/Lighttpd等生力軍程式都那麼喜歡它。
    但畢竟只有一個程式的話,晾著伺服器的多個CPU實在是罪過,為追求更高的機器利用率更短的請求響應處理時間,還是折騰著搞出了多程式epoll。從新程式線上上伺服器上的表現看,效果也確實不錯 ,開心。
    感謝諸多網友的帖子分享,現在新程式已經上線,小弟也將心得整理成這篇博文,希望能幫到有需要的童鞋。倉促成文,若有錯漏懇請指正,也請諸位不吝賜教給建議,灰常感謝!

相關文章