天天看點

多線程伺服器的适用場合

陳碩 (giantchen_AT_gmail)

www.cnblogs.com/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() 隻克隆目前線程的 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 萬行。)

本文繼續讨論一個服務程序什麼時候應該用多線程,先說說單線程的優勢。

event loop 的典型代碼架構是:

while (!done) { 

  int retval = ::poll(fds, nfds, timeout_ms); 

  if (retval < 0) { 

    處理錯誤 

  } else { 

    處理到期的 timers 

    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 < 100, m > 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 程式那樣。

這些條件比較抽象,這裡舉一個具體的(雖然是虛構的)例子。

運作在控制節點上的 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 隻在程式啟動的時候調用,在服務運作期間是不調用的。

在多核時代,多線程程式設計是不可避免的,“鴕鳥算法”不是辦法。

    本文轉自 陳碩  部落格園部落格,原文連結:http://www.cnblogs.com/Solstice/archive/2010/02/28/why_multithreading.html,如需轉載請自行聯系原作者

繼續閱讀