JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

執行緒池與任務模型

你已經知道怎麼生出一個執行緒。本篇要做更難的一躍:別再用執行緒思考,改用「任務」思考。執行緒池就是那座橋——一小組固定的工人,啃食一個工作佇列——而任務模型,是這一整級其餘部分所建立其上的全新看法。

為什麼不乾脆每個工作生一條執行緒?

在同步那一級結束時,你已經能建立一條作業系統執行緒、交給它一個函式、讓它與其他執行緒並肩執行。所以一個伺服器最顯然的設計是:一個請求到來,就生一條執行緒去處理它,處理完就讓那條執行緒結束。這行得通、寫起來容易,而且對於少量請求完全沒問題。麻煩只在工作變得又小又頻繁時才開始——每秒上千個微小工作——而那恰恰是真實系統所身處的領域。

一條作業系統執行緒並非免費。每一條都想要它自己的堆疊——常見的預設是預先保留 1 MiB 或 8 MiB 的虛擬位址空間——外加一個核心控制區塊與排程器的記帳。建立與銷毀一條執行緒是一次系統呼叫,不是一次便宜的函式呼叫,而執行緒的成本並不止於出生:可執行的執行緒越多,排程器就在脈絡切換上燒掉越多時間,每一次都要存回與還原暫存器、並汙染 CPU 快取。為一萬個微小工作生出一萬條執行緒,你花在管理執行緒上的時間可能比做實際工作還多。

還有第二個更微妙的問題:每個工作生一條執行緒,讓你對「同時跑多少條」毫無掌控。如果兩萬個請求一起降臨,你就得到兩萬條執行緒同時爭搶區區幾個 CPU 核心與好幾 GiB 的堆疊,機器慢到爬行——有時甚至整個垮掉。我們想要相反的東西:一個固定數量的工人團隊,依硬體規模而定,能吸收任意數量的湧入工作而不增生。這正是執行緒池的全部構想。

池子:固定的工班與一個工作佇列

一個執行緒池翻轉了執行緒與工作之間的關係。不再是一個工作一條執行緒,而是在啟動時一次性建立一小組長壽的工人執行緒(worker thread),從此不再銷毀它們。每個工人都跑同一個樸實的無窮迴圈:從一個共享佇列拿一個工作、把它跑到完成、回來再拿下一個。工作成千上萬地來來去去;工人始終都在。要記住的畫面是一間廚房:你不會為每張單子雇一個新廚師,你維持少數幾個廚師,把單子夾在一條他們會去拉取的軌道上。

那個共享佇列是整個設計的核心,而你早就見過它的模式:它是一個披著外衣的生產者—消費者問題。提交工作的執行緒是生產者;工人是消費者。這個佇列——常稱為任務佇列——必須是執行緒安全的,因為許多生產者推入、許多工人並行取出——所以在底層,它正是你上一級造的那套互斥鎖條件變數機制。一個發現佇列為空的工人不會空轉燒 CPU;它會在條件變數上等待,沉睡直到某個生產者發訊號說有新任務到了。這正是為什麼池子閒置時能近乎零成本地待著,而工作一降臨就能瞬間醒來。

submit(task) -->  [ task | task | task | task ]  -->  worker 1  --> run
  (producers)        ^                      ^           worker 2  --> run
                     |    one shared        |           worker 3  --> run
                     |    task queue        |           worker 4  --> run
                  push back              pop front       (fixed crew)

worker loop:   while (running) {
                   task = queue.pop();   // sleeps on cond-var if empty
                   task.run();           // run to completion, then loop
               }
一個執行緒池:許多生產者把任務推上一個共享佇列;一組固定的工人取出並執行它們。工人的數量受硬體限制,而非受工作量限制。

要幾個工人?對於 CPU 密集的工作,通常的起點是「每個硬體核心一個工人」(如果 CPU 提供雙路 SMT,就每個硬體執行緒一個)。理由很機械:一個核心一次只能真正執行一件事,所以可執行的工人比核心多,只是多了脈絡切換、卻沒有額外吞吐量。如果工作是 I/O 密集的——工人大多在磁碟或網路——那你可以划算地多跑一些,因為一個正在等待的工人並沒有在用它的核心。設定池子大小是一個真實的調校決策,把它調對,是這一級所要培養的實務技能之一。

真正的轉變:用任務思考,而非執行緒

執行緒池是有用的機件,但它更深的禮物是觀念上的。一旦你把工作提交給一個池子,你就不再問「哪條執行緒跑這個?」,而只問「工作的單位是什麼,它依賴什麼?」那個單位就是一個任務(task):一塊自足的工作,你可以把它交出去,讓它在某處某時、由你不指名也不在乎的某個工人來執行。把工作(做什麼)與工人(在哪何時)解耦,是這一整級裡最重要的一個心智動作。

這就是資料平行(data parallelism)任務平行(task parallelism)的差別,是同一個目標的兩個角度。在資料平行裡,你有一個操作與一大堆資料,於是你切分資料——「把這個濾鏡套到全部一百萬個像素上」變成一千個各含一千像素的任務。在任務平行裡,操作本身不同——剖析這個檔案、壓縮那個緩衝區、雜湊這個區塊——各自成為一個任務。一個執行緒池兩者都樂於執行,因為對池子而言,一個任務只是一個不透明的「執行我」;它不知道也不在乎這些任務是做同一件事的兄弟,還是做不同事的陌生人。

當任務依賴任務時:分叉—匯合

真實的工作很少是一袋扁平的獨立工作。常常是一個任務需要生出好幾個更小的任務、然後在它能完成之前等待它們的結果——想想把一個巨大陣列對半切、平行地分別求和、再把兩個部分和相加。這個形狀就是分叉—匯合(fork-join):一個任務分叉(fork)出子任務,子任務在池子上執行,而父任務藉由等待直到所有子任務完成來匯合(join),然後合併它們的輸出。它是平行演算法最常見的結構,而分治法的遞迴直接落入其中。

分叉—匯合聽起來很整潔,但它藏著一個尖銳的陷阱,恰恰揭示了為什麼一個樸素的執行緒池不是故事的終點。假設每個工人都忙著跑一個父任務,而每個父任務都分叉出子任務、然後阻塞等待它們。子任務坐在佇列裡——但已經沒有空閒的工人能來拿它們,因為每個工人都正阻塞著等待的,正是那些子任務。池子被完全佔滿、卻零進展:一個自找的死結。教訓是:一旦任務能等待其他任務,一個天真的「跑到完成、再拿下一個」的工人就不夠了。

解法是:讓一個阻塞著等待子任務的工人,去把那些子任務自己跑掉,而不是閒閒地睡——在等待時鑽進佇列裡,而不是把它的核心當人質扣著。把這個想法一般化,讓一個閒置的工人能伸手去抓堆積在某個忙碌工人身上的任務,正就是下一篇的工作竊取式排程器。眼下,先插上這面旗:一個基本的池子對獨立任務妙不可言,但相依的任務要求一個更聰明的排程器,而分叉—匯合正是那個逼出這個問題的工作負載。

從池子到執行期:前方的路

退後一步,注意我們實際上造了什麼:一個住在使用者空間的小小排程器,疊在核心自己的排程器之上。作業系統把執行緒排到核心上;我們的池子把任務排到執行緒上。這就是 M 對 N 執行緒模型 的種子——M 個輕量任務多工到 N 條作業系統執行緒上——而它是幾乎每一個現代並行執行期的底層架構,從 Go 的 goroutine 到 Rust 與 Java 的非同步執行器。一個擁有工人、佇列、排程策略與生命週期的通用執行緒池,正就是人們所說的 執行器(executor) 或執行期(runtime)。

現在有兩個問題壓上來,而每一個都開啟一篇後面的指南。第一:當你提交一個任務時,鑑於工人是稍後在某條別的執行緒上跑它的,你要怎麼把它的結果拿回來?你沒辦法就這樣跨越那道鴻溝回傳一個值。答案是一個小盒子,替一個尚未算出的值站位——一個 future,與填滿它的 promise 配成一對——這是第 3 篇要完整建造的。第二:如果一個工人必須把一個任務跑到完成才能拿下一個,那麼當一個任務想在中途暫停——比如要等一次網路讀取——卻不想拖住它整個工人時,會發生什麼?

所以本篇是接下來四篇的奠基石。你現在握有那些承重的觀念:限制並重用執行緒的池子、餵養它的佇列,以及最重要的——讓你能推理工作而非工人的任務模型。把這些帶著走。工作竊取(第 2 篇)讓排程器更聰明;future 與 async/await(第 3、4 篇)讓任務能等待並回傳結果而不阻塞;結構化並行、actor 與通道(第 5 篇)給你有紀律的方式去生成、取消、並讓任務彼此交談。它們每一個,都是你在這裡所理解的那台簡單而堅實的機器的一次精煉。