關於多進程epoll與“驚羣”問題

先來看看什麼是“驚羣”?簡單說來,多線程/多進程(linux下線程進程也沒多大區別)等待同一個socket事件,當這個事件發生時,這些線程/進程被同時喚醒,就是驚羣。可以想見,效率很低下,許多進程被內核重新調度喚醒,同時去響應這一個事件,當然只有一個進程能處理事件成功,其他的進程在處理該事件失敗後重新休眠(也有其他選擇)。這種性能浪費現象就是驚羣。

 

驚羣通常發生在server 上,當父進程綁定一個端口監聽socket,然後fork出多個子進程,子進程們開始循環處理(比如accept)這個socket。每當用戶發起一個TCP連接時,多個子進程同時被喚醒,然後其中一個子進程accept新連接成功,餘者皆失敗,重新休眠。

 

那麼,我們不能只用一個進程去accept新連接麼?然後通過消息隊列等同步方式使其他子進程處理這些新建的連接,這樣驚羣不就避免了?沒錯,驚羣是避免了,但是效率低下,因爲這個進程只能用來accept連接。對多核機器來說,僅有一個進程去accept,這也是程序員在自己創造accept瓶頸。所以,我仍然堅持需要多進程處理accept事件。

 

其實,在linux2.6內核上,accept系統調用已經不存在驚羣了(至少我在2.6.18內核版本上已經不存在)。大家可以寫個簡單的程序試下,在父進程中bind,listen,然後fork出子進程,所有的子進程都accept這個監聽句柄。這樣,當新連接過來時,大家會發現,僅有一個子進程返回新建的連接,其他子進程繼續休眠在accept調用上,沒有被喚醒。

 

但是很不幸,通常我們的程序沒那麼簡單,不會願意阻塞在accept調用上,我們還有許多其他網絡讀寫事件要處理,linux下我們愛用epoll解決非阻塞socket。所以,即使accept調用沒有驚羣了,我們也還得處理驚羣這事,因爲epoll有這問題。上面說的測試程序,如果我們在子進程內不是阻塞調用accept,而是用epoll_wait,就會發現,新連接過來時,多個子進程都會在epoll_wait後被喚醒!

 

【遇到問題】

    手頭原來有一個單進程的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。從新程序在線上服務器上的表現看,效果也確實不錯 ,開心。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章