1. 程式人生 > >服務端程式設計中多執行緒的應用

服務端程式設計中多執行緒的應用

         本文是陳碩的《Linux多執行緒服務端程式設計  使用muduo C++網路庫》一書中,第三章的讀書筆記。其中暗紅顏色的文字是自己的理解,鮮紅顏色的文字表示原書中需要注意的地方。

一:程序和執行緒

每個程序有自己獨立的地址空間。“在同一個程序”還是“不在同一個程序”是系統功能劃分的重要決策點。《Erlang程式設計》[ERL]把程序比喻為人:

每個人有自己的記憶(記憶體),人與人通過談話(訊息傳遞)來交流,談話既可以是面談(同一臺伺服器),也可以在電話裡談(不同的伺服器,有網路通訊)。面談和電話談的區別在於,面談可以立即知道對方是否死了(crash,SIGCHLD),而電話談只能通過週期性的心跳來判斷對方是否還活著。

有了這些比喻,設計分散式系統時可以採取“角色扮演”,團隊裡的幾個人各自扮演一個程序,人的角色由程序的程式碼決定(管登入的、管訊息分發的、管買賣的等等)。每個人有自己的記憶,但不知道別人的記憶,要想知道別人的看法,只能通過交談(暫不考慮共享記憶體這種IPC)。然後就可以思考:

·容錯:萬一有人突然死了

·擴容:新人中途加進來

·負載均衡:把甲的活兒挪給乙做

·退休:甲要修復bug,先別派新任務,等他做完手上的事情就把他重啟

等等各種場景,十分便利。

執行緒的特點是共享地址空間,從而可以高效地共享資料。一臺機器上的多個程序能高效地共享程式碼段(作業系統可以對映為同樣的實體記憶體),但不能共享資料。如果多個程序大量共享記憶體,等於是把多程序程式當成多執行緒來寫,掩耳盜鈴。

“多執行緒”的價值,我認為是為了更好地發揮多核處理器(multi-cores)的效能。在單核時代,多執行緒沒有多大價值(個人想法:如果要完成的任務是CPU密集型的,那多執行緒沒有優勢,甚至因為執行緒切換的開銷,多執行緒反而更慢;如果要完成的任務既有CPU計算,又有磁碟或網路IO,則使用多執行緒的好處是,當某個執行緒因為IO而阻塞時,OS可以排程其他執行緒執行,雖然效率確實要比任務的順序執行效率要高,然而,這種型別的任務,可以通過單執行緒的”non-blocking IO+IO multiplexing”的模型(事件驅動)來提高效率,採用多執行緒的方式,帶來的可能僅僅是程式設計上的簡單而已)。Alan Cox說過:”A computer is a state machine.Threads are for people who can’t program state machines.”(計算機是一臺狀態機。執行緒是給那些不能編寫狀態機程式的人準備的)如果只有一塊CPU、一個執行單元,那麼確實如Alan Cox所說,按狀態機的思路去寫程式是最高效的。

二:單執行緒伺服器的常用程式設計模型

據我瞭解,在高效能的網路程式中,使用得最為廣泛的恐怕要數”non-blocking IO + IO multiplexing”這種模型,即Reactor模式。

在”non-blocking IO + IO multiplexing”這種模型中,程式的基本結構是一個事件迴圈(event loop),以事件驅動(event-driven)和事件回撥的方式實現業務邏輯:

//程式碼僅為示意,沒有完整考慮各種情況
while(!done)
{
    int timeout_ms = max(1000, getNextTimedCallback());
    int retval = poll(fds, nfds, timeout_ms);
    if (retval<0){
        處理錯誤,回撥使用者的error handler
    }else{
        處理到期的timers,回撥使用者的timer handler
        if(retval>0){
        處理IO事件,回撥使用者的IO event handler
        }
    }
}

這裡select(2)/poll(2)有伸縮性方面的不足(描述符過多時,效率較低),Linux下可替換為epoll(4),其他作業系統也有對應的高效能替代品。

Reactor模型的優點很明顯,程式設計不難,效率也不錯。不僅可以用於讀寫socket,連線的建立(connect(2)/accept(2)),甚至DNS解析都可以用非阻塞方式進行,以提高併發度和吞吐量(throughput),對於IO密集的應用是個不錯的選擇。lighttpd就是這樣,它內部的fdevent結構十分精妙,值得學習。

基於事件驅動的程式設計模型也有其本質的缺點,它要求事件回撥函式必須是非阻塞的。對於涉及網路IO的請求響應式協議,它容易割裂業務邏輯,使其散佈於多個回撥函式之中,相對不容易理解和維護。

三:多執行緒伺服器的常用程式設計模型

         大概有這麼幾種:

a:每個請求建立一個執行緒,使用阻塞式IO操作。在Java 1.4引人NIO之前,這是Java網路程式設計的推薦做法。可惜伸縮性不佳(請求太多時,作業系統建立不了這許多執行緒)。

b:使用執行緒池,同樣使用阻塞式IO操作。與第1種相比,這是提高效能的措施。

c:使用non-blocking IO + IO multiplexing。即Java NIO的方式。

d:Leader/Follower等高階模式。

在預設情況下,我會使用第3種,即non-blocking IO + one loop per thread模式來編寫多執行緒C++網路服務程式。

1:one loop per thread

此種模型下,程式裡的每個IO執行緒有一個event  loop,用於處理讀寫和定時事件(無論週期性的還是單次的)。程式碼框架跟“單執行緒伺服器的常用程式設計模型”一節中的一樣。

libev的作者說:

One loop per thread is usually a good model. Doing this is almost never wrong, some times a better-performance model exists, but it is always a good start.

這種方式的好處是:

a:執行緒數目基本固定,可以在程式啟動的時候設定,不會頻繁建立與銷燬。

b:可以很方便地線上程間調配負載。

c:IO事件發生的執行緒是固定的,同一個TCP連線不必考慮事件併發。

Event loop代表了執行緒的主迴圈,需要讓哪個執行緒幹活,就把timer或IO channel(如TCP連線)註冊到哪個執行緒的loop裡即可:對實時性有要求的connection可以單獨用一個執行緒;資料量大的connection可以獨佔一個執行緒,並把資料處理任務分攤到另幾個計算執行緒中(用執行緒池);其他次要的輔助性connections可以共享一個執行緒。

比如,在dbproxy中,一個執行緒用於專門處理客戶端發來的管理命令;一個執行緒用於處理客戶端發來的mysql命令,而與後端資料庫通訊執行該命令時,是將該任務分配給所有事件執行緒處理的。

對於non-trivial(有一定規模)的服務端程式,一般會採用non-blocking IO + IO multiplexing,每個connection/acceptor都會註冊到某個event loop上,程式裡有多個event loop,每個執行緒至多有一個event loop。

多執行緒程式對event loop提出了更高的要求,那就是“執行緒安全”。要允許一個執行緒往別的執行緒的loop裡塞東西,這個loop必須得是執行緒安全的。

dbproxy中,執行緒向其他執行緒分發任務,是通過管道和佇列實現的。比如主執行緒accept到連線後,將表示該連線的結構放入佇列,並向管道中寫入一個位元組。計算執行緒在自己的event loop中註冊管道的讀事件,一旦有資料可讀,就嘗試從佇列中取任務。

2:執行緒池

不過,對於沒有IO而光有計算任務的執行緒,使用event loop有點浪費。可以使用一種補充方案,即用blocking  queue實現的任務佇列

typedef boost::function<void()>Functor;
BlockingQueue<Functor> taskQueue;   //執行緒安全的全域性阻塞佇列

//計算執行緒
void workerThread()
{
    while (running) //running變數是個全域性標誌
    {
        Functor task = taskQueue.take();    //this blocks
        task();     //在產品程式碼中需要考慮異常處理
    }
}

// 建立容量(併發數)為N的執行緒池
int N = num_of_computing_threads;
for (int i = 0; i < N; ++i)
{
    create_thread(&workerThread);   //啟動執行緒
}

//向任務佇列中追加任務
Foo foo;    //Foo有calc()成員函式
boost::function<void()> task = boost::bind(&Foo::calc,&foo);
taskQueue.post(task);

除了任務佇列,還可以用BlockingQueue<T>實現資料的生產者消費者佇列,即T是資料型別而非函式物件,queue的消費者從中拿到資料進行處理。其實本質上是一樣的。

3:總結

總結而言,我推薦的C++多執行緒服務端程式設計模式為:one (event) loop per thread + thread pool:

event  loop用作IO multiplexing,配合non-blockingIO和定時器;

thread  pool用來做計算,具體可以是任務佇列或生產者消費者佇列。

以這種方式寫伺服器程式,需要一個優質的基於Reactor模式的網路庫來支撐,muduo正是這樣的網路庫。比如dbproxy使用的是libevent

程式裡具體用幾個loop、執行緒池的大小等引數需要根據應用來設定,基本的原則是“阻抗匹配”(解釋見下),使得CPU和IO都能高效地運作。所謂阻抗匹配原則:

如果池中執行緒在執行任務時,密集計算所佔的時間比重為 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。

四:程序間通訊只用TCP

         Linux下程序間通訊的方式有:匿名管道(pipe)、具名管道(FIFO)、POSIX訊息佇列、共享記憶體、訊號(signals),以及Socket。同步原語有互斥器(mutex)、條件變數(condition variable)、讀寫鎖(reader-writer lock)、檔案鎖(record locking)、訊號量(semaphore)等等。

程序間通訊我首選Sockets(主要指TCP,我沒有用過UDP,也不考慮Unix domain協議)。其好處在於:

可以跨主機,具有伸縮性。反正都是多程序了,如果一臺機器的處理能力不夠,很自然地就能用多臺機器來處理。把程序分散到同一區域網的多臺機器上,程式改改host:port配置就能繼續用;

TCP sockets和pipe都是操作檔案描述符,用來收發位元組流,都可以read/write/fcntl/select/poll等。不同的是,TCP是雙向的,Linux的pipe是單向的,程序間雙向通訊還得開兩個檔案描述符,不方便;而且程序要有父子關係才能用pipe,這些都限制了pipe的使用;

TCP port由一個程序獨佔,且程序退出時作業系統會自動回收檔案描述符。因此即使程式意外退出,也不會給系統留下垃圾,程式重啟之後能比較容易地恢復,而不需要重啟作業系統(用跨程序的mutex就有這個風險);而且,port是獨佔的,可以防止程式重複啟動,後面那個程序搶不到port,自然就沒法初始化了,避免造成意料之外的結果;

與其他IPC相比,TCP協議的一個天生的好處是“可記錄、可重現”。tcpdump和Wireshark是解決兩個程序間協議和狀態爭端的好幫手,也是效能(吞吐量、延遲)分析的利器。我們可以藉此編寫分散式程式的自動化迴歸測試。也可以用tcpcopy之類的工具進行壓力測試。TCP還能跨語言,服務端和客戶端不必使用同一種語言。

分散式系統的軟體設計和功能劃分一般應該以“程序”為單位。從巨集觀上看,一個分散式系統是由執行在多臺機器上的多個程序組成的,程序之間採用TCP長連線通訊。

使用TCP長連線的好處有兩點:一是容易定位分散式系統中的服務之間的依賴關係。只要在機器上執行netstat  -tpna|grep  <port>就能立刻列出用到某服務的客戶端地址(Foreign Address列),然後在客戶端的機器上用netstat或lsof命令找出是哪個程序發起的連線。TCP短連線和UDP則不具備這一特性。二是通過接收和傳送佇列的長度也較容易定位網路或程式故障。在正常執行的時候,netstat列印的Recv-Q和Send-Q都應該接近0,或者在0附近擺動。如果Recv-Q保持不變或持續增加,則通常意味著服務程序的處理速度變慢,可能發生了死鎖或阻塞。如果Send-Q保持不變或持續增加,有可能是對方伺服器太忙、來不及處理,也有可能是網路中間某個路由器或交換機故障造成丟包,甚至對方伺服器掉線,這些因素都可能表現為資料傳送不出去。通過持續監控Recv-Q和Send-Q就能及早預警效能或可用性故障。以下是服務端執行緒阻塞造成Recv-Q和客戶端Send-Q激增的例子:

$netstat -tn
Proto  Recv-Q  Send-Q  Local Address    Foreign
tcp     78393       0  10.0.0.10:2000   10.0.0.10:39748     #服務端連線
tcp         0  132608  10.0.0.10:39748  10.0.0.10:2000      #客戶端連線
tcp         0      52  10.0.0.10:22     10.0.0.4:55572

五:多執行緒伺服器的適用場合

如果要在一臺多核機器上提供一種服務或執行一個任務,可用的模式有:

a:執行一個單執行緒的程序;

b:執行一個多執行緒的程序;

c:執行多個單執行緒的程序;

d:執行多個多執行緒的程序;

考慮這樣的場景:如果使用速率為50MB/s的資料壓縮庫,程序建立銷燬的開銷是800微秒,執行緒建立銷燬的開銷是50微秒。如何執行壓縮任務?

如果要偶爾壓縮1GB的文字檔案,預計執行時間是20s,那麼起一個程序去做是合理的,因為程序啟動和銷燬的開銷遠遠小於實際任務的耗時。

如果要經常壓縮500kB的文字資料,預計執行時間是10ms,那麼每次都起程序      似乎有點浪費了,可以每次單獨起一個執行緒去做。

如果要頻繁壓縮10kB的文字資料,預計執行時間是200微秒,那麼每次起執行緒似      乎也很浪費,不如直接在當前執行緒搞定。也可以用一個執行緒池,每次把壓縮任務交給執行緒池,避免阻塞當前執行緒(特別要避免阻塞IO執行緒)。

由此可見,多執行緒並不是萬靈丹(silver bullet)。

1:必須使用單執行緒的場合

據我所知,有兩種場合必須使用單執行緒:

a:程式可能會fork(2);

實際程式設計中,應該保證只有單執行緒程式能進行fork(2)。多執行緒程式不是不能呼叫fork(2),而是這麼做會遇到很多麻煩:

fork一般不能在多執行緒程式中呼叫,因為Linuxfork只克隆當前執行緒的thread of control,不可隆其他執行緒。fork之後,除了當前執行緒之外,其他執行緒都消失了。

這就造成一種危險的局面。其他執行緒可能正好處於臨界區之內,持有了某個鎖,而它突然死亡,再也沒有機會去解鎖了。此時如果子程序試圖再對同一個mutex加鎖,就會立即死鎖。因此,fork之後,子程序就相當於處於signal handler之中因為不知道呼叫fork時,父程序中的執行緒此時正在呼叫什麼函式,這和訊號發生時的場景一樣),你不能呼叫執行緒安全的函式(除非它是可重入的),而只能呼叫非同步訊號安全的函式。比如,fork之後,子程序不能呼叫:

malloc,因為malloc在訪問全域性狀態時幾乎肯定會加鎖;

任何可能分配或釋放記憶體的函式,比如snprintf;

任何Pthreads函式;

printf系列函式,因為其他執行緒可能恰好持有stdout/stderr的鎖;

除了man 7 signal中明確列出的訊號安全函式之外的任何函式。

因此,多執行緒中呼叫fork,唯一安全的做法是fork之後,立即呼叫exec執行另一個程式,徹底隔斷子程序與父程序的聯絡。

在多執行緒環境中呼叫fork,產生子程序後。子程序內部只存在一個執行緒,也就是父程序中呼叫fork的執行緒的副本。

使用fork建立子程序時,子程序通過繼承整個地址空間的副本,也從父程序那裡繼承了所有互斥量、讀寫鎖和條件變數的狀態。如果父程序中的某個執行緒佔有鎖,則子程序同樣佔有這些鎖。問題是子程序並不包含佔有鎖的執行緒的副本,所以子程序沒有辦法知道它佔有了哪些鎖,並且需要釋放哪些鎖。

儘管Pthread提供了pthread_atfork函式試圖繞過這樣的問題,但是這回使得程式碼變得混亂。因此《Programming With Posix Threads》一書的作者說:”Avoid using fork in threaded code except where the child process will immediately exec a new program.”

b:限制程式的CPU佔用率;

這個很容易理解,比如在一個8核的伺服器上,一個單執行緒程式即便發生busy-wait,佔滿1個core,其CPU使用率也只有12.5%,在這種最壞的情況下,系統還是有87.5%的計算資源可供其他服務程序使用。

因此對於一些輔助性的程式,如果它必須和主要服務程序執行在同一臺機器的話,那麼做成單執行緒的能避免過分搶奪系統的計算資源。

2:單執行緒程式的優缺點

從程式設計的角度,單執行緒程式的優勢在於簡單。程式的結構一般就是一個基於IO  multiplexing的event loop。

但是Event loop有一個明顯的缺點,它是非搶佔的。假設事件a的優先順序高於事件b,處理事件a需要1ms,處理事件b需要10ms。如果事件b稍早於a發生,那麼當事件a到來時,程式已經離開了poll(2)呼叫,並開始處理事件b。事件a要等上10ms才有機會被處理,總的響應時間為11ms。這等於發生了優先順序反轉。這個缺點可以用多執行緒來克服,這也是多執行緒的主要優勢。

3:適用多執行緒程式的場景

多執行緒相比於單執行緒、多程序的模式,未必有絕對意義上的效能優勢。比如說,如果用很少的CPU負載就能讓IO跑滿,或者用很少的IO流量就能讓CPU跑滿,那麼多執行緒沒啥用處。舉例來說:

對於靜態Web伺服器,或者FTP伺服器,CPU的負載較輕,主要瓶頸在磁碟IO和網路IO方面。這時往往一個單執行緒的程式就能撐滿IO。用多執行緒並不能提高吞吐量,因為IO硬體容量已經飽和了。同理,這時增加CPU數目也不能提高吞吐量。

CPU跑滿的情況比較少見,這裡虛構一個例子。假設有一個服務,它的輸人是n個整數,問能否從中選出m個整數,使其和為0(這裡n < 100,m > 0)。這是著名的subset sum問題,是NP-Complete的。對於這樣一個“服務”,哪怕很小的n值也會讓CPU算死。比如n=30,一次的輸人不過200位元組(32-bit整數),CPU的運算時間卻能長達幾分鐘。對於這種應用。多程序模式是最適合的,能發揮多核的優勢,程式也簡單(多執行緒相比於多程序的優勢在於:多執行緒更輕量,執行緒間的互動更簡單。該場景中,執行任務的時間遠大於執行緒或程序啟動銷燬的開銷,而且這種場景,程序或執行緒之間也無需互動。所以,多執行緒並不比多程序更好)。

那麼多執行緒到底適合哪種場景呢?我認為多執行緒的適用場景是:提高響應速度,讓IO和“計算”相互重疊,降低延遲。雖然多執行緒不能提高絕對效能,但能提高平均響應效能。

一個程式要做成多執行緒的,大致要滿足:

有多個CPU可用。單核機器上多執行緒沒有效能優勢(但或許能簡化併發業務邏輯的實現);

執行緒間有共享資料,即記憶體中的全域性狀態。如果沒有共享資料,用多程序就行;

共享的資料是可以修改的,而不是靜態的常量表。如果資料不能修改,那麼採用多程序模式,程序間用共享記憶體即可;

提供非均質的服務。即,事件的響應有優先順序差異,我們可以用專門的執行緒來處理優先順序高的事件。防止優先順序反轉;

延遲和吞吐量同樣重要,不是邏輯簡單的IO bound或CPU bound程式。換言之,程式要有相當的計算量;

利用非同步操作。比如logging模組。無論往磁碟寫log 檔案,還是往log server傳送訊息都不應該阻塞關鍵路徑;

能擴充套件。一個好的多執行緒程式可以享受增加CPU數目帶來的好處;

具有可預測的效能。隨著負載增加,效能緩慢下降,超過某個臨界點之後會急速下降。執行緒數目一般不隨負載變化;

多執行緒能有效地劃分責任與功能,讓每個執行緒的邏輯比較簡單,任務單一,便於編碼。而不是把所有邏輯都塞到一個event loop裡,使不同類別的事件之間相互影響。

以上這些條件比較抽象,這裡舉個具體的例子。

假設要管理一個Linux伺服器機群,這個機群有8個計算節點,1個控制節點。機器的配置都是一樣的,雙路四核CPU,千兆網互聯。現在需要編寫一個簡單的機群管理軟體,這個軟體由3個程式組成:

執行在控制節點上的master,這個程式監視並控制整個機群的狀態;

執行在每個計算節點上的slave,負責啟動和終止job,並監控本機的資源;

供終端使用者使用的client命令列工具,用於提交job;

slave是個“看門狗程序”,它就是簡單的fork子程序,由子程序exec別的job程序。因此必須是個單執行緒程式,另外它不應該佔用太多的CPU資源,這也適合單執行緒模型。

master應該是個多執行緒程式,因為:

它獨佔一臺8核的機器,如果用單執行緒,等於浪費了87.5%的CPU資源;

整個機群的狀態應該能完全放在記憶體中,這些狀態是共享且可變的。如果用多程序模式,那麼程序之間的狀態同步會成大問題。而如果大量使用共享記憶體,則等於是掩耳盜鈴,是披著多程序外衣的多執行緒程式。因為一個程序一旦在臨界區內阻塞或crash,其他程序會全部死鎖。

master的主要效能指標不是吞吐量,而是延遲,即儘快地響應各種事件。它幾乎不會出現把IO或CPU跑滿的情況。

master監控的事件有優先順序區別,一個程式正常執行結束和異常崩潰的處理優先順序不同,計算節點的磁碟滿了和機箱溫度過高這兩種報警條件的優先順序也不同。如果用單執行緒,則可能會出現優先順序反轉;

假設master和每個slave之間用一個TCP連線,那麼master採用2個或4個IO執行緒來處理8個TCP連線能有效地降低延遲;

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用多執行緒方式編寫是自然且高效的。

4:多執行緒模式中,執行緒的分類

據我的經驗,一個多執行緒服務程式中的執行緒大致可分為3類:

aIO執行緒,這類執行緒的主迴圈是IO muItiplexing,阻塞地等在select/poll/epoll_wait系統呼叫上。這類執行緒也處理定時事件。當然它的功能不止IO,有些簡單計算也可以放人其中,比如訊息的編碼或解碼;

b、計算執行緒,這類執行緒的主迴圈是blocking queue,阻塞地等在條件變數上。這類執行緒一般位於thread  pool中。這種執行緒通常不涉及IO,一般要避免任何阻塞操作;

c、第三方庫所用的執行緒,比如logging,又比如database  connection

伺服器程式一般不會頻繁地啟動和終止執行緒。甚至,在我寫過的程式裡,create thread只在程式啟動的時候呼叫,在服務執行期間是不呼叫的。

在多核時代,要想充分發揮CPU效能,多執行緒程式設計是不可避免的。

六:附註

1:Linux能同時啟動多少個執行緒

對於32位 Linux,一個程序的地址空間是4G,其中使用者態能訪問3G左右,而一個執行緒的預設棧大小是10MB(使用ulimit -a”ulimit -s”命令檢視,http://unix.stackexchange.com/questions/127602/default-stack-size-for-pthreads。因此一個程序大約最多能同時啟動300個執行緒。如果不改執行緒的呼叫棧大小的話,300左右是上限,因為程式的其他部分(資料段、程式碼段、堆、動態庫等等)同樣要佔用記憶體地址空間。

對於64位系統,執行緒數目可大大增加,具體數字我沒有測試過,因為我在實際專案中一臺機器上最多隻用到過幾十個使用者執行緒,其中大部分還是空閒的。

2:多執行緒能提高併發度嗎?

由問題1可知,假如單純採用thread per connection的模型,那麼併發連線數最多300,這遠遠低於基於事件(Reactor模式)的單執行緒程式所能輕鬆達到的併發連線數(幾千乃至上萬,甚至幾萬)。

採用前文推薦的one loop per thread模式,至少不遜於單執行緒程式。實際上單個event loop處理1萬個併發長連線並不罕見,一個multi-loop的多執行緒程式應該能輕鬆支援5萬併發連結。

因此,thread per connection不適合高併發場合,其擴充套件性不佳。one loop per thread的併發度足夠大,且與CPU數目成正比。

3:多執行緒能提高吞吐量嗎?

假設有一個耗時的計算服務,用單執行緒算需要0.8s。在一臺8核的機器上,啟動8個執行緒一起對外服務(如果記憶體夠用,啟動8個程序也一樣)。這樣完成單個計算仍然要0.8s(每個執行緒處理一個計算),但是由於這些執行緒的計算可以同時進行,理想情況下吞吐量可以從單執行緒的1.25qps(query per second)上升到10qps(0.8s完成了8個計算。但是實際情況可能要打個八折)。

假如改用並行演算法,用8個核一起算,理論上如果完全並行,加速比高達8,那麼計算時間是0.1s,吞吐量還是10qps。但是首次請求的響應時間卻降低了很多。實際上根據Amdahl's haw,即便演算法的並行度高達95%,8核的加速比也只有6,計算時間為0.133s,這樣會造成吞吐量下降為7.5qps。不過以此為代價,換得響應時間的提升,在有些應用場合也是值得的。

再舉一個例子,如果要在一臺8核機器上壓縮100個1GB的文字檔案,每個core的處理能力為200MB / s。那麼“每次起8個程序,每個程序壓縮1個檔案”與“依次壓縮每個檔案,每個檔案用8個執行緒並行壓縮”這兩種方式的總耗時相當,因為CPU都是滿載的。但是第2種方式能較快地拿到第一個壓縮完的檔案,也就是首次響應的延時更小。

如果用thread per request的模型,每個客戶請求用一個執行緒去處理,那麼當併發請求數大於某個臨界值T時,吞吐量反而會下降,因為執行緒多了以後上下文切換的開銷也隨之增加(分析與資料請見《A Design Framework for Highly Concurrent Systems》)。但是thread per request是最簡單的使用執行緒的方式,程式設計最容易,簡單地把多執行緒程式當成一堆序列程式,用同步的方式順序程式設計。

為了在併發請求數很高時也能保持穩定的吞吐量,我們可以用執行緒池,執行緒池的大小應該滿足“阻抗匹配原則”。

但是執行緒池也不是萬能的,如果響應一次請求需要做比較多的計算(比如計算的時間佔整個response time的1/5強),那麼用執行緒池是合理的,能簡化程式設計。如果在一次請求響應中,主要時間是在等待IO,那麼為了進一步提高吞吐量,往往要用其他程式設計模型,比如Proactor。

4:多執行緒能降低響應時間嗎?

如果設計合理,充分利用多核資源的話,多執行緒可以降低響應時間。

例1:多執行緒處理輸入以memcached服務端為例。memcached一次請求響應大概可以分為3步:讀取並解析客戶端輸人;操作hashtable;返回客戶端。

在單執行緒模式下,這3步是序列執行的。在啟用多執行緒模式時,它會啟用多個輸人執行緒(預設是4個),並在建立連線時按round-robin法把新連線分派給其中一個輸人執行緒,這正好是我說的one loop per thread模型。這樣一來,第1步的操作就能多執行緒並行,在多核機器上提高多使用者的響應速度。第2步用了全域性鎖,還是單執行緒的,這可算是一個值得繼續改進的地方。

比如,有兩個使用者同時發出了請求,這兩個使用者的連線正好分配在兩個IO執行緒上,那麼兩個請求的第1步操作可以在兩個執行緒上並行執行,然後彙總到第2步序列執行,這樣總的響應時間比完全序列執行要短一些(在“讀取並解析”所佔的比重較大的時候,效果更為明顯)。

例2:假設我們要做一個求解數獨的服務,這個服務程式在9981埠接受請求,輸人為一行81個數字(待填數字用0表示),輸出為填好之後的81個數字,如果無解,輸出“NO\r\n"。

由於輸人格式很簡單,用單個執行緒做IO就行了。先假設每次求解的計算用時為10ms,用前面的方法計算,單執行緒程式能達到的吞吐量上限為100qps;在8核機器上,如果用執行緒池來做計算,能達到的吞吐量上限為800qps。

下面我們看看多執行緒如何降低響應時間。

假設1個使用者在極短的時間內發出了10個請求,如果用單執行緒“來一個處理一個”的模型,這些請求會排在佇列裡依次處理(這個佇列是作業系統的TCP緩衝區,不是程式裡自己的任務佇列)。在不考慮網路延遲的情況下,第1個請求的響應時間是10ms;第2個請求要等第1個算完了才能獲得CPU資源,它等了10ms,算了10ms,響應時間是20ms;依此類推,第10個請求的響應時間為100ms。這10個請求的平均響應時間為55ms(10個請求,總的響應時間為10+20+30+…+100=550ms,因此平均響應時間為55ms,這是從使用者角度來看的)。

改用多執行緒:1個IO執行緒,8個計算執行緒(執行緒池)。二者之間用BlockingQueue溝通。同樣是10個併發請求,第1個請求被分配到計算執行緒1,第2個請求被分配到計算執行緒2,依此類推,直到第8個請求被第8個計算執行緒承擔。第9和第10號請求會等在BlockingQueue裡,直到有計算執行緒回到空閒狀態其才能被處理。

這裡的分配實際上由作業系統來做,作業系統會從處於waiting狀態的執行緒裡挑一個,不一定是round-robin的。這樣一來,前8個請求的響應時間差不多都是10ms,後2個請求屬於第二批,其響應時間大約會是20ms,總的平均響應時間是12ms(10個請求,總的響應時間為10+10+10+…+10+20+20=120ms,因此平均響應時間為12ms)。可以看出這比單執行緒快了不少。

由於每道數獨題目的難度不一,對於簡單的題目,可能1ms就能算出來,複雜的題目最多用10rns。那麼執行緒池方案的優勢就更明顯,它能有效地降低“簡單任務被複雜任務壓住”的出現概率。

5:多執行緒程式如何讓IO和“計算”相互重疊,降低latency ?

基本思路是,IO操作(通常是寫操作)通過BlockingQueue交給別的執行緒去做,自己不必等待。

比如在多執行緒伺服器程式中,日誌模組(logging)至關重要。本例僅考慮寫log file的情況,不考慮log server。

在一次請求響應中,可能要寫多條日誌訊息,而如果用同步的方式寫檔案(fprintf或fwrite),多半會降低效能,因為:

檔案操作一般比較慢,服務執行緒會等在IO上,讓CPU閒置,增加響應時間;

就算有buffer,還是不靈。多個執行緒一起寫,為了不至於把buffer寫錯亂,往往要加鎖。這會讓服務執行緒互相等待,降低併發度。

解決辦法是單獨用一個logging執行緒,負責寫磁碟檔案,通過一個或多個BlockingQueue對外提供介面。別的執行緒要寫日誌的時候,先把訊息準備好,然後往queue裡一塞就行,基本不用等待。這樣服務執行緒的計算就和logging執行緒的磁碟IO相互重疊,降低了服務執行緒的響應時間。

儘管logging很重要,但它不是程式的主要邏輯,因此對程式的結構影響越小越好,最好能簡單到如同一條printf語句,且不用擔心其他效能開銷。而一個好的多執行緒非同步logging庫能幫我們做到這一點。