Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

作業系統概念

作業系統概念(進階):當執行緒爭奪同一塊資料

從一行「加一」的競爭條件出發,深入互斥、原子指令、號誌與死結,看作業系統與硬體如何聯手馴服並行

兩個行程同時加一,為什麼結果可能不是加二?

你已經知道作業系統(operating system)用情境切換(context switch)製造出「多個程式同時執行」的錯覺。現在我們把這個錯覺拆開來問一個尖銳的問題:如果有兩個執行緒(thread)都在對同一個共享變數 counter 做「讀出、加一、寫回」,各做一千次,最後 counter 一定等於兩千嗎?

直覺說「當然」。但只要你真的在多核心機器上跑這段程式,會發現結果常常是一千多,偶爾才剛好兩千——而且每次跑都不一樣。這個看似違反算術的現象,正是作業系統與並行程式設計裡最核心、也最折磨人的難題:競爭條件(race condition)。入門篇告訴你 OS 製造了「並行」的假象;這篇要告訴你,這個假象底下藏著什麼樣的深淵,以及作業系統與硬體聯手用什麼工具把它填平。

為什麼「加一」不是一個動作

問題的根源在於:counter = counter + 1 在原始碼裡看起來是一行、像是一個不可分割的動作,但編譯成機器碼後,它其實是三個獨立的步驟。

load   R1, [counter]   ; 把 counter 的值讀進暫存器 R1
add    R1, R1, 1       ; R1 = R1 + 1
store  [counter], R1   ; 把 R1 寫回 counter

作業系統概念進階概念示意圖

關鍵在於:作業系統可以在這三步的任何一步之後把 CPU 從這個執行緒手上搶走(這叫搶占, preemption),切去跑另一個執行緒。於是下面這種交錯(interleaving)就可能發生(假設 counter 起始為 5):

時間軸    執行緒 A              執行緒 B           counter 實際值
 t1     load  R1 = 5                              5
 t2                          load  R1 = 5         5
 t3     add   R1 = 6                              5
 t4                          add   R1 = 6         5
 t5     store counter = 6                         6
 t6                          store counter = 6    6   ← 應該是 7!

兩個執行緒各自都「正確地」把值加了一,但因為它們都先讀到了還沒被對方更新的舊值 5,其中一次加一的效果被另一次覆蓋了。這就是經典的遺失更新(lost update)。注意這裡沒有任何程式碼是錯的——錯的是這兩段程式碼同時存取了共享資料,而存取動作沒有被妥善協調

我們把「多個執行緒會存取共享資料、且至少一個是寫入」的這段程式碼,稱為臨界區段(critical section)。並行的全部難題,可以濃縮成一句話:如何保證同一時間至多只有一個執行緒進入臨界區段——這個性質叫互斥(mutual exclusion)

軟體解法為什麼這麼難:你需要硬體幫忙

一個自然的念頭是:那我用一個旗標變數 lock 來把關不就好了?

// 看似可行,其實有致命缺陷
while (lock == 1) { /* 別人在用,空轉等待 */ }
lock = 1;          // 我要進去了
// ... 臨界區段 ...
lock = 0;          // 我出來了

問題是:「檢查 lock 是不是 0」和「把 lock 設成 1」本身又是兩個分開的動作!兩個執行緒可能同時讀到 lock == 0,然後雙雙設成 1、雙雙進入臨界區段。我們用來保護臨界區段的鎖,自己又掉進了同樣的競爭條件。這不是繞圈圈嗎?

歷史上電腦科學家曾用純軟體解決過這個問題(最著名的是 Dekker 與 Peterson 演算法),證明了「只靠 load/store 也能達成互斥」在理論上可行。但這些演算法繁瑣、只支援固定數量的執行緒,而且在現代亂序執行(out-of-order execution)的 CPU 上還會因記憶體重排序(memory reordering)而失效。實務上,我們需要的是硬體提供的原子指令(atomic instruction)

所謂原子(atomic),意思是「不可分割」:這條指令一旦開始,就會一口氣做完,中間不可能被任何其他執行緒插入。最關鍵的一族原子指令是「比較並交換」:

CAS(addr, expected, new):
    # 以下整段保證原子執行
    if *addr == expected:
        *addr = new
        return true     # 成功搶到
    else:
        return false    # 有人捷足先登

CAS(Compare-And-Swap,x86 上是 cmpxchg 指令)把「比較」和「寫入」綁成一個不可分割的動作。有了它,前面那個有缺陷的鎖就能修好:

// 用 CAS 實作的自旋鎖(spinlock),這次是正確的
while (!CAS(&lock, 0, 1)) {
    /* CAS 失敗代表鎖被別人持有,繼續嘗試 */
}
// ... 臨界區段 ...
lock = 0;   // 釋放

這就是自旋鎖(spinlock):拿不到鎖的執行緒就在原地「自旋」(空轉重試)。它是作業系統核心內部最基礎的同步原語(synchronization primitive)。所有更高階的鎖,幾乎都建立在 CAS 這類原子指令之上。

自旋還是睡眠:兩種等待哲學

自旋鎖有個明顯缺點:等不到鎖時它讓 CPU 空轉燒電。如果臨界區段很短(幾十個 cycle),自旋幾下就拿到了,比起切換執行緒還划算;但如果臨界區段很長,自旋就是純粹浪費。

於是有了另一種鎖:互斥鎖(mutex)。拿不到 mutex 的執行緒不會空轉,而是主動進入睡眠(sleep)——作業系統把它從可執行佇列移走,去跑別的執行緒,等鎖釋放時再叫醒它。這牽涉到一次系統呼叫與情境切換,成本較高,但 CPU 不會被白白浪費。

自旋鎖(spinlock) 互斥鎖(mutex)
等待方式 原地空轉重試 睡眠,讓出 CPU
適用臨界區段 極短 較長
等待成本 燒 CPU,但無切換 不燒 CPU,但有切換開銷
典型使用者 核心內部、多核心 應用程式、使用者空間

實務上的鎖常是混合策略:Linux 的 futex(fast userspace mutex)先在使用者空間用原子指令快速嘗試,只有在真的有競爭、必須等待時才掉進核心睡眠。沒競爭時完全不進核心,省下系統呼叫的成本——這正呼應了入門篇談過的「跨界昂貴,能不進核心就不進」。

比鎖更高階的工具:號誌與條件變數

鎖解決的是「互斥」,但並行程式還有另一種需求:協調(coordination)——讓某個執行緒等到「某件事發生」才繼續。例如生產者—消費者問題:消費者必須等到緩衝區裡「有東西」才能取,生產者必須等到緩衝區「有空位」才能放。

Dijkstra 在 1965 年提出的號誌(semaphore) 是這類問題的經典工具。一個號誌維護一個整數計數值,配兩個原子操作:

wait(S)   (又稱 P 操作):
    S = S - 1
    if S < 0:  把自己加入等待佇列並睡眠

signal(S) (又稱 V 操作):
    S = S + 1
    if 有人在等待佇列:  叫醒其中一個

S 初始為 1 時,號誌退化成一把互斥鎖;當 S 初始為 N 時,它能允許最多 N 個執行緒同時進入(例如限制連線池大小)。用一對號誌就能優雅地解決生產者—消費者問題(一個數「空位」、一個數「資料」)。

更貼近現代程式設計的是條件變數(condition variable),它總是和一把 mutex 搭配使用,提供 waitsignalbroadcast,讓執行緒在持有鎖的情況下「放掉鎖去睡,等被通知時再醒來重新搶鎖」。幾乎所有高階語言的並行函式庫(Java 的 synchronized、Python 的 threading.Condition、C++ 的 std::condition_variable)骨子裡都是這套機制。

動手算一下:競爭視窗有多大?

很多人以為競爭條件「機率太小,不會中」。我們來估算一下這種僥倖有多危險。

假設臨界區段(那三條 load/add/store)執行需時約 $3\text{ ns}$,而程式總共要對 counter 做 $N = 10^7$ 次更新。一次更新中,「讀出舊值到寫回新值」之間的這段時間就是競爭視窗(race window),大約 $w = 2\text{ ns}$。

在多核心上,兩個執行緒幾乎連續不斷地更新。我們粗略地問:另一個執行緒「剛好」在這 $2\text{ ns}$ 視窗內也來更新的機會有多大?若兩執行緒平均每 $3\text{ ns}$ 各更新一次,兩者落入同一視窗的碰撞機率大約是

$$ p \approx \frac{w}{\text{更新間隔}} = \frac{2}{3} \approx 0.67 \text{(量級上 } O(1)\text{)} $$

也就是說,在這種高頻共享寫入下,碰撞幾乎必然發生,難怪結果遠小於兩千萬。但若把更新頻率降到每秒才一次(更新間隔 $10^9\text{ ns}$),碰撞機率就驟降到

$$ p \approx \frac{2}{10^9} = 2 \times 10^{-9} $$

幾乎永遠不會中——但「幾乎永遠」不等於「永遠」。這正是並行 bug 最陰險之處:它可能在你的測試環境裡一年都不出現,卻在上線後流量爆增的某個深夜引爆。 競爭條件不是用測試「測掉」的,而是要靠正確的同步設計從根本上排除。

鎖會反咬一口:死結

加鎖能消除競爭,但鎖本身又會帶來新的災難:死結(deadlock)。設想兩個執行緒各需要兩把鎖 A 和 B:

執行緒 1:lock(A); lock(B); ...   # 先拿 A 再拿 B
執行緒 2:lock(B); lock(A); ...   # 先拿 B 再拿 A

如果執行緒 1 拿到了 A、執行緒 2 同時拿到了 B,接下來:1 想要 B(被 2 握著)、2 想要 A(被 1 握著)。兩者互相等待對方手上的鎖,永遠等下去,整個程式凍結。這就是著名的「哲學家用餐問題」的核心。

死結要同時滿足四個條件才會發生(Coffman 條件),缺一不可:

  1. 互斥(mutual exclusion):資源一次只能被一個執行緒持有。
  2. 持有並等待(hold and wait):執行緒握著資源的同時又去要別的。
  3. 不可搶占(no preemption):資源不能被強行奪走,只能自願釋放。
  4. 循環等待(circular wait):存在一個「A 等 B、B 等 C、…、最後一個又等 A」的環。

理解這四個條件的價值在於:只要破壞任何一個,就能預防死結。最常用、也最務實的手法是破壞第四個——強制所有執行緒以同一固定順序取得鎖(例如永遠先 A 後 B)。只要大家都遵守這個全域順序,就不可能形成循環等待,死結自然無從發生。這也是大型系統裡「鎖階層(lock ordering)」規範存在的理由。

重點回顧

  • 競爭條件的根源是「看似一個動作的操作(如 counter+1)實際由多個機器指令組成」,而作業系統可在任意指令邊界搶占切換,導致交錯與遺失更新。
  • 保護共享資料的那段程式碼叫臨界區段,並行的核心目標是達成互斥:同時至多一個執行緒進入。
  • 純軟體互斥困難且脆弱,實務上仰賴硬體的原子指令(如 CAS/cmpxchg);自旋鎖、mutex、號誌、條件變數都建立在其上。
  • 自旋鎖原地空轉適合極短臨界區段,mutex 睡眠讓出 CPU 適合較長等待;Linux 的 futex 是「無競爭走使用者空間、有競爭才進核心」的混合策略。
  • 鎖會引入死結,它需同時滿足 Coffman 四條件;破壞任一條件即可預防,最常見的是「固定取鎖順序」打破循環等待。

深入探討(研究所視角)

前面把鎖講得像萬靈丹,但研究所層級要面對的真相是:鎖本身既不充分、也常常不是最好的答案。

記憶體模型與重排序:原子性還不夠。 現代 CPU 與編譯器為了效能,會把指令重新排序、把寫入暫存在每個核心的 store buffer 裡延後對其他核心可見。這意味著即使每個操作都「原子」,不同核心觀察到記憶體事件的順序也可能不一致——這就是為什麼裸用 volatile 防不了並行 bug。正確的並行程式必須依據語言的記憶體模型(memory model)(如 C11/C++11 的 memory_order、Java 的 JMM)插入適當的記憶體屏障(memory barrier/fence)。最弱的 relaxed 只保證單一變數的原子性,acquire/release 建立跨執行緒的「happens-before」可見性保證,seq_cst(循序一致)最強也最貴。鎖之所以「能用」,正是因為它的取得/釋放隱含了 acquire/release 屏障,幫你把可見性順序釘好。

無鎖與等待自由:跳過鎖。 鎖會造成阻塞:持鎖執行緒若被搶占或崩潰,所有等鎖的人一起卡住(這叫優先序反轉, priority inversion,1997 年火星探路者號就栽在這上面)。無鎖(lock-free) 資料結構直接用 CAS 迴圈操作共享結構,保證「至少有一個執行緒能持續前進」,整體不會因單一執行緒停擺而全停。代價是 ABA 問題(值從 A 變 B 又變回 A,CAS 誤判沒變)與極高的設計難度。更強的等待自由(wait-free) 保證「每個執行緒都在有限步內完成」,但實作代價通常高到不切實際。

交易記憶體:把臨界區段當資料庫交易。 一個更上層的構想是軟體交易記憶體(Software Transactional Memory, STM):程式設計師只要把一段操作標記為 atomic { ... },執行期就樂觀地讓它跑,若偵測到與其他交易衝突就整段回滾重試——就像資料庫的交易隔離。它讓「組合多個操作」變容易(鎖很難安全組合),但有重試開銷與 I/O 不可回滾等限制。Intel 曾在硬體層提供 TSX(Transactional Synchronization Extensions) 嘗試加速,後因安全漏洞大多被停用,至今仍是活躍的研究方向。

形式化驗證:證明而非測試。 既然並行 bug 幾乎測不出來,最前沿的做法是用模型檢查(model checking) 工具(如 TLA+、SPIN)窮舉所有可能的執行緒交錯,數學上證明互斥與無死結成立。微型核心 seL4 更進一步,對整個核心做了機器檢查的形式化正確性證明。

這些議題向上連結到分散式系統(跨機器的共識與一致性,本質是並行問題的放大版)、向下連結到計算機組織(快取一致性協定 MESI 正是硬體在替你維護記憶體可見性)。可以說,「兩個行程同時加一」這個小到不能再小的問題,一路通往現代計算最深的理論核心:當多個獨立的執行流共享狀態時,我們究竟能保證什麼、又必須付出什麼代價。

AI 共讀助教正在陪你讀:作業系統概念(進階):當執行緒爭奪同一塊資料
嗨!我是這篇文章的共讀助教,只根據〈作業系統概念(進階):當執行緒爭奪同一塊資料〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。