一個廚師、好幾口鍋:要記住的那幅畫面
你抵達本階段時,已經知道一支程式是怎麼跑的:一條指令串流,照順序被取出、執行,沿著 main() 一路前進。到目前為止的一切,講的都是那條獨自前進的線。現在我們把世界放寬——程式同時在做不只一件事——而第一件要弄清楚的,就是「同時」到底是什麼意思。因為它可以指兩件完全不同的事,而對應這兩件事的詞——並行(concurrency)與平行(parallelism)——又老是被不小心互換。現在把它們理清楚,能讓你免於一團困惑多年的迷霧。
想像一位廚師獨自待在廚房裡,爐子上擺了三口鍋。她不會呆站在某一口鍋前面,凍結著等它滾。她先起鍋煮飯,趁飯燜著的時候切菜,然後攪一攪湯,再回頭看飯。在任何一個瞬間,她的兩隻手其實只在做一件事——她就只有一個人——可是整個晚上下來,三道菜卻是一起在前進的。這就是並行:一種組織工作的方式,讓好幾項任務同時在進行中、彼此交錯,即便在任何一個當下,真正被碰到的只有一項。
現在在她旁邊再加一位廚師,使用第二座爐子。兩把刀同時落在兩塊砧板上的那一刻,兩項任務是在同一個物理時刻往前推進——不是交錯,而是真正的同時。這就是平行:真的在同一時間做兩件事,這需要兩個執行者。要記住這個分別,最乾淨的一句話是:並行談的是同時應付許多事;平行談的是同時做許多事。前者是一種組織方式,後者則是底下硬體的一個事實。
歸根究柢在於核心數
把廚房拿掉,剩下的就是 CPU。一個處理器核心就是一位廚師——一塊取出並執行指令的硬體,一次一條。一台只有單核心的機器,在任何一個真正的瞬間,永遠只能跑一條指令串流。那麼,多年前你那台單核心筆電,是怎麼「同時」跑著瀏覽器、音樂播放器和一個下載的?它並沒有,並非字面上的同時。作業系統讓每一個各跑一小片時間——幾毫秒——然後把它凍結、存下它的狀態,再讓下一個跑。換得夠快,那種同時性的錯覺對人類來說就天衣無縫。
那種凍結與換手就是脈絡切換(context switch),而它造成的這種快速輪流,正是單核心上的並行:任務在牆上時鐘的時間裡重疊,卻從不在同一瞬間重疊。你在作業系統那一階已經見過它的近親了——這就是排程器在決定接下來換誰跑。所以並行並不需要額外的硬體。一個核心快速輪流,就給了你並行。它給不了你的,是真正的平行,因為廚師依然只有一個。
真正的平行需要更多核心——更多廚師。一塊現代晶片有許多核心,常見 8 或 16 個,每一個都是一整套執行單元,真正同時地跑著自己的指令串流。有四個核心,四項任務就能在同一個奈秒裡一起前進,不必輪流。而且這兩個觀念會疊起來:一台 8 核心的筆電跑著 100 項任務,在那 8 個核心之間是平行的,而那 100 項任務如何被分派、被時間切片到核心上,則是並行的。平行是硬體把你的手變多;並行則是無論你有幾隻手,都能讓許多任務維持在進行中的那套結構。
為什麼要費這個勁?兩個非常不同的理由
人們之所以動用並行與平行,理由值得分開來看,因為它們朝不同方向拉扯。第一個理由是吞吐量(throughput):你有一堆彼此獨立的工作,你希望它更早做完。把一萬張圖片改尺寸、把一個巨大的陣列加總、算出一幀畫面——把它分散到各核心上,有 N 個核心,你或許能逼近 N 倍的加速。這就是平行帶來的回報,也是為什麼「就多開幾條執行緒嘛」這麼誘人。但它有上限:只有工作中真正獨立的那部分會加速,而你沒有的那些核心幫不上忙。
第二個理由跟跑得更快毫無關係:它是回應性與好好等待。一支程式往往一生大半都被擋住(blocked)——在等一次檔案描述符的讀取、一個網路回覆、一次按鍵。一支單執行緒程式呼叫了會阻塞的 read(),就單純地凍住,直到位元組抵達為止;整個使用者介面都僵死了。有了並行,當一項任務停在那兒等硬碟時,另一項可以跑。這是純粹的並行——即使只有一個核心也有用,因為勝利不在於每秒做更多工作,而在於明明有有用的工作可做,卻絕不閒站著。一個下載檔案時依然順滑的聊天程式,享受的是這個,不是平行。
一支程式如何分裂成好幾條串流
所以一支程式同時做不只一件事——但那第二件「事」,具體來說是什麼?到目前為止,你的程式恰好只有一條指令串流。要做到並行,它需要不只一條,而取得它們的方式有兩種,正是緊接著下一篇的主題。一種是跑好幾個各自獨立的行程(process),每一個都是自己的程式、有自己的私有記憶體,就像 shell 並排跑著許多程式那樣。另一種、較輕量的方式,是在同一個行程裡生出好幾條執行緒(thread)。
一條執行緒是一條獨立的執行串流:它有自己的指令指標在程式碼裡前進,而且關鍵在於有自己的堆疊,好讓它能呼叫函式、持有區域變數,而不去踩到別人。一支普通的程式,不過就是一個只有一條執行緒的行程。再造一條執行緒,現在就有兩條串流跑在同一個行程裡——而在多核心機器上,作業系統可以把它們擺到不同核心上,讓它們真正平行地跑。執行緒與行程的差別是整個下一篇的主題;現在只要先記住:一條執行緒就是又一條前進中的指令線,而你可以有好幾條。
這裡有個岔路口,界定了本階段其餘的內容。各自獨立的行程,每一個都有自己的私有記憶體,所以它們不會不小心去踩到對方的資料——但要彼此交談得刻意費一番工夫。同一個行程裡的執行緒則恰恰相反:它們很便宜,而且它們共享行程的記憶體——同一個堆積、同一批全域變數,全部都共享。那種共享方便得令人陶醉,而它也正是本階段每一個棘手問題的出生地。兩條串流能碰到同一批位元組的那一刻,共享可變狀態(shared mutable state)就出現了。
麻煩住在哪裡:共享可變狀態
讓兩條執行緒共享同一個變數,又讓兩者都去寫它,你就踩上了整個本階段都在講的那根耙子。拿一行你能想到最人畜無害的程式碼——一個共享計數器,兩條執行緒都跑 `count++`。它看起來是不可分割的,單獨一次原子性的加一。它不是。對 CPU 來說,`count++` 是三個步驟:把目前的值從記憶體載入暫存器、把暫存器加一、再把暫存器存回記憶體。三條指令,而作業系統有權在它們之中任兩條之間暫停一條執行緒。
count starts at 5. Two threads each want to do count++.
If their three steps DO NOT overlap, you get 7 (correct):
Thread A: load 5 -> reg=5
Thread A: add 1 -> reg=6
Thread A: store -> count=6
Thread B: load 6 -> reg=6
Thread B: add 1 -> reg=7
Thread B: store -> count=7 result: 7 (good)
But the scheduler may INTERLEAVE the steps like this:
Thread A: load 5 -> reg=5
Thread B: load 5 -> reg=5 (B read the SAME old 5)
Thread A: add 1 -> reg=6
Thread A: store -> count=6
Thread B: add 1 -> reg=6
Thread B: store -> count=6 result: 6 (one ++ vanished!)再讀一次那條壞路徑:兩條執行緒都載入 5、都加一得到 6、都存回 6——發生了兩次遞增,計數器卻只動了一格。一次更新被無聲地吞掉了。這就是一個競爭條件(race condition):答案取決於兩條串流確切而難以預測的交錯,於是程式會有一定比例的時候,悄悄給出錯誤的結果。這種特指對共享記憶體未經同步的讀寫互相重疊的情況,更深一層的名字叫資料競爭(data race)。我們在這裡只勾勒了個輪廓;本階段第四篇講競爭條件,會把 `count++` 完整拆開,第五篇則給出解藥。
而這正是整個本階段誠實而令人不安的核心:並行讓你的程式變得不確定(nondeterministic)。把那個有競爭的計數器跑一千次,它可能有 999 次印出正確的總和、只有一次錯——或者在你的筆電上完美無瑕,只在伺服器上、只在負載之下、只在凌晨三點失敗。這個臭蟲不住在任何一行你能指著的程式碼裡;它住在行與行之間的時序裡,而那時序每跑一次都不一樣。這就是為什麼並行是不確定的,也是為什麼這類臭蟲是大多數程式設計師遇過最難的。好消息是:解法是學得會的,而接下來四篇會一個謹慎的步驟、一個步驟地把它建起來。
你帶進本階段其餘部分的東西
現在把整個本階段的形狀握在手裡,因為後面每一篇都掛在這一篇上。並行是結構——讓任務在時間上重疊,即便只有一個核心也有用,多半是為了好好等待;平行是硬體——真正的同時,需要許多核心,多半是為了跑得快。你取得多條指令串流的方式,要嘛是各自獨立的行程,要嘛是便宜、共享記憶體的執行緒——下一篇執行緒與行程會畫出那條界線。執行緒共享記憶體,而在搶佔式排程之下的共享可變狀態,正是競爭誕生之處,因為作業系統能在一條指令序列的中途暫停任何一條執行緒。
如果只有一句話能留下,就讓它是這句:並行談的是同時應付許多事;平行談的是同時做許多事。本階段其餘的一切,都是在仔細推敲:當那些重疊的串流伸手去拿同一批位元組時會發生什麼——以及我們如何用即將登場的互斥鎖、原子操作與紀律,去馴服那份不確定,而不是被它伏擊。往前進到執行緒與行程,在那裡,第二條串流終於有了自己的名字和一座屬於自己的堆疊。