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

為什麼我們需要同步

第 4 篇當場逮到 count++ 在說謊——兩個執行緒、兩次弄丟的更新、一個被弄壞的計數器。這一篇把那個診斷變成解藥:臨界區的概念、互斥的規則,以及執行它的互斥鎖。讀完你會知道的,不只是鎖能修好競爭,而是為什麼任何比它更弱的東西都修不了。

從診斷到解藥

第 4 篇留給你一個精確而令人不安的事實:當兩個執行緒對同一個共用變數執行 `count++` 時,更新會悄無聲息地消失。原因在於 `count++` 不是一個不可分割的步驟——它是一個讀取、再修改、再寫回:把 count 載入暫存器、加一、再存回去。如果執行緒 A 讀到 41,而執行緒 B 在 A 把 42 存回去之前也讀到 41,兩者都會存回 42,於是有一次遞增就這麼憑空不見了。那個變數是共用可變狀態,兩個執行緒在無法預測的時刻交錯,結果就是一個競爭條件。這一篇回答下一個自然的問題:既然我們已經能替這個病命名,那解藥是什麼?

請注意究竟哪裡出了錯,因為解藥的形狀正由它決定。臭蟲不在於兩個執行緒以「錯誤的順序」執行——根本沒有正確的順序;任一執行緒贏都合情合理。臭蟲在於某個執行緒的「讀取—修改—寫回」被從中間剖開,而另一個執行緒鑽進了那道縫隙。所以解法不是去控制順序,而是要確保:一旦某個執行緒開始它的「讀取—修改—寫回」,在它做完之前,別的執行緒都不能碰 count。我們需要一種方式來說:這一小段程式碼必須一路跑到完成,不被干擾。 那一小段有個名字。

臨界區與互斥的規則

臨界區 是一段存取共用狀態、且絕不能由超過一個執行緒同時執行的程式碼。在我們的計數器裡,臨界區就是組成 `count++` 的那三條小指令。我們需要的承諾是 互斥:在任一瞬間,至多只有一個執行緒能待在臨界區裡面;其他想進去的人,都得在門外排隊等輪到自己。若這個承諾成立,那個弄丟更新的交錯就變得不可能發生——執行緒 B 在執行緒 A 把結果完整存回去之前,根本讀不到 count,於是 B 讀到的是 42、不是過期的 41,因而算出 43。沒有更新被弄丟,每一次都如此。

請誠實面對這個概念帶來的代價。互斥是刻意把臨界區裡的平行性給拿掉的:當一個執行緒在裡面時,其他執行緒只能乾坐著。那正是重點所在——但它意味著臨界區是你的並行程式短暫變回循序的地方,所以你會希望它小到正確性規則所允許的極限。鎖住 `count++` 本身,而不是剛好把它包在裡面的整個函式。被競爭燙過手之後,初學者常見的矯枉過正,是把一大片程式碼包進同一把鎖裡;那是對的,但慢,因為你把當初辛辛苦苦造出來的並行性又丟掉了。真正的功夫,是找出仍能保護那條不變式的最小區域。

互斥鎖:門上的一把鎖

經典的機制是 互斥鎖——它的英文 mutex 是 mutual exclusion lock(互斥鎖)的縮寫。把它想成一間只容一人的房間的鑰匙。進入臨界區之前,執行緒呼叫 pthread_mutex_lock();若房間空著,它就握著鑰匙走進去;若另一個執行緒已經握著鑰匙,它就阻塞——核心讓它睡著——直到鑰匙被歸還。當這個執行緒做完臨界區的事,它呼叫 pthread_mutex_unlock(),把鑰匙交還,於是一個正在等的執行緒可以醒來、進入。因為鑰匙恰好只有一把,任何時刻至多只有一個執行緒在裡面。這就是被落實出來的互斥。

#include <pthread.h>

static long count = 0;
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void *worker(void *arg) {
    (void)arg;
    for (int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&lock);     /* enter critical section */
        count++;                       /* the read-modify-write, now protected */
        pthread_mutex_unlock(&lock);   /* leave; wake a waiter */
    }
    return NULL;
}

/* Two threads each run worker(); join both; count is now exactly 2000000. */
第 4 篇那個會競爭的計數器,現在正確了。lock() 與 unlock() 把臨界區夾在中間,使「讀取—修改—寫回」相對於其他執行緒以不可分割的方式執行。沒有這把鎖,它印出的是一個小於 2000000、且每次執行都不同的數字;有了它,每一次都恰好是 2000000。編譯指令:gcc -O2 -Wall main.c -pthread

為什麼我們不能用更弱的東西修好競爭——比方說,純粹的讀和寫加上某種巧妙的排序,完全不用鎖?因為這個危害住在你的 C 敘述句底下那一層。編譯器和 CPU 都被允許重排一般的記憶體操作,而上下文切換可以落在任意兩條機器指令之間;沒有同步原語,「讀取—修改—寫回」這串動作就沒有任何一處是系統保證不可分割的。互斥鎖之所以管用,正是因為 lock 與 unlock 很特別:它們本身建立在硬體不可分割操作之上(以及隨之而來的記憶體順序保證),所以它們不會像 `count++` 那樣被撕開。你不是再加一段可能被中斷的程式碼;你是伸手探到一個硬體承諾不可能被中斷的操作。

原子操作:當鎖縮成單一指令

對於「單獨一個計數器」這個特定情況,還有一帖更輕的解藥。現代 CPU 提供 不可分割的 讀取—修改—寫回指令——一個單一的硬體操作,在一個不可分割、其他核心無法中斷的步驟裡完成載入、遞增與儲存。在 C 裡你透過 `_Atomic long count;` 與一個原子遞增去用它,在 C++ 裡則透過 `std::atomic<long>`;底下坐著的是諸如比較並交換或原子加法之類的指令。對單一變數而言,這比互斥鎖快,因為執行緒從不必睡著或醒來——它只是請硬體把整串「讀取—修改—寫回」一口氣做完。

那既然原子操作比較快,又何必還留著互斥鎖?因為原子操作一次只保護一個記憶體位置,而大多數真實的不變式橫跨好幾個。如果你得把錢從一個帳戶轉到另一個帳戶,「兩個餘額之和永遠等於同一個總額」這條規則,是橫跨兩個變數的關係;沒有任何單一原子指令能在轉帳過程中讓兩者都保持一致。互斥鎖則能把整個多步驟的更新當成一個臨界區守住。誠實的拇指法則:當整個共用操作就是對單一值的一次「讀取—修改—寫回」時,伸手去拿原子操作;當正確性取決於好幾個操作、或好幾個變數要一起保持一致時——也就是有一條鎖不變式必須在整個臨界區裡都成立時——伸手去拿互斥鎖。

鎖解決一個問題,又打開另一個

若讓你以為互斥鎖是把所有並行痛苦一鍵關掉的乾淨開關,那就不誠實了。同步是一個貨真價實的新難度層次,它帶來自己的失敗模式。在函式眾多出口的其中一條路徑上忘了解鎖,其他每個執行緒就永遠等下去。更糟的是,在不同執行緒裡以不同順序拿兩把鎖——執行緒 1 握著鎖 A 想要 B,而執行緒 2 握著 B 想要 A——兩者就永遠互相等待。這種僵局就是 死結,是你一旦擁有超過一把鎖、那一刻就到來的招牌危害。修好競爭的解藥是真的,但它不是免費的,必須小心使用。

退一步,看看你在整個章節裡蓋起了什麼。你學到並行不是平行、執行緒是一個共用同一位址空間的跑者而非一個私有的行程、怎麼用 pthreads 生出一個,以及跨執行緒共用可變狀態會造出競爭條件,因為 `count++` 並非不可分割。這一篇把這個迴圈收了起來:解藥是一個受互斥保護的臨界區,由一把互斥鎖(或者,對單一值而言,一個原子操作)來執行,而這把鎖立基於任何更弱的東西都提供不了的硬體保證。下一個章節就蓋在這片地板上——用條件變數去等待某個狀態、而不只是把別人排除在外,還有號誌、生產者—消費者模式,以及你必須學會避開的死結。你現在握有其餘一切所倚賴的那個核心概念:共用可變狀態需要同步,而你確切知道為什麼。