National Central University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try an AI Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

3

Local AI

--

Uedu Code

--

中央大學 AQI 80 24°C PM2.5 21
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

Unit 08

競爭條件與臨界區

counter++ 為什麼會算錯:親手排出遺失更新
Interaction實作 35 minute(s)

Learning Objectives

  • 說明 counter++ 為什麼不是一個動作,以及遺失更新怎麼發生
  • 親手排出讓結果出錯的交錯順序,再用隨機執行看同一支程式可能有哪些結果
  • 找出程式裡的臨界區,用 mutex 保護它
  • 說明為什麼「測試跑過一次是對的」不能證明沒有競爭條件

同一支程式,每次答案都不一樣

兩個執行緒各把同一個變數加 300 萬次,答案應該是 600 萬。這支程式放到真正的 Linux 上跑六次:

第 1 次:counter = 4056803
第 2 次:counter = 4543638
第 3 次:counter = 4251054
第 4 次:counter = 5945684
第 5 次:counter = 4110325
第 6 次:counter = 4082710

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-03)。

沒有一次是 600 萬,而且每次都不同。程式沒有用亂數,也沒有讀外部資料,只有 counter++ 這一行在做事。

這種「結果取決於執行順序」的錯誤叫競爭條件(race condition)。這一課要親手把它排出來。

教學核心跑的是同一種程式,只是圈數少很多,讓你可以一步一步看。

counter++ 其實是三個動作

C 語言寫起來只有一行,CPU 做的是三件事:

load  counter     把記憶體裡的 counter 讀到暫存器
add   1           暫存器加 1
store counter     把暫存器寫回記憶體

每個執行緒有自己的暫存器,記憶體裡的 counter 則是共用的。作業系統隨時可能在兩個指令之間暫停一個執行緒、換另一個上來(上一課的內容切換),暫存器的內容會保存起來,下次輪到時原封不動放回去。

如果 A 讀到 0 之後被換下來,B 也讀到 0,兩個人都算出 1、都寫回 1:加了兩次,結果只多 1。這叫遺失更新(lost update)。

互動練習:你來當排程器

程式已經寫好,每個執行緒只做一次 counter++。排程交給你:有多個執行緒能跑時,每張卡片會出現「讓它走一步」,按哪一張就輪到誰。卡片上寫著下一個機器指令,也看得到這個執行緒的暫存器;右邊的「變數與鎖」看得到記憶體裡的 counter。

臨界區與互斥

會存取共用資料、而且不能和別人交錯執行的那段程式,叫臨界區(critical section)。上面的臨界區就是 counter++ 這一行。

解法要滿足三個條件:

條件 意思
互斥(mutual exclusion) 同一時間最多一個執行緒在臨界區裡
能前進(progress) 臨界區沒人時,想進去的人不會被無故擋在外面
等待有上限(bounded waiting) 不會有人永遠排不到

最常用的工具是 mutex(互斥鎖):進入臨界區前 pthread_mutex_lock,離開時 pthread_mutex_unlock。別人持有鎖時,呼叫 lock 的執行緒會被核心放到等待狀態,不會佔用 CPU。

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

pthread_mutex_lock(&lock);
counter++;
pthread_mutex_unlock(&lock);

注意 mutex 沒有禁止切換:持有鎖的執行緒還是可能在 counter++ 中間被換下來。差別在於別人就算輪到了,也進不了臨界區。

鎖本身怎麼做到「不會兩個人同時拿到」,是下一課的內容。

互動練習:隨機跑很多次,再把它修好

這次每個執行緒加 20 次,不可能一步一步排了。「隨機跑」會用不同的交錯重跑同一支程式,統計出現過哪些結果。先看修改前的分布,再加上 mutex。

為什麼這種錯誤很難抓

加上 mutex 之後,同一支程式在真正的 Linux 上跑三次(每個執行緒 30 萬次,因為鎖比較花時間):

第 1 次:counter = 600000
第 2 次:counter = 600000
第 3 次:counter = 600000

在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-03)。

有鎖就正確了。但沒有鎖的版本並不是每次都錯得這麼明顯。如果兩個執行緒沒有同時開始,第一個可能在第二個建立之前就做完了,測試就會得到完全正確的答案。圈數少、核心少、機器不忙的時候,錯誤可能一直不出現。

所以:

  • 跑過一次是對的,不能證明沒有競爭條件。 錯誤取決於時機,可能要到正式環境、流量變大才出現。
  • 加 printf 除錯可能讓錯誤消失。 輸出會改變時機,這類錯誤有時被叫做 Heisenbug(一觀察就變)。
  • 要從程式碼判斷:有沒有共用資料?有沒有人會寫入?存取的時候有沒有用同一把鎖保護?

工具可以幫忙:gcc 和 clang 的 ThreadSanitizer(編譯時加 -fsanitize=thread)會在執行時偵測沒有保護的共用存取。它只能抓到這次執行有走到的路徑,所以仍然不能取代對程式碼的判斷。

用程式模擬兩種交錯

用 Python 把剛才的三個指令寫出來。order 是執行順序,例如 'AABBAB' 代表 A、A、B、B、A、B。注意 reg 是每個執行緒各一個,counter 只有一個。

實作:算出所有交錯的結果

A、B 各有 3 個指令,全部的先後順序有幾種?其中幾種會遺失更新?補完程式,算出每種結果出現幾次。

combinations(range(6), 3) 會列出「A 的 3 個指令放在 6 個位置裡的哪幾個」的所有選法,每一種選法就是一種交錯。

你的輸出
預期輸出

確認一下

1.

兩個執行緒各做一次 counter++,counter 從 0 開始。下面哪一種順序的結果是 1?(A1 是 A 的 load、A2 是 add、A3 是 store)

2.

一支沒有用鎖的多執行緒程式,在你的電腦上測試 100 次都正確。可以得到什麼結論?

3.

只有一顆 CPU 核心時,還會發生遺失更新嗎?

4.

有 mutex 保護 counter++ 時,持有鎖的執行緒可能在 load 和 store 之間被換下來嗎?

5.

下面哪一段程式需要保護?

第 1 次作答

重點整理

  • counter++ 是 load、add、store 三個動作,切換可能發生在任何兩個動作之間。
  • 結果取決於執行順序的錯誤叫競爭條件;遺失更新是最典型的一種。不需要多核心,一顆核心加上切換就會發生。
  • 存取共用資料、不能交錯的程式段是臨界區。用 mutex 保護時,切換還是會發生,只是別人進不來。
  • 測試通過不能證明沒有競爭條件,要從程式碼判斷:共用、有寫入、沒有同一把鎖保護。

下一課打開鎖的內部:硬體提供什麼指令,自旋鎖和 mutex 有什麼差別。

這一課完成了!
Next lesson:鎖:自旋鎖與 mutex
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。