多線程服務器的適用場合
作者: solstice_ (2 篇文章) 日期: 十一月 11, 2010 在 8:11 下午
陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice
2010 Feb 28
這篇文章原本是前一篇博客《多線程服務器的常用編程模型》(以下簡稱《常用模型》)計劃中的一節,今天終于寫完了。
“服務器開發”包羅萬象,本文所指的“服務器開發”的含義請見《常用模型》一文,一句話形容是:跑在多核機器上的 Linux 用戶態的沒有用戶界面的長期運行的網絡應用程序。“長期運行”的意思不是指程序 7x24 不重啟,而是程序不會因為無事可做而退出,它會等著下一個請求的到來。例如 wget 不是長期運行的,httpd 是長期運行的。
正名
與前文相同,本文的“進程”指的是 fork() 系統調用的產物。“線程”指的是 pthread_create() 的產物,而且我指的 pthreads 是 NPTL 的,每個線程由 clone() 產生,對應一個內核的 task_struct。本文所用的開發語言是 C++,運行環境為 Linux。
首先,一個由多臺機器組成的分布式系統必然是多進程的(字面意義上),因為進程不能跨 OS 邊界。在這個前提下,我們把目光集中到一臺機器,一臺擁有至少 4 個核的普通服務器。如果要在一臺多核機器上提供一種服務或執行一個任務,可用的模式有:
運行一個單線程的進程
運行一個多線程的進程
運行多個單線程的進程
運行多個多線程的進程
這些模式之間的比較已經是老生常談,簡單地總結:
模式 1 是不可伸縮的 (scalable),不能發揮多核機器的計算能力;
模式 3 是目前公認的主流模式。它有兩種子模式:
3a 簡單地把模式 1 中的進程運行多份,如果能用多個 tcp port 對外提供服務的話;
3b 主進程+woker進程,如果必須綁定到一個 tcp port,比如 httpd+fastcgi。
模式 2 是很多人鄙視的,認為多線程程序難寫,而且不比模式 3 有什么優勢;
模式 4 更是千夫所指,它不但沒有結合 2 和 3 的優點,反而匯聚了二者的缺點。
本文主要想討論的是模式 2 和模式 3b 的優劣,即:什么時候一個服務器程序應該是多線程的。
從功能上講,沒有什么是多線程能做到而單線程做不到的,反之亦然,都是狀態機嘛(我很高興看到反例)。從性能上講,無論是 IO bound 還是 CPU bound 的服務,多線程都沒有什么優勢。那么究竟為什么要用多線程?
在回答這個問題之前,我先談談必須用必須用單線程的場合。
必須用單線程的場合
據我所知,有兩種場合必須使用單線程:
程序可能會 fork()
限制程序的 CPU 占用率
先說 fork(),我在《Linux 新增系統調用的啟示》中提到:
fork() 一般不能在多線程程序中調用,因為 Linux 的 fork() 只克隆當前線程的 thread of control,不克隆其他線程。也就是說不能一下子 fork() 出一個和父進程一樣的多線程子進程,Linux 也沒有 forkall() 這樣的系統調用。forkall() 其實也是很難辦的(從語意上),因為其他線程可能等在 condition variable 上,可能阻塞在系統調用上,可能等著 mutex 以跨入臨界區,還可能在密集的計算中,這些都不好全盤搬到子進程里。
更為糟糕的是,如果在 fork() 的一瞬間某個別的線程 a 已經獲取了 mutex,由于 fork() 出的新進程里沒有這個“線程a”,那么這個 mutex 永遠也不會釋放,新的進程就不能再獲取那個 mutex,否則會死鎖。(這一點僅為推測,還沒有做實驗,不排除 fork() 會釋放所有 mutex 的可能。)
綜上,一個設計為可能調用 fork() 的程序必須是單線程的,比如我在《啟示》一文中提到的“看門狗進程”。多線程程序不是不能調用 fork(),而是這么做會遇到很多麻煩,我想不出做的理由。
一個程序 fork() 之后一般有兩種行為:
立刻執行 exec(),變身為另一個程序。例如 shell 和 inetd;又比如 lighttpd fork() 出子進程,然后運行 fastcgi 程序。或者集群中運行在計算節點上的負責啟動 job 的守護進程(即我所謂的“看門狗進程”)。
不調用 exec(),繼續運行當前程序。要么通過共享的文件描述符與父進程通信,協同完成任務;要么接過父進程傳來的文件描述符,獨立完成工作,例如 80 年代的 web 服務器 NCSA httpd。
這些行為中,我認為只有“看門狗進程”必須堅持單線程,其他的均可替換為多線程程序(從功能上講)。
單線程程序能限制程序的 CPU 占用率。
這個很容易理解,比如在一個 8-core 的主機上,一個單線程程序即便發生 busy-wait(無論是因為 bug 還是因為 overload),其 CPU 使用率也只有 12.5%,即占滿 1 個 core。在這種最壞的情況下,系統還是有 87.5% 的計算資源可供其他服務進程使用。
因此對于一些輔助性的程序,如果它必須和主要功能進程運行在同一臺機器的話(比如它要監控其他服務進程的狀態),那么做成單線程的能避免過分搶奪系統的計算資源。
基于進程的分布式系統設計
《常用模型》一文提到,分布式系統的軟件設計和功能劃分一般應該以“進程”為單位。我提倡用多線程,并不是說把整個系統放到一個進程里實現,而是指功能劃分之后,在實現每一類服務進程時,在必要時可以借助多線程來提高性能。對于整個分布式系統,要做到能 scale out,即享受增加機器帶來的好處。
對于上層的應用而言,每個進程的代碼量控制在 10 萬行 C++ 以下,這不包括現成的 library 的代碼量。這樣每個進程都能被一個腦子完全理解,不會出現混亂。(其實我更想說 5 萬行。)
這里推薦一篇 Google 的好文《Introduction to Distributed System Design》。其中點睛之筆是:分布式系統設計,是 design for failure。
本文繼續討論一個服務進程什么時候應該用多線程,先說說單線程的優勢。
單線程程序的優勢
從編程的角度,單線程程序的優勢無需贅言:簡單。程序的結構一般如《常用模型》所言,是一個基于 IO multiplexing 的 event loop。或者如云風所言,直接用阻塞 IO。
event loop 的典型代碼框架是:
while (!done) {
int retval = ::poll(fds, nfds, timeout_ms);
if (retval 0) {
處理 IO 事件
}
}
}
event loop 有一個明顯的缺點,它是非搶占的(non-preemptive)。假設事件 a 的優先級高于事件 b,處理事件 a 需要 1ms,處理事件 b 需要 10ms。如果事件 b 稍早于 a 發生,那么當事件 a 到來時,程序已經離開了 poll() 調用開始處理事件 b。事件 a 要等上 10ms 才有機會被處理,總的響應時間為 11ms。這等于發生了優先級反轉。
這可缺點可以用多線程來克服,這也是多線程的主要優勢。
多線程程序有性能優勢嗎?
前面我說,無論是 IO bound 還是 CPU bound 的服務,多線程都沒有什么絕對意義上的性能優勢。這里詳細闡述一下這句話的意思。
這句話是說,如果用很少的 CPU 負載就能讓的 IO 跑滿,或者用很少的 IO 流量就能讓 CPU 跑滿,那么多線程沒啥用處。舉例來說:
對于靜態 web 服務器,或者 ftp 服務器,CPU 的負載較輕,主要瓶頸在磁盤 IO 和網絡 IO。這時候往往一個單線程的程序(模式 1)就能撐滿 IO。用多線程并不能提高吞吐量,因為 IO 硬件容量已經飽和了。同理,這時增加 CPU 數目也不能提高吞吐量。
CPU 跑滿的情況比較少見,這里我只好虛構一個例子。假設有一個服務,它的輸入是 n 個整數,問能否從中選出 m 個整數,使其和為 0 (這里 n 0)。這是著名的 subset sum 問題,是 NP-Complete 的。對于這樣一個“服務”,哪怕很小的 n 值也會讓 CPU 算死,比如 n = 30,一次的輸入不過 120 字節(32-bit 整數),CPU 的運算時間可能長達幾分鐘。對于這種應用,模式 3a 是最適合的,能發揮多核的優勢,程序也簡單。
也就是說,無論任何一方早早地先到達瓶頸,多線程程序都沒啥優勢。
說到這里,可能已經有讀者不耐煩了:你講了這么多,都在說單線程的好處,那么多線程究竟有什么用?
適用多線程程序的場景
我認為多線程的適用場景是:提高響應速度,讓 IO 和“計算”相互重疊,降低 latency。
雖然多線程不能提高絕對性能,但能提高平均響應性能。
一個程序要做成多線程的,大致要滿足:
有多個 CPU 可用。單核機器上多線程的優勢不明顯。
線程間有共享數據。如果沒有共享數據,用模型 3b 就行。雖然我們應該把線程間的共享數據降到最低,但不代表沒有;
共享的數據是可以修改的,而不是靜態的常量表。如果數據不能修改,那么可以在進程間用 shared memory,模式 3 就能勝任;
提供非均質的服務。即,事件的響應有優先級差異,我們可以用專門的線程來處理優先級高的事件。防止優先級反轉;
latency 和 throughput 同樣重要,不是邏輯簡單的 IO bound 或 CPU bound 程序;
利用異步操作。比如 logging。無論往磁盤寫 log file,還是往 log server 發送消息都不應該阻塞 critical path;
能 scale up。一個好的多線程程序應該能享受增加 CPU 數目帶來的好處,目前主流是 8 核,很快就會用到 16 核的機器了。
具有可預測的性能。隨著負載增加,性能緩慢下降,超過某個臨界點之后急速下降。線程數目一般不隨負載變化。
多線程能有效地劃分責任與功能,讓每個線程的邏輯比較簡單,任務單一,便于編碼。而不是把所有邏輯都塞到一個 event loop 里,就像 Win32 SDK 程序那樣。
這些條件比較抽象,這里舉一個具體的(雖然是虛構的)例子。
假設要管理一個 Linux 服務器機群,這個機群里有 8 個計算節點,1 個控制節點。機器的配置都是一樣的,雙路四核 CPU,千兆網互聯。現在需要編寫一個簡單的機群管理軟件(參考 LLNL 的 SLURM),這個軟件由三個程序組成:
運行在控制節點上的 master,這個程序監視并控制整個機群的狀態。
運在每個計算節點上的 slave,負責啟動和終止 job,并監控本機的資源。
給最終用戶的 client 命令行工具,用于提交 job。
根據前面的分析,slave 是個“看門狗進程”,它會啟動別的 job 進程,因此必須是個單線程程序。另外它不應該占用太多的 CPU 資源,這也適合單線程模型。
master 應該是個模式 2 的多線程程序:
它獨占一臺 8 核的機器,如果用模型 1,等于浪費了 87.5% 的 CPU 資源。
整個機群的狀態應該能完全放在內存中,這些狀態是共享且可變的。如果用模式 3,那么進程之間的狀態同步會成大問題。而如果大量使用共享內存,等于是掩耳盜鈴,披著多進程外衣的多線程程序。
master 的主要性能指標不是 throughput,而是 latency,即盡快地響應各種事件。它幾乎不會出現把 IO 或 CPU 跑滿的情況。
master 監控的事件有優先級區別,一個程序正常運行結束和異常崩潰的處理優先級不同,計算節點的磁盤滿了和機箱溫度過高這兩種報警條件的優先級也不同。如果用單線程,可能會出現優先級反轉。
假設 master 和每個 slave 之間用一個 TCP 連接,那么 master 采用 2 個或 4 個 IO 線程來處理 8 個 TCP connections 能有效地降低延遲。
master 要異步的往本地硬盤寫 log,這要求 logging library 有自己的 IO 線程。
master 有可能要讀寫數據庫,那么數據庫連接這個第三方 library 可能有自己的線程,并回調 master 的代碼。
master 要服務于多個 clients,用多線程也能降低客戶響應時間。也就是說它可以再用 2 個 IO 線程專門處理和 clients 的通信。
master 還可以提供一個 monitor 接口,用來廣播 (pushing) 機群的狀態,這樣用戶不用主動輪詢 (polling)。這個功能如果用單獨的線程來做,會比較容易實現,不會搞亂其他主要功能。
master 一共開了 10 個線程:
4 個用于和 slaves 通信的 IO 線程
1 個 logging 線程
1 個數據庫 IO 線程
2 個和 clients 通信的 IO 線程
1 個主線程,用于做些背景工作,比如 job 調度
1 個 pushing 線程,用于主動廣播機群的狀態
雖然線程數目略多于 core 數目,但是這些線程很多時候都是空閑的,可以依賴 OS 的進程調度來保證可控的延遲。
綜上所述,master 用多線程方式編寫是自然且高效的。
線程的分類
據我的經驗,一個多線程服務程序中的線程大致可分為 3 類:
IO 線程,這類線程的的主循環是 io multiplexing,等在 select/poll/epoll 系統調用上。這類線程也處理定時事件。當然它的功能不止 IO,有些計算也可以放入其中。
計算線程,這類線程的主循環是 blocking queue,等在 condition variable 上。這類線程一般位于 thread pool 中。
第三方庫所用的線程,比如 logging,又比如 database connection。
服務器程序一般不會頻繁地啟動和終止線程。甚至,在我寫過的程序里,create thread 只在程序啟動的時候調用,在服務運行期間是不調用的。
在多核時代,多線程編程是不可避免的,“鴕鳥算法”不是辦法。