為每張點單雇一位廚師的代價
在前面幾篇導覽裡,你已經學會什麼是執行緒——它是行程內部的一條控制流,與其他執行緒共用程式碼與堆積,卻保有自己的堆疊與暫存器——你也看過一對一模型如何讓每一條使用者執行緒背後對應一條真正的核心執行緒。這個模型很強大,卻藏著一張帳單。建立執行緒並非免費:核心必須配置一塊堆疊、設好管理資訊,並向排程器登記這條新執行緒。把它拆掉時又得再付一次。對一條活上好幾個小時的執行緒來說,這筆一次性成本小到可以忽略。但對一條只為了處理一個短促的網路請求、然後就死去的執行緒而言,這些前置設定的花費,可能比真正的工作還要多。
想像一家忙碌的餐廳:每來一張點單,就立刻新雇一位廚師,等菜一擺盤就馬上把人辭退。面試、發制服、走進廚房——這一切,每出一道菜就重來一遍。等到晚餐尖峰,這家餐廳花在雇人與辭人上的力氣,竟比花在做菜上的還多。一台「每進來一個連線就開一條新執行緒」的伺服器,正好有著一模一樣的毛病;而在請求如潮水般湧入時,它可能整個垮掉:上千條短命的執行緒堆積如山,每一條都要一塊堆疊與一個排程名額,直到整台機器淹沒在額外開銷裡,而不是淹沒在真正的工作裡。
執行緒池:一支不下班的常備班底
解法跟一家聰明的餐廳所用的如出一轍:聘一支固定的廚師班底常駐。他們在早上一次雇齊,然後整天都在。點單一進來,閒著的廚師就接手;菜做好了,那位廚師不會下班回家——他只是等下一張點單。這支常備班底,就是執行緒池。程式一開始就先建立固定數量的執行緒,之後,這同一批執行緒會被一遍又一遍地重複使用,去處理源源不絕的一連串小任務。昂貴的「雇人」只在啟動時發生一次;在那之後,把一件任務交給一條等待中的執行緒,便宜得很。核心也不必在尖峰當中再去做一條新執行緒。
任務是怎麼送到班底手上的?透過一條共用佇列。進來的工作會被放進一條工作佇列,池子裡的執行緒則一次一件地把工作取走。這正是你在下一個階梯會正面交鋒的生產者—消費者問題:負責接收請求的那部分程式是生產者,把工作丟進佇列;工作執行緒則是消費者,把工作取出。這條佇列是一塊共用的可變狀態,因此必須加以保護——但那份危險,正是本階梯最後一篇導覽的整個主題,先把這個念頭按下。
requests ---> [ work queue: J1 J2 J3 J4 ... ]
| | |
(consumers pull jobs off the queue)
v v v
+------+------+------+------+
thread pool -> | T1 | T2 | T3 | T4 | (fixed crew, reused)
+------+------+------+------+
each thread: loop { take a job; run it; go idle }當執行緒太重:綠色執行緒與協程
執行緒池會重複使用執行緒,但池中每一條都仍是一條完整的核心執行緒,配著一塊完整大小的堆疊(往往是一 MB 甚至更多),以及核心排程器裡的一個名額。如果你想要的不是八條或八十條,而是十萬件並行任務——十萬條開著的網路連線,每一條多半只是在等待——那麼,連執行緒池都嫌太重。答案,是讓「工作的單位」本身變輕。這便是輕量級執行緒登場之處:它們是核心從未見過的控制流,完全在程式內部被排程。
綠色執行緒正是這樣的東西:它們是由執行期環境或函式庫在使用者空間中建立並排程的執行緒,完全不勞核心插手。它們就是上一篇導覽裡的使用者層級執行緒,被認真看待、並當成預設的並行單位。因為在兩條綠色執行緒之間切換,不過是你自己程式內部的一次函式呼叫——存下幾個暫存器、跳過去——它的花費只是核心上下文切換的極小一部分,後者還必須陷入核心。綠色執行緒的堆疊也能一開始就很小、只在需要時才長大,所以一百萬條綠色執行緒擠得進去的地方,一千條核心執行緒卻塞不下。這就是為什麼,現代那些追求高並行的伺服器執行期環境,都倚重綠色執行緒。
協程是所有輕量級積木中最為直白的一種。一個普通函式從頭跑到尾,只回傳一次。協程卻能在中途把自己暫停——把控制權讓回給呼叫它的人——日後再從那個確切的位置恢復,且所有區域變數原封不動。它是一個懂得替自己夾書籤的函式。這種協作式的「暫停—恢復」,正是綠色執行緒底下、以及你或許見過的 async/await 風格底下的引擎:每件任務一路跑到它「將要等待」的那一刻,便讓出控制權,好讓另一件任務開跑——這一切都在單一一條執行緒上完成。
協作式 vs 搶佔式:由誰決定何時讓位?
輕量級執行緒有一個誠實該講的代價,值得明白點破。核心執行緒是被「搶佔式」排程的:一個計時器中斷可以在任何時刻把任何一條執行緒從 CPU 上拽下來,所以一條貪心的執行緒沒辦法把其他人凍住。純粹的綠色執行緒與協程,則通常是被「協作式」排程的:一件任務會一直占著 CPU,直到它自願讓位。要是某個協程陷在一個緊密的迴圈裡、從不讓位——或者發出一個不與執行期環境配合的阻塞呼叫——那麼,同一條執行緒上的其他每一件任務,都會卡在它後面動彈不得。輕,是真的輕;但這份責任,也是真的。
好的執行期環境會緩和這個問題。它們把許多綠色執行緒排在一個小小的核心執行緒池上——這正是上一篇導覽裡的多對多模型——這麼一來,萬一某條綠色執行緒把它底下的核心執行緒卡住了,其餘的綠色執行緒就能被搬到另一條核心執行緒上、繼續跑。有些執行期環境甚至會自動插入讓位點,來假冒搶佔。要記住的重點不是「輕量級執行緒比較差」,而是它們把工作從核心轉移到了執行期環境裡,而這個執行期環境,必須被審慎地寫好。
挑選你的工具,以及每種選擇共有的東西
於是,你手上有一道由輕到重的小階梯,可供選擇。一條赤裸裸的 pthread(或任何一對一的核心執行緒)給你真正的搶佔式排程與真正的平行,但每一條都很昂貴,所以你只會想要為數不多的幾條。執行緒池重複使用一支固定的核心執行緒班底,去吸收一連串短任務,而不必一次又一次地付出建立成本。綠色執行緒與協程,則把數量龐大、便宜、協作式的任務,塞進寥寥幾條核心執行緒上——當大多數任務的時間是花在等待、而非運算時,這最為理想。
- 大多在等待(大量網路或磁碟 I/O、上千條多半閒著的連線)?那就採用綠色執行緒或協程——巨大的並行量、極小的成本。
- 源源不絕、各自獨立的短 CPU 工作?用一個大小接近你核心數的執行緒池,這樣你能得到平行,又免去逐件工作的建立開銷。
- 少數幾個長壽、需要重度平行的運算?單純的核心執行緒(pthread)就很好——攤在好幾個小時的工作上,那筆一次性成本微不足道。
但有一條線索貫穿這三者——也正是通往最後一篇導覽的橋樑。無論是池中的工作執行緒、綠色執行緒,還是 pthread,這些控制流中的每一條,都共用著所屬行程的同一塊堆積、同一份全域資料。那條工作佇列、一個共用的計數器、一份快取下來的結果——只要其中兩條在同一時刻碰觸到同一塊可變狀態,你就踏上了危險的地界。這份危害有個名字,叫做競爭條件(race condition);而學會用鎖以及其他工具去馴服共用的可變狀態,正是本階梯結束、作業系統下一個重大篇章開始的地方。