上一篇談 Thread 時,我們最後留下了一個問題,假設同一個 Process 裡有兩條 Thread,而且它們共享:
int counter = 0;
現在兩條 Thread 都執行一次:counter++;照理來說:
初始 counter = 0
Thread A → +1
Thread B → +1
最後 counter = 2
但在 Multi-threaded Program 裡,最後的結果不一定是 2。
甚至更麻煩的是:有時候程式跑一百次都正常,第 101 次才突然出錯。
問題是:
counter++:
counter
│
│ +1
▼
counter
一個動作,結束。
但是Source Code 的「一行」,不代表 CPU 執行時一定是唯一個不可分割的操作,假設:
counter++;
counter++;
最後 Memory 裡:counter = 1,為什麼不是 2?
因為兩條 Thread 都在對方寫回之前讀到了:counter = 0,因此它們各自都算出:0 + 1 = 1
最後兩邊都把 1 寫回去。
其中一次更新就像被吃掉了。
這種情況常被稱為:Lost Update
也就是某次更新因為並行操作互相覆蓋,最後沒有反映在結果裡。
原本在程式邏輯上希望被當成一個完整操作的事情,實際上可能由多個步驟組成,而其他 Thread 有機會在這些步驟之間介入。
假設:
Thread A → read x
Thread B → read x
兩個 Thread 都只是讀取,而且沒有其他修改,通常不會因為「兩個人一起讀」就產生我們這裡說的資料競爭問題。會出問題的是:
Race Condition:程式的正確結果依賴多個執行流程發生的相對順序或時機,而這個順序沒有被程式正確控制。
(2)同一份程式 + 同一份輸入 + 不同 Execution Interleaving = 不同結果
例如同一段程式:counter++;可能有一種執行順序:
Thread A:Load 0 | Thread B:Load 1
Thread A:Add 1 | Thread B:Add 1
Thread A:Store 1| Thread B:Store 2
結果:
counter = 2
但是也可能:
Thread A:Load 0
Thread B:Load 0
Thread A:Add 1
Thread B:Add 1
Thread A:Store 1
Thread B:Store 1
結果:
counter = 1
這就是不同 Execution Interleaving 有 不同結果。
(3)Interleaving 是什麼?:不同 Thread 的操作彼此交錯執行。
只要各 Thread 自己需要遵守的執行關係沒有被破壞,不同 Thread 之間就可能出現許多不同的 interleaving。
所以Concurrency 最難處理的地方之一,就是:
不能只看某一條 Thread 自己的程式碼,還必須考慮它和其他 Thread 交錯之後會發生什麼。
不需要。
即使只有One CPU Core,也可能發生 Race Condition。
假設 Thread A 正在執行:
Load counter → 0
Add 1 → 1
但還沒 Store 回去,這時 OS 因為排程等原因讓 CPU 改去執行 Thread B。
所以,多個 execution flows 的操作可以產生不受控制的 interleaving,而程式的正確性依賴這些操作的相對順序。
因此,在 Multi-core CPU 上,問題甚至可能更複雜,因為不同 Threads 確實可以在不同 Cores 上平行執行。
假設:counter++;這段操作會修改所有 Threads 都能存取的Shared Data
那這一小段程式就不能毫無限制地讓多個 Thread 同時進來操作。
這種會存取共享資源,而且需要避免不安全並行操作的程式區域,通常稱為:Critical Section
也就是說,counter++;成為一個需要被保護的 Critical Section。
Thread A
│
▼
┌─────────────────┐
│ Critical Section│
│ │
│ counter++; │
│ │
└─────────────────┘
如果 Thread A 正在執行這段會修改共享資料的程式碼,那 Thread B 暫時不能同時執行同一個受保護區域。
等 A 完成:
Thread A 離開
↓
Thread B 才進入
這樣就可以讓:
A:Load
A:Add
A:Store
一個受到保護的操作序列完成之後,再輪到 B
(2)而這種:同一時間只允許一個執行流程進入某個 Critical Section叫做:Mutual Exclusion互斥。(Mutex,名字其實就是從Mutual Exclusion來的)
Race Condition 有一個非常討厭的特性:它不一定每次發生。假設我們寫:
Thread A:counter++
Thread B:counter++
第一次執行:
A 完整跑完
↓
B 完整跑完 ; 結果 = 2
第二次:一樣完美;
第三次:
A Load
↓
B Load
↓
A Store
↓
B Store ; 結果 = 1
這就是 Concurrent Bug 很難 Debug 的原因之一,因為它可能受到:
Thread A / Thread B 兩邊可以任意進入:Load / Add / Store
所以發生 Race Condition。
那我們改成:
Thread A
│
│ 取得進入權
▼
┌──────────────────┐
│ Critical Section │
│ │
│ counter++; │
│ │
└──────────────────┘
│
│ 離開
▼
Thread B 才能進入
也就是要建立:Synchronization
建立多個 execution flows 之間需要遵守的協調規則,避免不安全的執行順序。其中一種最經典的工具就是:Mutex,但 Mutex 並不是唯一的方法,後面還會依據情境有不同的解法。
Shared State
+
Concurrent Access
+
缺乏正確 Synchronization
↓
執行結果受到 Timing / Interleaving 影響
↓
Race Condition
解決方法之一是:我們把那些需要避免不安全 Concurrent Access 的程式區域稱為:
(而這種同一時間只允許一個執行流程進入某個 Critical Section叫做:Mutual Exclusion)
接下來要解決的問題就是:
如果我們希望某個操作「要嘛完整發生,要嘛完全沒發生」,中間不能被其他 Thread 看到半套狀態,CPU 和 OS 到底要怎麼做到?
下一篇: