《多執行緒伺服器的適用場合》例釋與答疑
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 時間。如果解決一個問題需要執行一億條指令(這個數字不大,不要被嚇到),那麼用多執行緒只會讓這個數字增加。但是通過合理調配這一億條指令在多個核上的執行情況,我們能讓工期提早結束。這聽上去像統籌方法,確實也正是統籌方法。