一袋鬆散任務的問題
到現在你能做的事不少了。第一篇給了你執行緒池和任務佇列;第二篇用工作竊取把池子磨利;第三篇把future 與 promise和async/await交到你手上;第四篇解釋了一個 await 究竟暫停了什麼。於是你寫下 `spawn(work)`,剩下的交給執行期。麻煩在於「剩下的」是什麼意思:一個被生出來的任務是個四處漂浮的東西,沒有父親、沒有作用域、沒有任何人有義務去等它或停它。它是 `malloc()` 卻沒有對應 `free()` 的並行雙胞胎——開始很容易,結束卻沒有人清楚地負責。
看看哪裡會出錯。一個請求處理器生出三個任務去向三個服務抓資料,收到兩個就回傳——然後忘了第三個,它繼續跑著、佔著一條連線、對著虛空寫日誌,一個小小的洩漏任務。或者其中一個抓取失敗:你想放棄另外兩個,卻沒有它們的把手,於是它們無用地磨著轉。又或者處理器自己被逾時取消了,它的孩子卻照樣航行、成了孤兒,因為沒有人把取消往下傳。這每一個都是同一個形狀的臭蟲——一個生命期逃出了創造它的邏輯的任務。非結構化的並行讓這成了預設狀態。
結構化並行:像函式呼叫一樣巢狀的任務
結構化並行是一條後果巨大的規則:一個任務不得活得比啟動它的那個語彙區塊久。你開一個作用域(Smith 造的詞叫「nursery/育嬰房」,在 Swift 裡是 `TaskGroup`、在 Kotlin 裡是 `Scope`),你把子任務生進去,而那個區塊在每個子任務都結束之前不會回傳。子任務的生命期嚴格地巢狀在父親的之內,正如一個被呼叫者的堆疊框架巢狀在它呼叫者的之內。那個巢狀就是整個想法——它把一袋扁平的任務變回一棵有清楚邊界的樹。
async with open_nursery() as n: // open a scope
n.start_soon(fetch, url_a) // spawn child 1
n.start_soon(fetch, url_b) // spawn child 2
n.start_soon(fetch, url_c) // spawn child 3
// <-- the 'with' block does NOT exit here until ALL three finish.
// If any child raises, the others are cancelled, and the
// error propagates out of the block like a normal exception.
// No child can leak past this line. Ever.三項保證從這一條規則裡掉出來,而它們正是上一節那三個臭蟲,被修好了。第一,無洩漏:作用域在還有子任務跑著時不能閉合,所以一個被遺忘的任務不可能存在——區塊只會單純地掛著等,這把錯誤浮上來而非藏起來。第二,行得通的錯誤傳播:若一個子任務失敗,作用域取消它的手足、並把錯誤重新拋給父親,於是一個失敗再也無法消失進一個脫離的任務裡。第三,作用域取消往正確的方向流動:取消父親,取消就沿著樹往下傳到每一個後代,正如例外沿著呼叫堆疊往下展開。並行終於像普通程式碼那樣可以組合了。
Actor 與通道:以溝通來共享
組織生命期是戰役的一半;另一半是組織任務之間如何交談。整個同步那一級講的都是共享可變狀態的危險——兩個執行緒碰同一個變數、隨之而來的競爭條件、互斥鎖與死結。actor 模型用一個立場上的轉變繞開了這一類臭蟲:根本別共享狀態。一個 actor 是一個任務,它擁有一塊沒有別的任務碰得到的私有狀態,外加一個信箱——一條傳入訊息的佇列。actor 永遠迴圈著:取下一則訊息、更新自己的狀態、也許送訊息給別的 actor、重複。因為狀態恰好由一個任務碰觸,沒有需要防範的資料競爭,也沒有要拿的鎖。
任務之間的導管是一個通道:一個有型別、執行緒安全的佇列,有一個傳送端和一個接收端。那句口號——Go 的設計者借自 Hoare 的 CSP——是「不要以共享記憶體來溝通;以溝通來共享記憶體」。具體地說,與其讓兩個任務鎖住一個共享計數器,不如讓一個任務擁有那個計數器、其他任務沿著通道送給它遞增訊息;值被搬動,而非被別名。在 Rust 裡這由型別系統強制——一個沿通道送出的值被移動,所以送出者可證明地之後碰不到它,這就是為什麼一條持有值的通道在構造上沒有資料競爭。一個 `select` 讓一個任務同時等候數條通道,並對最先就緒的那條動作。
不過要誠實:訊息傳遞拿一組問題換另一組——它沒有刪掉並行的困難部分,而是把它們搬了地方。你不再對抗資料競爭,但你仍能用一圈互相等候彼此訊息的 actor 砌出一個死結,而有界的信箱帶來背壓:若一個生產者送得比消費者抽得快,通道會塞滿,這時你必須選擇阻塞送出者、丟棄訊息、或讓記憶體無上界地長。這幾個沒有一個是免費的。actor 讓一大族臭蟲在結構上不可能、並讓倖存者更容易被定位到單一信箱——但「更容易推理」才是誠實的主張,不是「無臭蟲」。
平行模式:fork-join 與管線
手裡有了結構與通道,兩個形狀就承擔了大部分真正的工作。第一個是 fork-join,它是最純粹形式的資料平行:拿一個工作、把它切成獨立的子工作(fork/分叉)、平行地跑它們、並在合併結果之前等候全部(join/會合)。請注意這就是結構化並行的另一個名字——一個所有子任務都在區塊回傳前結束的育嬰房,恰恰就是一次 join。它是分治法的自然模式:在兩個任務上各排序陣列的一半、把一個向量的各個切片相加、把一個函式映射到一百萬個元素上。第二篇那個工作竊取池正是為了讓 fork-join 便宜而造的,因為被分叉出來的子工作就是那些可被竊取的任務。
第二個形狀是管線(pipeline),它是串流之上的任務平行:一串階段,每個是自己的任務,由通道串接,於是第一階段的輸出是第二階段的輸入,依此類推。想想 Unix shell 管線 `cat f | grep x | sort`——同一個想法,現在搬進一個程式裡。一串項目流過,所有階段同時在不同項目上跑,就像一條工廠生產線、每個工位都忙在不同的車上。這正是背壓賺到它身價的地方:階段之間一條有界的通道意味著一個慢階段自動節流那個餵它的快階段,於是管線自我調節,而非某個階段以無上界的排隊工作淹沒下一個。
- 工作是不是一個可切成獨立片段的集合之上的大工作?動用 fork-join——切分、平行跑、會合、合併。
- 工作是不是一連串各自需要同一序列轉換的項目流?動用一條由通道串接各階段的管線。
- 把你選的那個包進一個作用域,讓取消與錯誤得以傳播,並把每條通道設成有界,使慢階段施加背壓而非洩漏記憶體。
把這一級拼起來
退一步,把整一級看成一疊決策。底層坐著一個由工作竊取式排程器驅動的執行緒池——把就緒任務變成 CPU 時間的引擎。在它之上,future 與 async/await讓你寫出能暫停與恢復、卻不阻塞執行緒的程式碼,而協程是讓那暫停變便宜的機械裝置。本篇加上了缺失的那一組織層:結構化並行給那些任務以巢狀的生命期,而帶通道的 actor 給它們一套有紀律的溝通方式。每一層都假設它底下那一層存在;沒有一層免除了對順序與共享狀態的誠實謹慎。
並把一個誠實的告誡放在心頭最前:這些沒有一個能單憑自己讓程式更快。結構、actor 與模式讓並行可管理且正確,但並行不是平行,而一條最慢階段就是全部故事的管線替你買不到任何東西——同步那一級的 Amdahl 定律依然當家。這一級的重點不是原始速度;是讓你能表達一個你真能推理其生命期、失敗、與取消的並行程式。一個你看得懂的、正確但慢的程式,勝過一個任務以你看不見的方式洩漏的快程式。那就是這整道階梯一直朝著建立的那份紀律。