《多線程服務器的適用場合》例釋與答疑

http://blog.csdn.net/Solstice/archive/2010/03/03/5343217.aspx

陳碩 (giantchen_AT_gmail)

Blog.csdn.net/Solstice

2010 March 3 - rev 01

《多線程服務器的適用場合》(以下簡稱《適用場合》)一文在博客登出之後,有熱心讀者提出質疑,我自己也覺得原文沒有把道理說通說透,這篇文章試圖用一些實例來解答讀者的疑問。我本來打算修改原文,但是考慮到已經讀過的讀者不一定會注意到文章的變動,乾脆另寫一篇。爲方便閱讀,本文以問答體呈現。這篇文章可能會反覆修改擴充,請注意上面的版本號。

本文所說的“多線程服務器”的定義與前文一樣,同時參見《多線程服務器的常用編程模型》(以下簡稱《常用模型》)一文的詳細界定,以下“連接、端口”均指 TCP 協議。

1. Linux 能同時啓動多少個線程?

對於 32-bit Linux,一個進程的地址空間是 4G,其中用戶態能訪問 3G 左右,而一個線程的默認棧 (stack) 大小是 10M,心算可知,一個進程大約最多能同時啓動 300 個線程。如果不改線程的調用棧大小的話,300 左右是上限,因爲程序的其他部分(數據段、代碼段、堆、動態庫、等等)同樣要佔用內存(地址空間)。

對於 64-bit 系統,線程數目可大大增加,具體數字我沒有測試,因爲我實際用不到那麼多線程。

以下的關於線程數目的討論以 32-bit Linux 爲例。

2. 多線程能提高併發度嗎?

如果指的是“併發連接數”,不能。

由問題 1 可知,假如單純採用 thread per connection 的模型,那麼併發連接數最多 300,這遠遠低於基於事件的單線程程序所能輕鬆達到的併發連接數(幾千上萬,甚至幾萬)。所謂“基於事件”,指的是用 IO multiplexing event loop 的編程模型,又稱 Reactor 模式,在《常用模型》一文中已有介紹。

那麼採用《常用模型》一文中推薦的 event loop per thread 呢?至少不遜於單線程程序。

小結:thread per connection 不適合高併發場合,其 scalability 不佳。event loop per thread 的併發度不比單線程程序差。

3. 多線程能提高吞吐量嗎?

對於計算密集型服務,不能。

假設有一個耗時的計算服務,用單線程算需要 0.8s。在一臺 8 核的機器上,我們可以啓動 8 個線程一起對外服務(如果內存夠用,啓動 8 個進程也一樣)。這樣完成單個計算仍然要 0.8s,但是由於這些進程的計算可以同時進行,理想情況下吞吐量可以從單線程的 1.25cps (calc per second) 上升到 10cps。(實際情況可能要打個八折——如果不是打對摺的話。)

假如改用並行算法,用 8 個核一起算,理論上如果完全並行,加速比高達 8,那麼計算時間是 0.1s,吞吐量還是 10cps,但是首次請求的響應時間卻降低了很多。實際上根據 Amdahl's law,即便算法的並行度高達 95%,8 核的加速比也只有 6,計算時間爲 0.133s,這樣會造成吞吐量下降爲 7.5cps。不過以此爲代價,換得響應時間的提升,在有些應用場合也是值得的。

這也回答了問題 4。

如果用 thread per request 的模型,每個客戶請求用一個線程去處理,那麼當併發請求數大於某個臨界值 T’ 時,吞吐量反而會下降,因爲線程多了以後上下文切換的開銷也隨之增加(分析與數據請見《A Design Framework for Highly Concurrent Systems》 by Matt Welsh et al.)。thread per request 是最簡單的使用線程的方式,編程最容易,簡單地把多線程程序當成一堆串行程序,用同步的方式順序編程,比如 Java Servlet 中,一次頁面請求由一個函數 HttpServlet#service(HttpServletRequest req, HttpServletResponse resp) 同步地完成。

爲了在併發請求數很高時也能保持穩定的吞吐量,我們可以用線程池,線程池的大小應該滿足“阻抗匹配原則”,見問題 7。

線程池也不是萬能的,如果響應一次請求需要做比較多的計算(比如計算的時間佔整個 response time 的 1/5 強),那麼用線程池是合理的,能簡化編程。如果一次請求響應中,thread 主要是在等待 IO,那麼爲了進一步提高吞吐,往往要用其它編程模型,比如 Proactor,見問題 8。

4. 多線程能降低響應時間嗎?

如果設計合理,充分利用多核資源的話,可以。在突發 (burst) 請求時效果尤爲明顯。

例1: 多線程處理輸入。

以 memcached 服務端爲例。memcached 一次請求響應大概可以分爲 3 步:

讀取並解析客戶端輸入操作 hashtable返回客戶端

在單線程模式下,這 3 步是串行執行的。在啓用多線程模式時,它會啓用多個輸入線程(默認是 4 個),並在建立連接時按 round-robin 法把新連接分派給其中一個輸入線程,這正好是我說的 event loop per thread 模型。這樣一來,第 1 步的操作就能多線程並行,在多核機器上提高多用戶的響應速度。第 2 步用了全局鎖,還是單線程的,這可算是一個值得繼續改進的地方。

比如,有兩個用戶同時發出了請求,這兩個用戶的連接正好分配在兩個 IO 線程上,那麼兩個請求的第 1 步操作可以在兩個線程上並行執行,然後彙總到第 2 步串行執行,這樣總的響應時間比完全串行執行要短一些(在“讀取並解析”所佔的比重較大的時候,效果更爲明顯)。請繼續看下面這個例子。

例2: 多線程分擔負載。

假設我們要做一個求解 Sudoku 的服務(見《談談數獨》),這個服務程序在 9981 端口接受請求,輸入爲一行 81 個數字(待填數字用 0 表示),輸出爲填好之後的 81 個數字 (1 ~ 9),如果無解,輸出 “NO\r\n”。

由於輸入格式很簡單,用單個線程做 IO 就行了。先假設每次求解的計算用時 10ms,用前面的方法計算,單線程程序能達到的吞吐量上限爲 100req/s,在 8 核機器上,如果用線程池來做計算,能達到的吞吐量上限爲 800req/s。下面我們看看多線程如何降低響應時間。

假設 1 個用戶在極短的時間內發出了 10 個請求,如果用單線程“來一個處理一個”的模型,這些 reqs 會排在隊列裏依次處理(這個隊列是操作系統的 TCP 緩衝區,不是程序裏自己的任務隊列)。在不考慮網絡延遲的情況下,第 1 個請求的響應時間是 10ms;第 2 個請求要等第 1 個算完了才能獲得 CPU 資源,它等了 10ms,算了 10ms,響應時間是 20ms;依次類推,第 10 個請求的響應時間爲 100ms;10個請求的平均響應時間爲 55ms。

如果 Sudoku 服務在每個請求到達時開始計時,會發現每個請求都是 10ms 響應時間,而從用戶的觀點,10 個請求的平均響應時間爲 55ms,請讀者想想爲什麼會有這個差異。

下面改用多線程:1 個 IO 線程,8 個計算線程(線程池)。二者之間用 BlockingQueue 溝通。同樣是 10 個併發請求,第 1 個請求被分配到計算線程1,第 2 個請求被分配到計算線程 2,以此類推,直到第 8 個請求被第 8 個計算線程承擔。第 9 和第 10 號請求會等在 BlockingQueue 裏,直到有計算線程回到空閒狀態才能被處理。(請注意,這裏的分配實際上是由操作系統來做,操作系統會從處於 Waiting 狀態的線程裏挑一個,不一定是 round-robin 的。)

這樣一來,前 8 個請求的響應時間差不多都是 10ms,後 2 個請求屬於第二批,其響應時間大約會是 20ms,總的平均響應時間是 12ms。可以看出比單線程快了不少。

由於每道 Sudoku 題目的難度不一,對於簡單的題目,可能 1ms 就能算出來,複雜的題目最多用 10ms。那麼線程池方案的優勢就更明顯,它能有效地降低“簡單任務被複雜任務壓住”的出現概率。

以上舉的都是計算密集的例子,即線程在響應一次請求時不會等待 IO,下面談談更復雜的情況。

5. 多線程程序如何讓 IO 和“計算”相互重疊,降低 latency?

基本思路是,把 IO 操作(通常是寫操作)通過 BlockingQueue 交給別的線程去做,自己不必等待。

例1: logging

在多線程服務器程序中,日誌 (logging) 至關重要,本例僅考慮寫 log file 的情況,不考慮 log server。

在一次請求響應中,可能要寫多條日誌消息,而如果用同步的方式寫文件(fprintf 或 fwrite),多半會降低性能,因爲:

文件操作一般比較慢,服務線程會等在 IO 上,讓 CPU 閒置,增加響應時間。就算有 buffer,還是不靈。多個線程一起寫,爲了不至於把 buffer 寫錯亂,往往要加鎖。這會讓服務線程互相等待,降低併發度。(同時用多個 log 文件不是辦法,除非你有多個磁盤,且保證 log files 分散在不同的磁盤上,否則還是受到磁盤 IO 瓶頸制約。)

解決辦法是單獨用一個 logging 線程,負責寫磁盤文件,通過一個或多個 BlockingQueue 對外提供接口。別的線程要寫日誌的時候,先把消息(字符串)準備好,然後往 queue 裏一塞就行,基本不用等待。這樣服務線程的計算就和 logging 線程的磁盤 IO 相互重疊,降低了服務線程的響應時間。

儘管 logging 很重要,但它不是程序的主要邏輯,因此對程序的結構影響越小越好,最好能簡單到如同一條 printf 語句,且不用擔心其他性能開銷,而一個好的多線程異步 logging 庫能幫我們做到這一點。(Apache 的 log4cxx 和 log4j 都支持 AsyncAppender 這種異步 logging 方式。)

例2: memcached 客戶端

假設我們用 memcached 來保存用戶最後發帖的時間,那麼每次響應用戶發帖的請求時,程序裏要去設置一下 memcached 裏的值。這一步如果用同步 IO,會增加延遲。

對於“設置一個值”這樣的 write-only idempotent 操作,我們其實不用等 memcached 返回操作結果,這裏也不用在乎 set 操作失敗,那麼可以藉助多線程來降低響應延遲。比方說我們可以寫一個多線程版的 memcached 的客戶端,對於 set 操作,調用方只要把 key 和 value 準備好,調用一下 asyncSet() 函數,把數據往 BlockingQueue 上一放就能立即返回,延遲很小。剩下的時就留給 memcached 客戶端的線程去操心,而服務線程不受阻礙。

其實所有的網絡寫操作都可以這麼異步地做,不過這也有一個缺點,那就是每次 asyncWrite 都要在線程間傳遞數據,其實如果 TCP 緩衝區是空的,我們可以在本線程寫完,不用勞煩專門的 IO 線程。Jboss 的 Netty 就使用了這個辦法來進一步降低延遲。

以上都僅討論了“打一槍就跑”的情況,如果是一問一答,比如從 memcached 取一個值,那麼“重疊 IO”並不能降低響應時間,因爲你無論如何要等 memcached 的回覆。這時我們可以用別的方式來提高併發度,見問題8。(雖然不能降低響應時間,但也不要浪費線程在空等上,對吧)

另外以上的例子也說明,BlockingQueue 是構建多線程程序的利器。

6. 爲什麼第三方庫往往要用自己的線程?

往往因爲 event loop 模型沒有標準實現。如果自己寫代碼,儘可以按所用 Reactor 的推薦方式來編程,但是第三方庫不一定能很好地適應並融入這個 event loop framework。有時需要用線程來做一些串並轉換。

對於 Java,這個問題還好辦一些,因爲 thread pool 在 Java 裏有標準實現,叫 ExecutorService。如果第三方庫支持線程池,那麼它可以和主程序共享一個 ExecutorService ,而不是自己創建一堆線程。(比如在初始化時傳入主程序的 obj。)對於 C++,情況麻煩得多,Reactor 和 Thread pool 都沒有標準庫。

例1:libmemcached 只支持同步操作

libmemcached 支持所謂的“非阻塞操作”,但沒有暴露一個能被 select/poll/epoll 的 file describer,它的 memcached_fetch 始終會阻塞。它號稱 memcached_set 可以是非阻塞的,實際意思是不必等待結果返回,但實際上這個函數會同步地調用 write(),仍可能阻塞在網絡 IO 上。

如果在我們的 reactor event handler 裏調用了 libmemcached 的函數,那麼 latency 就堪憂了。如果想繼續用 libmemcached,我們可以爲它做一次線程封裝,按問題 5 例 2 的辦法,同額外的線程專門做 memcached 的 IO,而程序主體還是 reactor。甚至可以把 memcached “數據就緒”作爲一個 event,注入到我們的 event loop 中,以進一步提高併發度。(例子留待問題 8 講)

萬幸的是,memcached 的協議非常簡單,大不了可以自己寫一個基於 reactor 的客戶端,但是數據庫客戶端就沒那麼幸運了。

例2:MySQL 的官方 C API 不支持異步操作

MySQL 的客戶端只支持同步操作,對於 UPDATE/INSERT/DELETE 之類只要行爲不管結果的操作(如果代碼需要得知其執行結果則另當別論),我們可以用一個單獨的線程來做,以降低服務線程的延遲。可仿照前面 memcached_set 的例子,不再贅言。麻煩的是 SELECT,如果要把它也異步化,就得動用更復雜的模式了,見問題 8。

相比之下,PostgreSQL 的 C 客戶端 libpq 的設計要好得多,我們可以用 PQsendQuery() 來發起一次查詢,然後用標準的 select/poll/epoll 來等待 PQsocket,如果有數據可讀,那麼用 PQconsumeInput 處理之,並用 PQisBusy 判斷查詢結果是否已就緒,最後用 PQgetResult 來獲取結果。藉助這套異步 API,我們可以很容易地爲 libpq 寫一套 wrapper,使之融入到程序所用的 reactor 模型中。

7. 什麼是線程池大小的阻抗匹配原則?

我在《常用模型》中提到“阻抗匹配原則”,這裏大致講一講。

如果池中線程在執行任務時,密集計算所佔的時間比重爲 P (0 < P <= 1),而系統一共有 C 個 CPU,爲了讓這 C 個 CPU 跑滿而又不過載,線程池大小的經驗公式 T = C/P。(T 是個 hint,考慮到 P 值的估計不是很準確,T 的最佳值可以上下浮動 50%。)

以後我再講這個經驗公式是怎麼來的,先驗證邊界條件的正確性。

假設 C = 8, P = 1.0,線程池的任務完全是密集計算,那麼 T = 8。只要 8 個活動線程就能讓 8 個 CPU 飽和,再多也沒用,因爲 CPU 資源已經耗光了。

假設 C = 8, P = 0.5,線程池的任務有一半是計算,有一半等在 IO 上,那麼 T = 16。考慮操作系統能靈活合理地調度 sleeping/writing/running 線程,那麼大概 16 個“50% 繁忙的線程”能讓 8 個 CPU 忙個不停。啓動更多的線程並不能提高吞吐量,反而因爲增加上下文切換的開銷而降低性能。

如果 P < 0.2,這個公式就不適用了,T 可以取一個固定值,比如 5*C。

另外,公式裏的 C 不一定是 CPU 總數,可以是“分配給這項任務的 CPU 數目”,比如在 8 核機器上分出 4 個核來做一項任務,那麼 C=4。

8. 除了你推薦的 reactor + thread poll,還有別的 non-trivial 多線程編程模型嗎?

有,Proactor。

如果一次請求響應中要和別的進程打多次交道,那麼 proactor 模型往往能做到更高的併發度。當然,代價是代碼變得支離破碎,難以理解。

這裏舉 http proxy 爲例,一次 http proxy 的請求如果沒有命中本地 cache,那麼它多半會:

解析域名 (不要小看這一步,對於一個陌生的域名,解析可能要花半秒鐘)建立連接發送 HTTP 請求等待對方迴應把結果返回客戶

這 5 步裏邊跟 2 個 server 發生了 3 次 round-trip:

向 DNS 問域名,等待回覆;向對方 http 服務器發起連接,等待 TCP 三路握手完成;向對方發送 http request,等待對方 response。

而實際上 http proxy 本身的運算量不大,如果用線程池,池中線程的數目會很龐大,不利於操作系統管理調度。

這時我們有兩個解決思路:

把“域名已解析”,“連接已建立”,“對方已完成響應”做成 event,繼續按照 Reactor 的方式來編程。這樣一來,每次客戶請求就不能用一個函數從頭到尾執行完成,而要分成多個階段,並且要管理好請求的狀態(“目前到了第幾步?”)。用回調函數,讓系統來把任務串起來。比如收到用戶請求,如果沒有命中本地 cache,立刻發起異步的 DNS 解析 startDNSResolve(),告訴系統在解析完之後調用 DNSResolved() 函數;在 DNSResolved() 中,發起連接,告訴系統在連接建立之後調用 connectionEstablished();在 connectionEstablished() 中發送 http request,告訴系統在收到響應之後調用 httpResponsed();最後,在 httpResponsed() 裏把結果返回給客戶。.NET 大量採用的 Begin/End 操作也是這個編程模式。當然,對於不熟悉這種編程方式的人,代碼會顯得很難看。Proactor 模式的例子可看 boost::asio 的文檔,這裏不再多說。

Proactor 模式依賴操作系統或庫來高效地調度這些子任務,每個子任務都不會阻塞,因此能用比較少的線程達到很高的 IO 併發度。

Proactor 能提高吞吐,但不能降低延遲,所以我沒有深入研究。

9. 模式 2 和模式 3a 該如何取捨?

這裏的“模式”不是 pattern,而是 model,不巧它們的中譯是一樣的。《適用場合》中提到,模式 2 是一個多線程的進程,模式 3a 是多個相同的單線程進程。

我認爲,在其他條件相同的情況下,可以根據工作集 (work set) 的大小來取捨。工作集是指服務程序響應一次請求所訪問的內存大小。

如果工作集較大,那麼就用多線程,避免 CPU cache 換入換出,影響性能;否則,就用單線程多進程,享受單線程編程的便利。

例如,memcached 這個內存消耗大戶用多線程服務端就比在同一臺機器上運行多個 memcached instance 要好。(除非你在 16G 內存的機器上運行 32-bit memcached,那麼多 instance 是必須的。)

又例如,求解 Sudoku 用不了多大內存,如果單線程編程更方便的話,可以用單線程多進程來做。再在前面加一個單線程的 load balancer,仿 lighttpd + fastcgi 的成例。

線程不能減少工作量,即不能減少 CPU 時間。如果解決一個問題需要執行一億條指令(這個數字不大,不要被嚇到),那麼用多線程只會讓這個數字增加。但是通過合理調配這一億條指令在多個核上的執行情況,我們能讓工期提早結束。這聽上去像統籌方法,確實也正是統籌方法。

請注意,我一般不在 CSDN 博客的評論跟帖中回覆匿名用戶的提問,如果希望我解答疑惑,請:1. 給我寫信,2. 在 twitter 上 follow @bnu_chenshuo,3. 登陸以後評論。 如有不便,還請見諒。


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