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

並行不是平行

兩個常被初學者混為一談的詞,也是關於執行緒最常見、最致命的一個誤解。學會把並行看成「結構」、把平行看成「執行」——並弄懂為什麼「一個雜耍者拋三顆球」和「三個雜耍者各拋一顆」根本不是同一回事。

一個雜耍者不等於三個雜耍者

在上一篇導覽裡,你認識了執行緒——它是行程內部的一條控制流,與兄弟執行緒共用程式碼、資料與堆積,卻保有自己的堆疊與暫存器。現在我們要處理關於執行緒最大的思緒混亂來源。人們常說某個程式「平行地跑」,其實只是日常口語裡那種鬆散的「同時做好幾件事」。這是兩個不同的概念,把它們當成同一回事,會讓後面每一個主題都變得更難。所以讓我們小心地把它們拆開。

想像一位雜耍者讓三顆球在空中飛舞。在任何一個瞬間,恰好只有一顆球在他手裡;另外兩顆都在半空飛行。雜耍者並沒有「同時」碰到三顆球——他碰一顆、拋出去、接住下一顆、再拋出去,這種飛快的切換營造出三件事一起發生的錯覺。這就是並行(concurrency):好幾件任務同時進行中、在時間軸上交錯穿插,並被組織成彼此不必互等就能各自推進。現在再想像三位雜耍者並肩站著,每人手上一顆球。就在這一瞬間,三隻手碰著三顆球——真真切切、實實在在、在同一時刻發生。這就是平行(parallelism)

為什麼單核心仍然忙得過來:時間切片

單一核心是怎麼「同時」跑音樂播放器、下載、還有你打字的呢?其實它辦不到——至少不是真的同時。核心把這顆核心交給某條執行緒一小段時間,接著做一次上下文切換換到下一條,再下一條,輪得飛快,快到在人眼裡彷彿天衣無縫。這正是第一個階梯談過的分時(time-sharing)概念,現在套用到執行緒上。切換本身是不產出任何成果的真實工作,但它換來了「許多任務一起向前推進」的表象。

把它講具體一點。假設有三條執行緒——A、B、C——都想執行,而我們只有一顆核心,以單純的輪流(round-robin)方式,每次發出比如 10 毫秒的時間片。看一下底下的時間軸。在任何時刻都不會有兩條執行緒一起執行;核心永遠只在做一件事。但因為每條執行緒每秒都輪到很多次,三者都穩定地向前推進;而且 A 裡頭一次緩慢的磁碟讀取,並不會把 B 和 C 凍住——當 A 受阻時,核心只管把它交給任何一個就緒的執行緒。這就是並行在單核心上發揮價值的方式。

ONE CORE, three threads, 10 ms slices (round-robin):

time ->  0    10   20   30   40   50   60   70  ms
core:  [ A ][ B ][ C ][ A ][ B ][ C ][ A ][ B ]...

Never two at once. The core does ONE thing at a time.
Fast switching => the ILLUSION of A, B, C together.

TWO CORES, same three threads (true parallelism):

time ->  0         10        20        30   ms
core 0: [   A    ][   A    ][   C    ][  C  ]
core 1: [   B    ][   C    ][   B    ][  A  ]

At t=5 ms, A AND B are BOTH executing. Really.
上:單核心上的並行——交錯穿插營造出「同時」的錯覺。下:雙核心上的平行——在某個瞬間,兩條執行緒真的同時執行。

平行需要真正的硬體

這裡有個誠實又重要的限制。並行是你設計進程式裡的東西;平行則是硬體賜給你的東西。如果你的筆電有四顆核心,那麼在同一瞬間最多就只有四條執行緒能真正執行——沒有任何軟體把戲能抬高這個天花板。在一台單核心機器上寫一支有一千條執行緒的程式,你會得到滿滿的並行、以及恰好為零的平行;每條執行緒仍得在那唯一一顆核心上輪流。這一千條執行緒可以是個非常好的設計(它們讓緩慢的任務去等待、而不阻擋快速的任務),但要算完一大堆純粹的數字運算,它們不會比一條執行緒快。

這正是為什麼使用執行緒的理由會分成兩個陣營。一個理由是回應性與乾淨的結構:讓使用者介面保持靈活,同時讓一件耗時的任務在背景慢慢跑。這個好處純粹來自並行——即使在單核心上也有用,因為重點是「不要卡在等待上」。另一個理由是多核心機器上的純粹速度:把一個龐大的運算切開、分到各核心上,好讓牆上時鐘的時間下降。這個好處來自平行——而它只有在硬體真能讓執行緒並肩執行時才會出現。

平行的兩種風味

當你真的要去追求平行時,它通常以兩種形態之一現身。在資料平行(data parallelism)裡,同一個運算同時跑在不同的資料區塊上——就像四位廚師各削同一座馬鈴薯山的四分之一。要把一張巨大影像的亮度加倍,把上半交給一顆核心、下半交給另一顆;兩者跑的是一樣的程式碼、只是處理不同的像素。在任務平行(task parallelism)裡,則是不同的運算同時進行——就像一位廚師在切菜、另一位在煨煮、第三位在擺盤。一台網頁伺服器可能在一顆核心上解壓縮檔案、同時在另一顆核心上加密回應;工作各不相同,卻在時間上重疊。

兩種風味都仍然騎在執行緒上,也都被你的核心數所限制。而且就算硬體很慷慨,這裡還有個讓人清醒的事實:不是每件工作都能漂亮地加速。如果一件任務的各部分彼此相依,或有一段頑固的片段非得獨自跑不可,那麼多加核心只會帶來越來越少的回報。工作中本質上必須循序進行的那部分,會為整件事能跑多快設下一個硬地板,無論你丟多少核心進去都一樣。平行很強大,但它不是魔法,「核心加倍」很少等於「速度加倍」。

共享的代價:第一聲警告

共享正是讓執行緒既便宜又強大的原因——兄弟執行緒看見同一個堆積,所以彼此傳遞資料幾乎不花成本。但同一塊「可變的共享記憶體」也正是事情變危險的地方,而這份危險在我們的兩個世界裡長相不同。在真正的平行下,兩條跑在兩顆核心上的執行緒,可以在「字面上同一瞬間」讀寫同一個變數,它們的更新可能互相踐踏。而即使只是單核心上的並行,一次上下文切換也可能正好落在某個只更新到一半的動作中間,於是下一條執行緒看到的是一個「既不是舊值、也不是新值」的數字。

想想兩條執行緒,各自在一個初值為 5 的共享計數器上跑那看似無害的一行「count = count + 1」。我們期望得到 7。但這一行底層其實是三個步驟:把 count 讀進暫存器、加一、把暫存器寫回去。現在想像一種倒楣的交錯:執行緒一讀到 5;在它寫回任何東西之前,執行緒二也讀到 5;兩者各自在自己的暫存器裡加一得到 6;兩者各自寫回 6。最終值是 6 而不是 7——其中一次加一就這麼憑空消失了。這就是競爭條件(race condition)——一種結果取決於交錯時機運氣好壞的臭蟲——也是後面幾個階梯的頭號反派。它的陷阱在於:只有在那種倒楣的交錯下臭蟲才現身,於是程式在測試時看起來好端端,到了現場卻神祕地壞掉。眼下只要把這聲警告記在心裡:當兩條執行緒在沒有協調的情況下共用可變狀態的那一刻起,正確性就不再有保障了。

把它們串起來

所以整幅圖有兩條初學者務必分清的軸線,而這個並行與平行之分值得你刻在記憶裡。並行關乎結構:把程式組織成一群彼此合作、能同時進行中的控制流,而核心透過在「現有的核心數」上交錯穿插執行緒來實現它。平行關乎執行:真的在同一瞬間執行多條執行緒,而這只有多核心能提供。一支設計良好的並行程式,在你給它更多核心時能優雅地擴展——但即使只有一顆核心,它仍然正確、仍然有用。

有兩件事不會出現在我們的圖裡:排程器更深層的機制,以及執行緒實作的各種樣貌——每條使用者執行緒該各自對應一條核心執行緒嗎?還是讓許多條共用少數幾條?這些選擇,決定了你的並行究竟能兌現多少平行。而這正是下一篇導覽要去的地方:我們將揭開使用者執行緒、核心執行緒,以及夾在它們之間的多工模型。