兩種溝通的哲學
到目前為止,本階梯的一切都建立在一個假設上:兩條以上的執行緒看到同一塊記憶體,而整齣戲都在防止它們把那塊記憶體踩爛。號誌、互斥鎖、第 3 篇導覽的比較並交換迴圈,都是那同一座共享遊樂場的裁判。這個模式甚至有個名字:以共享來溝通。你把一個值留在共享記憶體裡,我了解你做了什麼的方式,就是去讀那同一塊記憶體。鎖則是讓我們不會讀到「寫到一半」的禮節。
訊息傳遞是相反的哲學,凝成一句值得背下來的口號:別以共享記憶體來溝通;要以溝通來共享記憶體。每條執行緒或每個行程都把自己的資料嚴格地留給自己。當其中兩個需要協調時,它們完全不去碰對方的變數——一個把它想傳達的東西打包成一份副本、當成訊息送出;另一個收下那份副本。沒有人會指著同一個位元組,於是根本就沒有共享的遊樂場可以爭奪了。
注意這買到了什麼。前幾篇導覽裡大部分的折磨——兩筆更新交錯的競爭條件、遺失的更新、忘了上的鎖——都來自兩股控制流同時改動同一格。如果它們從不共享任何一格,那一整類臭蟲就無從發生。你並沒有讓並行變簡單;你是把困難的部分搬到了一個遠更容易看見的地方——搬進了「發送」與「接收」這兩個明確的動作裡。
發送、接收,與僅有的兩個原語
一套訊息傳遞系統剛好需要兩個操作:send(目的地, 訊息) 把一則訊息交出去,receive(來源, 訊息) 等一則訊息到來。整個介面就這麼多。在底下,這只是一種有紀律的行程間通訊——核心為管線、訊息佇列、通訊端所提供的同一套機制——被裝扮成一對乾淨的 send/receive。有趣的設計問題是一個小細節:如果接收者還沒準備好,send 該怎麼辦?
這有兩個誠實的答案,而用它們來寫程式的感覺截然不同。在同步(也叫阻塞,或會合)訊息傳遞裡,send 會一直等到接收者真的把訊息拿走;兩股控制流在同一瞬間會合、交出訊息,然後才各自走開。在非同步訊息傳遞裡,send 把訊息丟進一個緩衝區就立刻返回,於是發送者一路衝在前面,接收者稍後再追上。同步給你一個內建的確認——當 send 返回時,你就知道它已被收到——代價是讓發送者等待。非同步讓發送者保持自由,但需要一個地方暫存在途中的訊息,而那個緩衝區會被塞滿。
通道,與重生的生產者—消費者問題
現代語言給 send/receive 一張親切的臉,叫做通道——一條有型別的管道,你從一端寫入、從另一端讀出,像執行緒之間一條單向的管線。Go 把它整套並行的故事都圍著通道與輕量的 goroutine 來建;Erlang 把一切都經由行程的信箱來繞送;Rust 在標準函式庫裡就附了通道。通道就是那道接縫:生產者不知道誰在消費,消費者不知道誰在生產,而兩邊都不會持有你看得見的鎖。這裡是經典的生產者—消費者模式,被改寫成沒有共享變數、也沒有明寫的鎖——只有一條通道。
channel ch // a typed conduit; capacity = small bounded buffer
producer():
loop:
item = make_item()
send(ch, item) // blocks if the channel buffer is full
consumer():
loop:
item = receive(ch) // blocks if the channel is empty
use(item)
-- no shared variable, no lock(), no wait(S)/signal(S) anywhere --
-- the channel itself enforces: empty -> consumer waits
-- full -> producer waits再讀一次,感受改變了什麼。在號誌版本裡,正確性取決於每條執行緒都記得以正確順序呼叫 wait 與 signal;漏掉一個,整件事就會悄悄壞掉。在這裡,同步不是一條你必須記住的紀律——它被烤進了通道裡,而那條通道在物理上不可能在生產者送出之前就把一個項目交給消費者。第 1 篇導覽那個來之不易的教訓——「號誌只有在每個人都正確使用它時才保護你」——被繞開了:沒有一把獨立的鎖可以用錯,因為通道與資料是一起旅行的。
跨越機器:同一個想法,且別無選擇
在單一台電腦內,訊息傳遞是一個你可以選、也可以不選的雅致選項。在兩台電腦之間,它就不再是選項了。兩台機器根本不共享記憶體——沒有共同的位址空間讓鎖棲身,沒有共享的格子可以比較並交換。它們唯一能做的,就是透過一條線互相發送位元組。所以每一個分散式系統,從一個瀏覽器跟伺服器對話,到一群資料庫組成的叢集,骨子裡都是一個訊息傳遞系統,無論它的作者有沒有這樣想。
一個流行的包裝法是遠端程序呼叫(RPC):你呼叫一個看起來像普通函式的東西,系統則悄悄把這次呼叫變成一則請求訊息、運到另一台機器、在那邊跑真正的函式,再把結果運回來。要做到這點,它必須把你的引數封送——把你記憶體裡的物件壓平成一串能旅行、並在對岸被重建的位元組,因為一個在你位址空間裡有意義的指標,在另一個位址空間裡毫無意義。RPC 之所以舒適,正是因為它把訊息藏了起來。不過要對這個偽裝誠實以對。
誠實的取捨,與它的位置
訊息傳遞不是免費的午餐,也不會自動比共享記憶體更好——只是不一樣。它最大的成本是複製:共享記憶體遞給你一個指向「已經在位」資料的指標,而訊息卻要從發送者複製到接收者,對大的內容而言是實實在在的工。有些系統用「傳遞所有權而非複製」來緩和它(Rust 移動一個值,使得永遠只有一條執行緒能碰它),或在寫入時複製之下共享那一頁,好讓副本只在真有人寫入時才產生。但心智模型仍然乾淨:你照著「每則訊息都是一份私有副本」來推理。
而且訊息傳遞並不會神奇地驅逐每一種並行危害。兩條執行緒仍可能在通道上死結:A 阻塞著要發給 B,B 又阻塞著要發給 A,兩個都永遠等下去——就是你學過的同樣四個必要條件,只是把資源從鎖換成了通道。它的勝利不在於危險消失了;而在於剩下的危險,住在一小撮看得見、你指得出來的 send 與 receive 呼叫裡,而不是隱形地散佈在每一次對共享記憶體的存取中。
它在其他工具旁邊的位置在哪?一個透過通道傳值的協程,往往是表達一條多階段流水線最易讀的方式。當資料真的巨大、複製它會主導一切時,共享記憶體的鎖仍然勝出。而下一篇導覽會以一個令人不安的問題為這一階梯收尾,那個問題懸在以上所有做法——鎖、無鎖、訊息傳遞——之上:為什麼當每一個個別的機制單獨看起來都那麼合理時,並行的臭蟲卻惡魔般地難以捉到?