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

臨界區與互斥鎖

並行那個章節讓你看到,兩個共用記憶體的執行緒如何把它弄壞。這篇給你第一個、也是最重要的修法:把那段危險的程式碼取個名字叫臨界區,再用一把互斥鎖保護它,讓任何時刻都只有一個執行緒待在裡面。

為什麼一行 C 不是一個步驟

在並行那個章節你見過那個醜陋的事實:當兩個執行緒共用同一個位址空間時,它們就共用每一個全域變數、每一個堆積物件,而硬體真的會讓它們在不同的核心上同時執行。那正是資料競爭會咬人的時刻。經典的例子看起來無害到極點。拿一個共用的 `int counter`,從兩個執行緒各跑一次 `counter = counter + 1`。你會以為計數會加二。常常確實如此。但有時候只加了一,而你剛剛親眼看著一次更新憑空消失。

原因在於 `counter = counter + 1` 不是一個步驟。CPU 沒辦法在一個念頭裡就對記憶體做加法;它必須先把目前的值載入暫存器,一,再把結果存回去。這三段式的舞步就是讀取—修改—寫回,而縫隙就在這裡張開。如果執行緒 A 載入了 5,然後執行緒 B 在 A 存回之前也載入了 5,兩者都算出 6、都寫回 6,於是一次遞增永遠遺失。兩個執行緒各自的邏輯都沒壞——這個 bug 完全活在交錯之中。

把一種糟糕的交錯大聲推一遍。執行緒 A 載入 `r = 5`。在 A 來得及存回之前,排程器切到執行緒 B,它也載入了 `r = 5`。A 加一並存回 `counter = 6`。B 仍握著它那個過時的 5,再加一、再次存回 `counter = 6`。兩次遞增都跑了,計數器卻只從 5 動到 6——第二次寫入蓋掉了第一次。5 和 6 這些值都沒問題;載入與存回剛好如何交錯的那個順序,才是整個病灶。

為危險命名:臨界區

修法的第一步,是為那段危險的程式碼取個名字。臨界區critical section)是指任何一段碰觸共用可變狀態的指令,而且這碰觸不能被另一個做同樣事情的執行緒打斷。在計數器的例子裡,整個載入—加—存回就是一個臨界區。目標不是要讓那三條指令變得不可中斷——作業系統照樣可以在中途暫停你的執行緒——而是要保證在你執行那一段時,沒有別的執行緒同時在跑它自己的同一段。

那個保證也有個名字:互斥。「互斥」的意思是任何一瞬間最多只有一個執行緒能待在臨界區裡;當一個在裡面時,其他想進去的全都得等。把它想成一間只能容一人、只有一把鑰匙的洗手間。誰都可以在外面等;只有握著鑰匙的人在裡面;他出來時把鑰匙交給下一個。鑰匙不會讓你變快,也不會讓那棟建築消失——它只是替那一間房強制執行一次一個

互斥鎖:一把有主人的鎖

強制執行互斥的工具就是互斥鎖——英文 mutex 是 mutual exclusion 的縮寫。它就是洗手間那把鑰匙,在軟體裡成真。互斥鎖只有兩個狀態,未鎖與已鎖,外加一個持有它的概念。你把臨界區夾在一次上鎖與一次解鎖之間,剩下的交給互斥鎖:第一個呼叫上鎖的執行緒走進去;當它被持有期間,任何其他呼叫上鎖的執行緒會被作業系統送去睡覺,直到主人解鎖為止。用 POSIX 執行緒時,這兩個呼叫是 pthread_mutex_lock() 與 pthread_mutex_unlock()。

pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
int counter = 0;

void bump(void) {
    pthread_mutex_lock(&m);     /* enter critical section */
    counter = counter + 1;      /* now safe: we hold m     */
    pthread_mutex_unlock(&m);   /* leave; wake a waiter     */
}
計數器競爭的修法:只有持鎖者能跑那段載入—加—存回,於是更新再也不會重疊。

注意這把鎖真正替你買到了什麼。當一個執行緒持有 m 時,它可以放心地把資料弄亂、再修好——例如從串列上摘掉一個節點,那會讓串列一瞬間處於改了一半的狀態——只要在解鎖之前一切又恢復一致就好。那個承諾叫鎖不變式:一條在鎖空閒時恆成立、在持鎖期間可暫時被破壞、且必須在解鎖前修復的規則。正是這把鎖,讓你能擁有那些私密、凌亂的中途時刻,而不被任何其他人看見。

自旋還是睡覺?兩種等待方式

當鎖已經被持有時,等待中的執行緒有兩個誠實的選項,而它們的取捨非常不同。真正的互斥鎖會讓等待者睡覺:作業系統把它從執行佇列上移走,等到鎖空出來才喚醒它,所以一個被擋住的執行緒不燒任何 CPU。另一個選項是自旋鎖,它做的恰恰相反——它待在一個緊湊的迴圈裡不停地問「空了沒?空了沒?」直到搶到鎖。自旋浪費週期,但它省下了進出睡眠那一次內容切換的成本。

所以判斷的訣竅在於你預期要等多久。如果臨界區只有幾條指令、只持有幾奈秒,自旋往往勝出,因為一次內容切換可能比整段等待還貴。如果那一段可能被持有很久——更糟的是,如果持鎖者可能卡在 I/O 上——那自旋就是災難:你會白白燒掉一整個核心、什麼也沒做,而持鎖者卻在睡覺。自旋鎖在核心內部、以及經過仔細量測的熱路徑上才會發光;一般的應用程式碼,先伸手拿一把普通的互斥鎖,讓它去睡。

互斥鎖辦不到的事

對它的限制要誠實,因為一把只被半信半疑的鎖,比完全沒有鎖更危險。互斥鎖保護的,是你約定它保護的東西——而那個約定只活在你的腦袋和你的程式碼裡,從來不在語言之中。編譯器不會阻止另一個函式不拿 m 就去碰 `counter`。互斥鎖是一條靠大家都選擇對同一份資料、上同一把鎖來維繫的慣例。為每一份共用狀態選定一把鎖,把它記錄下來,並讓每一次存取都走過它。

互斥鎖本身也沒辦法讓你等待一個條件——好比「等到佇列非空為止」。你是可以上鎖、瞄一眼、解鎖、再迴圈,但那種忙碌等待浪費 CPU、又跟鎖打架。乾淨的解答是另一個工具,條件變數,那正是下一篇要建立的。而且互斥鎖也不是最便宜的工具:對單一個計數器來說,一個無鎖的原子操作可以用一個不可分割的硬體步驟完成遞增,完全不用鎖。原子操作對微小的更新是個真正的替代方案——我們會在這個章節的尾聲遇到它——但它沒辦法像互斥鎖那樣,擴展去保護一個多步驟的不變式。