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

競爭條件:當 count++ 說謊時

兩個執行緒各自對一個共用計數器加一百萬次一,最後的答案卻不知怎地小於兩百萬。你的邏輯沒有錯、也沒有打錯字——只是並行程式設計裡最有名的陷阱。這篇把 count++ 一條指令一條指令地拆開,明確指出更新是在哪裡憑空消失的,並精準地替這個危害命名,好讓下一篇能修好它。

一個會掉數的計數器

上一篇結束時,你已經會用 pthread_create() 啟動一個執行緒、並用 pthread_join() 等它。那麼這裡就是兩個執行緒能跑的、最小卻有意思的程式:兩者都對一個共用全域計數器加一,各加五十萬次。第 2 篇的全部重點就是執行緒活在同一個共用位址空間裡,所以兩個執行緒看到的是同一個名為 count 的變數。一個執行緒時,答案顯然是一百萬。兩個執行緒分攤工作時,答案應該還是一百萬——我們只是在兩個跑者上做同樣的加法。可是跑跑看,你會得到像 743912 這樣的數。再跑一次:689004。每次都不一樣,而且從來不太對。

程式很小:一個共用全域 `long count = 0;`、一個工作函式(它的整個本體就是一個跑 500000 次 `count++` 的迴圈),以及一個 main()(它在那個工作函式上啟動兩個執行緒、把兩者 join、然後印出 count)。(兩個執行緒之所以共用 count,正是因為第 2 篇講的:一個全域變數活在那唯一的共用位址空間裡。)這段程式在尋常意義上沒有任何地方是錯的——沒有差一錯誤、沒有未初始化的變數、沒有壞指標。迴圈對、邊界對、加法也對。然而更新卻在漏掉。這是你初次嚐到並行的非決定性:輸出不只取決於你寫了什麼,還取決於那看不見、每次執行都不同的時序——兩個執行緒剛好如何交錯。要看清楚遺失的更新跑去哪了,我們得停止相信 `count++` 是單一一個動作。

count++ 是三步,不是一步

從組合語言那個章節,你已經知道 CPU 並沒有一條「把記憶體裡的這個變數加一、而且不准被打斷」的指令。一個記憶體位置不是一個暫存器;要對它做算術,處理器必須先把它載入一個暫存器、改動那個暫存器、再把它存回去。所以看起來人畜無害的 `count++`,其實是一段三個獨立機器步驟的讀取—修改—寫回序列,而關鍵在於,執行緒可以在這當中任何兩步之間被暫停

count++   compiles to roughly three instructions:

    load   count -> reg     ; read the current value from memory
    add    reg, 1           ; modify it in the register
    store  reg -> count     ; write the new value back to memory

A context switch may land BETWEEN any of these three.
一條 C 述句變成三個機器操作:讀取、修改、寫回。整段過程中,執行緒把進行到一半的值放在一個私有暫存器裡——在最後那次寫回之前,沒有別的執行緒看得到這個值。

那個暫存器極為要緊。每個執行緒有自己的暫存器(第 2 篇:暫存器是私有的部分),所以當執行緒 A 做到它的讀取—修改—寫回的一半時,它把那個做了一半的結果握在一個執行緒 B 看不到的暫存器裡。對 B 而言,記憶體裡的共用 count 仍是舊值——因為 A 還沒有把它的新值存回去。A 的載入與 A 的寫回之間的那扇窗,正是危險所在。現在我們可以看兩個執行緒在那扇窗裡相撞。

看著那次更新憑空消失

假設 count 此刻是 41,而執行緒 A 與執行緒 B 都想做一次加一。兩者都跑完後,正確的結果是 43。我們走過一個倒楣的交錯——排程器剛好讓兩個執行緒的指令交替的某一種特定順序——看看真正發生了什麼。

  1. 執行緒 A 把 count 載入它的暫存器:A 的暫存器現在握著 41。記憶體裡的值仍是 41。
  2. 在 A 還來不及做別的之前,一次上下文切換把 CPU 交給了執行緒 B。(A 被凍結在加一的中途,它的暫存器仍握著 41。)
  3. 執行緒 B 把 count 載入「它」的暫存器:B 的暫存器握著 41。B 加 1 得到 42。B 把 42 存回記憶體。記憶體現在是 42。
  4. 排程器切回執行緒 A,它從剛才暫停的地方原樣繼續——暫存器裡仍握著 41,完全不知道任何事變過。A 加 1 得到 42。A 把 42 存回記憶體。
  5. 最終結果:count 是 42。兩次加一都跑了,但 count 只升了一。B 的更新被 A 那個過時的寫回悄悄蓋掉了——一次加一不見了,沒有錯誤、沒有崩潰、沒有警告。

整場災難就濃縮在這一幅圖裡。兩個執行緒都讀到 41、都算出 42、都存了 42——而第二次寫回壓在第一次之上,像在一份共用文件裡隨手一存,把另一個人的編輯扔了。這就是一個讀取—修改—寫回危害:讀取與寫回沒有黏在一起,所以另一個執行緒能溜進它們中間,讓你讀到的值在你寫回之前就過時了。把這種罕見的相撞乘上一百萬次迴圈疊代,你得到的正是第一節裡那成千上萬個悄悄不見的計數。

精準地替它命名:競爭條件與資料競爭

現在替這個臭蟲命名,因為大家用的兩個名字並不完全是同義詞,而這個差別很要緊。競爭條件是那個比較一般、比較高階的毛病:程式的正確性取決於事件的時序或交錯,所以不同的排程給出不同的結果。我們的計數器就有一個——它的最終值,字面上就取決於誰最後寫回。資料競爭則是造成我們這個問題的、比較具體、比較底層的技術條件:兩個或更多執行緒並行存取同一個記憶體位置、至少一個存取是寫入、而且沒有任何同步替它們排序。來自兩個執行緒的 `count++` 是教科書級的資料競爭:並行、重疊、含寫入、未同步。

一個微妙但令人解脫的重點:那個競爭,即使在剛好印出 1000000 的那些執行裡也存在。靠運氣得到的正確,不是正確。臭蟲是那個壞交錯的可能性,而不是它咬到你的那一次特定執行——這正是為什麼競爭除錯起來令人抓狂。它們是經典的非決定性臭蟲:一萬次執行才出現一次,你一加上一條 print 述句想查(把某個執行緒拖慢到剛好閃過那扇窗)它就消失,而且在除錯器底下永遠重現不出來。一個你一看它就躲起來的競爭,甚至有個名字,叫海森堡蟲。

什麼能修好它——以及什麼不能

所有競爭共通的解法是同一個想法:讓讀取—修改—寫回變得不可分割,這樣沒有任何執行緒能在另一個執行緒的更新做到一半時看到 count。我們需要那三個機器步驟表現得像一個不可拆的步驟。要到達那裡有兩條誠實的路,而這個章節接下來講的就是這兩條。第一條是讓操作本身成為一個原子操作——一條硬體支援的指令(或一個 C11 的原子型別/gcc 內建的 fetch-and-add),把讀取—修改—寫回當成單一、不可打斷的一個單位來執行。有了它,`count++` 就真的無法被切開,計數器也就正確了。

第二條、更通用的路,是把危險的那幾行包進一個臨界區段——一段同一時間只准一個執行緒執行的程式碼——並用一把鎖保護它。「載入、相加、存回 count」這一塊變成一個你必須握著一把互斥鎖才能進入的區域,所以當 A 在裡頭時,B 就單純地等它的輪次;第三節那個交錯變得不可能發生,因為 B 永遠不可能在 A 更新到一半時去載入 count。這正是這個章節下一篇、也是最後一篇〈為什麼我們需要同步〉的主題——原子操作處理一個微小的操作,但互斥鎖保護的是一整段跨多行的不變式,而那才是真實程式需要的。