驚羣問題的思考

原文: http://www.cppblog.com/isware/archive/2011/07/20/151470.aspx


「聽說」驚羣問題已是一個很古老的問題了,而且在大多數系統中 已經獲得有效解決 ,但對我來講,仍舊是一個比較新的概念,所以有必要記錄一下。

什麼是驚羣

        舉一個很簡單的例子,當你往一羣鴿子中間扔一塊食物,雖然最終只有一個鴿子搶到食物,但全部鴿子都會被驚動來爭奪,沒有搶到食物的鴿子只好回去繼續睡覺,等待下一塊食物到來。這樣,每扔一塊食物,都會驚動全部的鴿子,即爲驚羣。對於操做系統來講,多個進程/線程在等待同一資源是,也會產生相似的效果,其結果就是每當資源可用,全部的進程/線程都來競爭資源,形成的後果:
1)系統對用戶進程/線程頻繁的作無效的調度、上下文切換,系統系能大打折扣。
2)爲了確保只有一個線程獲得資源,用戶必須對資源操做進行加鎖保護,進一步加大了系統開銷。

        最多見的例子就是對於socket描述符的accept操做,當多個用戶進程/線程監聽在同一個端口上時,因爲實際只可能accept一次,所以就會產生驚羣現象,固然前面已經說過了,這個問題是一個古老的問題,新的操做系統內核已經解決了這一問題。

linux內核解決驚羣問題的方法

        對於一些已知的驚羣問題,內核開發者增長了一個「互斥等待」選項。一個互斥等待的行爲與睡眠基本相似,主要的不一樣點在於:
        1)當一個等待隊列入口有 WQ_FLAG_EXCLUSEVE 標誌置位, 它被添加到等待隊列的尾部. 沒有這個標誌的入口項, 相反, 添加到開始.
        2)當 wake_up 被在一個等待隊列上調用時, 它在喚醒第一個有 WQ_FLAG_EXCLUSIVE 標誌的進程後中止。
        也就是說,對於互斥等待的行爲,好比如對一個listen後的socket描述符,多線程阻塞accept時,系統內核只會喚醒全部正在等待此時間的隊列的第一個,隊列中的其餘人則繼續等待下一次事件的發生,這樣就避免的多個線程同時監聽同一個socket描述符時的驚羣問題。

根據以上背景信息,咱們來比較一下常見的Server端設計方案。
方案1:listen後,啓動多個線程(進程),對此socket進行監聽(僅阻塞accept方式不驚羣)。
方案2:主線程負責監聽,經過線程池方式處理鏈接。(一般的方法)
方案3:主線程負責監聽,客戶端鏈接上來後由主線程分配實際的端口,客戶端根據此端口從新鏈接,而後處理數據。

先考慮客戶端單鏈接的狀況
方案1:每當有新的鏈接到來時,系統內核會從隊列中以FIFO的方式選擇一個監聽線程來服務此鏈接,所以能夠充分發揮系統的系能而且多線程負載均衡。對於單鏈接的場景,這種方案無疑是很是優越的。遺憾的是,對於select、epoll,內核目前沒法解決驚羣問題。 (nginx對於驚羣問題的解決方法)
方案2:因爲只有一個線程在監聽,其瞬時的併發處理鏈接請求的能力必然不如多線程。同時,須要對線程池作調度管理,必然涉及資源共享訪問,相對於方案一來講管理成本要增長很多,代碼複雜度提升,性能也有所降低。
方案3:與方案2有很多相似的地方,其優點是不須要作線程調度。缺點是增長了主線程的負擔,除了接收鏈接外還須要發送數據,並且須要兩次鏈接,孰優孰劣,有待測試。

再考慮客戶端多鏈接的狀況:
對於數據傳輸類的應用,爲了充分利用帶寬,每每會開啓多個鏈接來傳輸數據,鏈接之間的數據有相互依賴性,所以Server端要想很好的維護這種依賴性,把同一個客戶端的全部鏈接放在一個線程中處理是很是有必要的。
A、同一客戶端在一個線程中處理
方案1:若是沒有更底層的解決方案的話,Server則須要維護一個全局列表,來記錄當前鏈接請求該由哪一個線程處理。多線程須要同時競爭一個全局資源,彷佛有些不妙。
方案2:主線程負責監聽並分發,所以與單鏈接相比沒有帶來額外的性能開銷。僅僅會形成主線程忙於更多的鏈接請求。
方案3:較單線程來講,主線程工做量沒有任何增長,因爲多鏈接而形成的額外開銷由實際工做線程分擔,所以對於這種場景,方案3彷佛是最佳選擇。

B、同一客戶端在不一樣線程中處理
方案1:一樣須要競爭資源。
方案2:沒理由。
方案3:不可能。

另外:
(《UNIX網絡編程》第三版是在第30章)
讀《UNIX網絡編程》第二版的第一卷時,發現做者在第27章「客戶-服務器程序其它設計方法」中的27.6節「TCP預先派生子進程服務器程序,accept無上鎖保護」中提到了一種由子進程去競爭客戶端鏈接的設計方法,用僞碼描述以下:

服務器主進程:

listen_fd = socket(...);
bind(listen_fd, ...);
listen(listen_fd, ...);
pre_fork_children(...);
close(listen_fd);
wait_children_die(...);
linux


服務器服務子進程:

while (1) {
conn_fd = accept(listen_fd, ...);
do_service(conn_fd, ...);
}
nginx


初 識上述代碼,真有眼前一亮的感受,也正如做者所說,以上代碼確實不多見(反正我讀此書以前是確實沒見過)。做者真是構思精巧,巧妙地繞過了常見的預先建立 子進程的多進程服務器當主服務進程接收到新的鏈接必須想辦法將這個鏈接傳遞給服務子進程的「陷阱」,上述代碼經過共享的傾聽套接字,由子進程主動地去向內 核「索要」鏈接套接字,從而避免了用UNIX域套接字傳遞文件描述符的「淫技」。

不過,當接着往下讀的時候,做者談到了「驚羣」 (Thundering herd)問題。所謂的「驚羣」就是,當不少進程都阻塞在accept系統調用的時候,即便只有一個新的鏈接達到,內核也會喚醒全部阻塞在accept上 的進程,這將給系統帶來很是大的「震顫」,下降系統性能。

除了這個問題,accept還必須是原子操做。爲此,做者在接下來的27.7節講述了加了互斥鎖的版本:

while (1) {
lock(...);
conn_fd = accept(listen_fd, ...);
unlock(...);
do_service(conn_fd, ...);
}
編程


原 子操做的問題算是解決了,那麼「驚羣」呢?文中只是提到在Solaris系統上當子進程數由75變成90後,CPU時間顯著增長,而且做者認爲這是由於進 程過多,致使內存互換。對「驚羣」問題回答地十分含糊。經過比較書中圖27.2的第4列和第7列的內容,咱們能夠確定「真兇」絕對不是「內存對換」。

「元兇」究竟是誰?

仔 細分析一下,加鎖真的有助於「驚羣」問題麼?不錯,確實在同一時間只有一個子進程在調用accept,其它子進程都阻塞在了lock語句,可是,當 accept返回並unlock以後呢?unlock確定是要喚醒阻塞在這個鎖上的進程的,不過誰都沒有規定是喚醒一個仍是喚醒多個。因此,潛在的「驚 羣」問題仍是存在,只不過換了個地方,換了個形式。而形成Solaris性能驟降的「罪魁禍首」頗有可能就是「驚羣」問題。

崩潰了!這麼說全部的鎖都有可能產生驚羣問題了?

彷佛真的是這樣,因此 減小鎖的使用很重要。特別是在競爭比較激烈的地方。

做者在27.9節所實現的「傳遞文件描述符」版本的服務器就有效地克服了「驚羣」問題,在現實的服務器實現中,最經常使用的也是此節所提到的基於「分配」形式。

把「競爭」換成「分配」是避免「驚羣」問題的有效方法,可是 也不要忽視「分配」的「均衡」問題,否則後果可能更加嚴重哦!
相關文章
相關標籤/搜索