程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> .NET網頁編程 >> 關於.NET >> 多線程服務器的適用場合

多線程服務器的適用場合

編輯:關於.NET

“服務器開發”包羅萬象,本文所指的“服務器開發”的含義請見《常用模型》一文,一句話形容是 :跑在多核機器上的 Linux 用戶態的沒有用戶界面的長期運行的網絡應用程序。“長期運行”的意思不 是指程序 7x24 不重啟,而是程序不會因為無事可做而退出,它會等著下一個請求的到來。例如 wget 不 是長期運行的,httpd 是長期運行的。

與前文相同,本文的“進程”指的是 fork() 系統調用的產物。“線程”指的是 pthread_create() 的產物,而且我指的 pthreads 是 NPTL 的,每個線程由 clone() 產生,對應一個內核的 task_struct 。本文所用的開發語言是 C++,運行環境為 Linux。

首先,一個由多台機器組成的分布式系統必然是多進程的(字面意義上),因為進程不能跨 OS 邊界 。在這個前提下,我們把目光集中到一台機器,一台擁有至少 4 個核的普通服務器。如果要在一台多核 機器上提供一種服務或執行一個任務,可用的模式有:

1. 運行一個單線程的進程

2. 運行一個多線程的進程

3. 運行多個單線程的進程

4. 運行多個多線程的進程

這些模式之間的比較已經是老生常談,簡單地總結:

* 模式 1 是不可伸縮的 (scalable),不能發揮多核機器的計算能力;

* 模式 3 是目前公認的主流模式。它有兩種子模式:

o 3a 簡單地把模式 1 中的進程運行多份,如果能用多個 tcp port 對外提供服務的話;

o 3b 主進程+woker進程,如果必須綁定到一個 tcp port,比如 httpd+fastcgi。

* 模式 2 是很多人鄙視的,認為多線程程序難寫,而且不比模式 3 有什麼優勢;

* 模式 4 更是千夫所指,它不但沒有結合 2 和 3 的優點,反而匯聚了二者的缺點。

本文主要想討論的是模式 2 和模式 3b 的優劣,即:什麼時候一個服務器程序應該是多線程的。

從功能上講,沒有什麼是多線程能做到而單線程做不到的,反之亦然,都是狀態機嘛(我很高興看到 反例)。從性能上講,無論是 IO bound 還是 CPU bound 的服務,多線程都沒有什麼優勢。那麼究竟為 什麼要用多線程?

在回答這個問題之前,我先談談必須用必須用單線程的場合。

必須用單線程的場合

據我所知,有兩種場合必須使用單線程:

1. 程序可能會 fork()

2. 限制程序的 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() 之後一般有兩種行為:

1. 立刻執行 exec(),變身為另一個程序。例如 shell 和 inetd;又比如 lighttpd fork() 出子進 程,然後運行 fastcgi 程序。或者集群中運行在計算節點上的負責啟動 job 的守護進程(即我所謂的“ 看門狗進程”)。

2. 不調用 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) {
     處理錯誤
   } 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 跑滿, 那麼多線程沒啥用處。舉例來說:

1. 對於靜態 web 服務器,或者 ftp 服務器,CPU 的負載較輕,主要瓶頸在磁盤 IO 和網絡 IO。這 時候往往一個單線程的程序(模式 1)就能撐滿 IO。用多線程並不能提高吞吐量,因為 IO 硬件容量已 經飽和了。同理,這時增加 CPU 數目也不能提高吞吐量。

2. 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 程序那樣。

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

假設要管理一個 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 個線程:

o 4 個用於和 slaves 通信的 IO 線程

o 1 個 logging 線程

o 1 個數據庫 IO 線程

o 2 個和 clients 通信的 IO 線程

o 1 個主線程,用於做些背景工作,比如 job 調度

o 1 個 pushing 線程,用於主動廣播機群的狀態

* 雖然線程數目略多於 core 數目,但是這些線程很多時候都是空閒的,可以依賴 OS 的進程調度來 保證可控的延遲。

綜上所述,master 用多線程方式編寫是自然且高效的。

線程的分類

據我的經驗,一個多線程服務程序中的線程大致可分為 3 類:

1. IO 線程,這類線程的的主循環是 io multiplexing,等在 select/poll/epoll 系統調用上。這類 線程也處理定時事件。當然它的功能不止 IO,有些計算也可以放入其中。

2. 計算線程,這類線程的主循環是 blocking queue,等在 condition variable 上。這類線程一般 位於 thread pool 中。

3. 第三方庫所用的線程,比如 logging,又比如 database connection。

服務器程序一般不會頻繁地啟動和終止線程。甚至,在我寫過的程序裡,create thread 只在程序啟 動的時候調用,在服務運行期間是不調用的。

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

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved