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