平行化與執行緒安全 (Parallelism & Thread Safety)
Overview Table
| 主題 | 核心概念 | 關鍵結論 | 書頁 |
|---|---|---|---|
| 平行程式 (parallel program) | 在多核上執行的 concurrent program | parallel ⊂ concurrent ⊂ all programs | p.1049 |
| 平行加總實驗 | psum-mutex → psum-array → psum-local | 同步開銷極貴,能避免就避免 | p.1050-1053 |
| 效能量測 | speedup |
strong scaling vs. weak scaling | p.1054-1056 |
| Thread safety | 四類 thread-unsafe functions | Class 2 只能改寫;其餘可用 mutex / lock-and-copy | p.1056-1058 |
| Reentrancy | 不引用任何 shared data 的函式 | reentrant ⊂ thread-safe;explicit vs. implicit | p.1059-1060 |
| 函式庫函式 | rand、strtok、ctime… 為 thread-unsafe | 改用 _r 結尾的 reentrant 版本 |
p.1060-1061 |
| Races | 正確性依賴執行緒到達順序 | 傳 thread ID 要用 Malloc 獨立區塊,不可傳 &i |
p.1061-1063 |
| Deadlocks | 互相等待永遠不會發生的條件 | mutex lock ordering rule 可防死結 | p.1063-1066 |
12.6 用執行緒實現平行化 (p.1049)
現代機器多為 multi-core processor,kernel 會把 concurrent threads 排程到多個核心「平行」執行,而非在單核上循序切換。三種程式集合的關係:
- sequential program:單一 logical flow。
- concurrent program:多個 concurrent flows(與 sequential 互斥、共同構成所有程式)。
- parallel program:跑在多個 processors 上的 concurrent program → parallel programs 是 concurrent programs 的 proper subset。
+--------------------------------------------------+
| All programs |
| +----------------------------+ +------------+ |
| | Concurrent programs | | Sequential | |
| | +----------------------+ | | programs | |
| | | Parallel programs | | +------------+ |
| | +----------------------+ | |
| +----------------------------+ |
+--------------------------------------------------+
平行加總範例:三個版本的演進 (p.1050-1053)
問題:用 myid[i] = i 再傳 &myid[i]),thread 用 ID 算出自己的區間 start = myid * nelems_per_thread、end = start + nelems_per_thread。這種「傳唯一 thread ID」是平行程式的通用技巧。
| 版本 | 累加位置 | 同步方式 | 特徵 |
|---|---|---|---|
psum-mutex |
共享全域變數 gsum |
每次更新都 P(&mutex) / V(&mutex) |
慢到災難等級,加核心反而更慢 |
psum-array |
全域陣列 psum[myid] |
無(每 thread 專屬元素) | 快數個數量級 |
psum-local |
區域變數 sum,最後才寫 psum[myid] |
無 | 再快一個數量級 |
四核心、
| 版本 \ threads | 1 | 2 | 4 | 8 | 16 |
|---|---|---|---|---|---|
psum-mutex |
68.00 | 432.00 | 719.00 | 552.00 | 599.00 |
psum-array |
7.26 | 3.64 | 1.91 | 1.85 | 1.84 |
psum-local |
1.06 | 0.54 | 0.28 | 0.29 | 0.30 |
Synchronization overhead is expensive:psum-mutex 中 P/V 的成本遠高於一次記憶體更新,導致多執行緒版比單執行緒慢近一個數量級、加核心還更糟。同步能避免就避免;避不掉就要用大量有用計算來攤提 (amortize) 它。psum-local 進一步呼應第 5 章「用區域變數消除不必要的記憶體參考」的原則(見 05-Program-Optimization/04-Limiting-Factors-and-Memory-Performance)。
psum-array 之所以能不加鎖,前提是每條 thread 只寫自己專屬的陣列元素,一旦共享就回到 race 的世界。平行程式效能的量測 (p.1054-1056)
psum-local 的執行時間隨 thread 數下降,到 4 threads(= 核心數)後持平甚至微升——因為同一核心上多 threads 的 context switch 開銷。因此平行程式常寫成每核恰好一條 thread。
threads: 1 2 4 8 16
time(s): 1.06 0.54 0.28 0.29 0.30
| | | | |
██████ ███ █▌ █▌ █▌ ← 4 核用滿後不再下降
↑ 理想:threads 加倍、時間減半(直到 t > 4)
- Speedup(加速比):
, 為核心數, 為 核上的執行時間。此式稱為 strong scaling(問題大小固定)。 取「循序版程式」→ absolute speedup:較能真實反映平行化的效益。 取「平行版跑在單核」→ relative speedup:平行版即使單核也有同步開銷,會灌大分子、人為膨脹加速數字;但量測容易(absolute 需要兩份程式,複雜程式可能無循序版可用)。
- Efficiency(效率):
,以百分比表示,範圍 ;衡量平行化的 overhead——高效率代表花在有用計算的時間多、花在同步與通訊的時間少。 - 範例程式效率 88-100%(見下表)很漂亮,但那是因為問題「trivially easy to parallelize」;實務上通常沒這麼好。
| Threads |
1 | 2 | 4 | 8 | 16 |
|---|---|---|---|---|---|
| Cores |
1 | 2 | 4 | 4 | 4 |
| 1.06 | 0.54 | 0.28 | 0.29 | 0.30 | |
| 1 | 1.9 | 3.8 | 3.7 | 3.5 | |
| 100% | 98% | 95% | 91% | 88% |
- Weak scaling:核心數增加時同步放大問題規模,使每核工作量固定;speedup/efficiency 改以「單位時間完成的總工作量」表示——核心加倍且每小時做兩倍的工作 = linear speedup、100% efficiency。
| 比較 | Strong scaling | Weak scaling |
|---|---|---|
| 問題大小 | 固定 | 隨核心數等比放大 |
| 目標 | 固定工作量盡快做完 | 用更大的機器做更多工作 |
| 適用 | 問題規模不易擴大者(如 real-time signal processing,工作量由實體 sensor 決定) | 科學計算(問題可放大、越大預測越準),通常是更真實的指標 |
加速比受程式中無法平行化部分的限制:
12.7.1 Thread Safety(執行緒安全)(p.1056-1058)
函式是 thread-safe ⟺ 被多條 concurrent threads 重複呼叫時永遠產生正確結果;否則為 thread-unsafe。四類(非互斥,一個函式可同時屬多類)thread-unsafe functions:
| Class | 特徵 | 例子 | 修法 | 代價 |
|---|---|---|---|---|
| 1 | 不保護 shared variables | 遞增未保護的全域 counter | 用 P/V 保護共享變數 | 呼叫端不用改,但同步使函式變慢 |
| 2 | 跨呼叫保留 state(static 資料) | rand(next_seed 依賴前次結果)、strtok |
唯一解法:改寫成不用 static data、由 caller 以參數傳入 state(即改寫為 reentrant) | 呼叫端必須跟著改,大程式數百個 call site 易出錯 |
| 3 | 回傳指向 static variable 的指標 | ctime、gethostbyname |
(a) 改寫成由 caller 傳入結果位址(需有 source);(b) lock-and-copy | (a) 需改雙方;(b) 同步變慢、複雜結構需 deep copy |
| 4 | 呼叫 thread-unsafe function 的函式 | 任何呼叫 rand 的 f |
視被呼叫者的 class 而定(見下) | — |
- Class 3 的災難情境:多 thread 同時呼叫
ctime,一條 thread 正在使用的結果被另一條默默覆寫。 - Lock-and-copy:為 thread-unsafe function 配一個 mutex;在 call site「上鎖 → 呼叫 → 把結果複製到私有記憶體 → 解鎖」。實務上包成 thread-safe wrapper(如
ctime_ts(timep, privatep):P(&mutex); sharedp = ctime(timep); strcpy(privatep, sharedp); V(&mutex); return privatep;),再把所有呼叫改成呼叫 wrapper,對 caller 改動最小。 - Class 4 判定:f 呼叫 thread-unsafe 的 g,f 是否 unsafe?It depends——
- g 是 Class 2 → f 也 thread-unsafe,除了改寫 g 別無他法。
- g 是 Class 1 或 3 → 用 mutex 保護 call site 與所有相關 shared data,f 仍可 thread-safe(lock-and-copy 即為此例)。
12.7.2 Reentrancy(可重入性)(p.1059-1060)
Reentrant function:被多條 threads 呼叫時不引用任何 shared data 的函式。thread-safe 與 reentrant 常被(錯誤地)當同義詞,實際上有明確技術區別:
+---------------------------------------------+
| All functions |
| +------------------------+ +-----------+ |
| | Thread-safe functions | | Thread- | |
| | +------------------+ | | unsafe | |
| | | Reentrant | | | functions | |
| | | functions | | +-----------+ |
| | +------------------+ | |
| +------------------------+ |
+---------------------------------------------+
reentrant ⊂ thread-safe;thread-safe 與 thread-unsafe 互斥
- Reentrant functions 通常比 non-reentrant 的 thread-safe functions 更有效率:不需要任何同步操作。
- 把 Class 2 函式變 thread-safe 的唯一方法就是改寫成 reentrant。例:
rand_r(unsigned int *nextp)把 static 的 seed 換成 caller 傳入的指標:*nextp = *nextp * 1103515245 + 12345; return (unsigned)(*nextp / 65536) % 32768;。
| 類型 | 條件 | 可否僅看程式碼判定 |
|---|---|---|
| Explicitly reentrant | 所有參數 pass by value(無指標)且只存取區域自動(stack)變數 | 可——無論怎麼被呼叫都 reentrant |
| Implicitly reentrant | 允許部分參數 pass by reference(傳指標) | 否——僅當呼叫的 threads 小心地傳入非共享資料的指標時才 reentrant(rand_r 屬此類) |
12.7.3 在多執行緒程式中使用既有函式庫 (p.1060-1061)
大多數 Linux/標準 C 函式庫函式(malloc、free、realloc、printf、scanf⋯)都是 thread-safe,只有少數例外:
| Thread-unsafe function | Class | Linux thread-safe 版本 |
|---|---|---|
rand |
2 | rand_r |
strtok |
2 | strtok_r |
asctime |
3 | asctime_r |
ctime |
3 | ctime_r |
gethostbyaddr |
3 | gethostbyaddr_r |
gethostbyname |
3 | gethostbyname_r |
inet_ntoa |
3 | (none) |
localtime |
3 | localtime_r |
- 除
rand、strtok(Class 2)外皆為 Class 3(回傳 static 指標)。strtok已 deprecated;gethostbyaddr/gethostbyname/inet_ntoa是過時的網路函式,已被 reentrant 的getaddrinfo/getnameinfo/inet_ntop取代(見 11-Network-Programming/03-Sockets-Interface)。 - 對 caller 最不擾動的作法是 lock-and-copy,但缺點:(1) 額外同步變慢;(2) 回傳「結構中還有結構」的指標時需 deep copy 整個階層;(3) 對 Class 2(如
rand)完全無效——它依賴跨呼叫的 static state。 - 因此 Linux 提供多數 thread-unsafe 函式的 reentrant 版本,名稱一律以
_r結尾;建議能用就用。
12.7.4 Races(競爭)(p.1061-1063)
Race:程式的正確性依賴「thread A 先到達控制流中的 x 點、thread B 才到達 y 點」。成因通常是程式設計者假設 threads 會走某條特定的執行軌跡,忘了黃金法則:threaded 程式必須對任何可行的 trajectory 都正確(progress graph 觀點見 12-Concurrent-Programming/03-Shared-Variables-and-Semaphores)。
經典 bug(race.c):main thread 以 Pthread_create(&tid[i], NULL, thread, &i) 把區域堆疊變數 i 的指標傳給 peer thread;peer thread 執行 myid = *((int *)vargp)。
main thread peer thread i
----------- -------------
for i = 0..N-1:
Pthread_create(..., &i) ──┐
i++ ←─── RACE! ───────────┼──→ myid = *vargp (解參考 &i)
│
若 i++ 先發生 → peer 讀到別條 thread 的 ID(輸出重複/跳號)
若解參考先發生 → 恰好正確 —— 取決於 kernel 排程,不可預期
- 實測輸出可能出現
Hello from thread 3兩次而 0 消失——且換一台機器可能就正常,讓 bug 潛伏。 - 修法(
norace.c):每次迴圈ptr = Malloc(sizeof(int)); *ptr = i;再傳ptr——為每個 ID 動態配置獨立區塊;peer thread 讀完後必須Free(vargp)避免 memory leak。 - 為何不能由 main thread 在
Pthread_create後立刻 free?因為 peer thread 可能尚未解參考該區塊,free 後再讀是 use-after-free race(Practice Problem 12.13;memory bug 分類見 09-Virtual-Memory/06-Garbage-Collection-and-Memory-Bugs)。 - 不用 malloc/free 的替代方案(Practice Problem 12.14):如同 12.6 的 psum 程式,用全域陣列
myid[i] = i再傳&myid[i],每條 thread 有專屬元素;優點是不需動態配置,缺點是需事先知道 thread 數上限且陣列生命週期必須涵蓋所有 threads。
12.7.5 Deadlocks(死結)(p.1063-1066)
Deadlock:一群 threads 全部 blocked,等待一個永遠不會為真的條件。Semaphore 的引入帶來這種 run-time error 的可能。Progress graph 是理解 deadlock 的利器:
Thread 2
^
V(s)| . . . ←不死結的軌跡
V(t)| +----------------+
| | Forbidden |
| | region for s |
P(s)| +----|---+============| d = deadlock state
| | | d | Forbidden | ▓ = deadlock region:
P(t)| | ▓▓▓+---+ region for t 進得去、出不來
| | ▓▓▓| |
+---+----+----------------+--→ Thread 1
s=1 P(s) P(t) V(s) V(t)
t=1 (兩 thread 以「不同順序」P 兩個 mutex 時,
兩個 forbidden regions 重疊 → 產生 deadlock region)
由 progress graph 可得三個洞見:
- P/V 順序排錯使兩個 semaphores 的 forbidden regions 重疊;軌跡一旦到達 deadlock state
,每個合法方向都被擋住,無法再前進——每條 thread 都在等對方做一個永遠不會發生的 V。 - 重疊區誘發 deadlock region:軌跡碰到其中任何狀態,死結不可避免;軌跡進得了 deadlock region 卻永遠出不來。
- Deadlock 不總是可預測:有些幸運軌跡繞過 deadlock region,有些被困住。同一程式跑一千次沒事,第一千零一次死結;在這台機器正常、換一台就死結;且不易重現(每次執行軌跡不同)。
給定所有 mutexes 的一個 total ordering:若每條 thread 都依序取得 (acquire in order) mutexes、並以反序釋放 (release in reverse order),則程式 deadlock-free。
例:把 Figure 12.44 的程式改成兩條 thread 都「先鎖 s、再鎖 t」,progress graph 中兩個 forbidden regions 便不再重疊,死結消失 (Figure 12.45)。
s=1, t=0,兩 thread 都會卡在 P(t)),也會造成必然死結——修法是把 t 的初值改為 1。12.8 Summary (p.1066)
- 三種建構 concurrent programs 的機制比較(本章總結):
| 機制 | 排程者 | 位址空間 | 資料共享 |
|---|---|---|---|
| Processes | kernel 自動 | 各自獨立的 virtual address space | 需要顯式 IPC 機制 |
| I/O multiplexing(event-driven) | 程式自己顯式排程(flows 模型化為 state machines) | 單一 process | 快速、容易 |
| Threads | kernel 自動(像 process) | 單一 process(像 I/O multiplexing) | 快速、容易 |
- 無論用哪種機制,同步共享資料的存取都是難題;semaphore 的 P/V 可提供 mutual exclusion,也可排程資源存取(producer-consumer 的 bounded buffer、readers-writers 的共享物件)。
- Thread 呼叫的函式必須 thread-safe;四類 thread-unsafe functions 各有對策。Reentrant functions 是 thread-safe functions 的 proper subset,因不碰 shared data、無需同步原語而通常更快。
- Races:程式設計者對 logical flows 的排程做了錯誤假設。Deadlocks:flow 等待永遠不會發生的事件。
Exam/Test Patterns
| 情境 / 關鍵字 | 答案 |
|---|---|
| parallel、concurrent、sequential 三者關係 | parallel ⊊ concurrent;sequential 與 concurrent 互斥且窮盡所有程式 |
| psum-mutex 為何多執行緒反而更慢 | P/V 同步操作成本遠高於一次記憶體更新;synchronization overhead 未被有用計算攤提 |
| 給 |
|
| absolute vs. relative speedup 哪個較真實 | absolute( |
| strong vs. weak scaling | strong:問題固定、求更快;weak:每核工作量固定、問題隨核心數放大,以單位時間工作量計 |
| threads 數超過核心數後時間微升 | 同核心上多 threads 的 context switch 開銷 → 常設計成每核一條 thread |
rand / strtok 是哪類 unsafe、怎麼救 |
Class 2(跨呼叫 static state);只能改寫成 reentrant(rand_r/strtok_r),lock-and-copy 無效 |
ctime / gethostbyname 回傳 static 指標 |
Class 3;用 lock-and-copy wrapper 或改用 _r 版本 |
| f 呼叫 thread-unsafe 的 g,f 是否 unsafe | g 為 Class 2 → f unsafe(只能改寫 g);g 為 Class 1/3 → 用 mutex 保護 call site 即可 safe |
| reentrant vs. thread-safe | reentrant 是 thread-safe 的 proper subset;不碰 shared data、免同步、較快 |
rand_r 為何是 implicitly reentrant |
參數以指標傳入(pass by reference),僅當 caller 傳非共享資料的指標時才 reentrant(Problem 12.12) |
Pthread_create(..., &i) 傳迴圈變數位址 |
race:main 的 i++ 與 peer 的 *vargp 解參考競爭 → 用 Malloc 獨立區塊,peer Free |
| 為何不能由 main 在 create 後立刻 free ID 區塊 | peer 可能尚未解參考,變成 use-after-free race(Problem 12.13) |
| 兩 thread 以不同順序 P 兩個 mutex | forbidden regions 重疊 → deadlock region;用 mutex lock ordering rule 修正 |
| 軌跡進入 deadlock region | 死結不可避免;進得去、出不來;且 bug 不可預測、難重現 |
s=1, t=0 且兩 thread 都先 P(s)V(s) 再 P(t)V(t) |
必然死結(t 永遠為 0);把 t 初值改 1 即解(Problem 12.15) |
Related Notes
- 12-Concurrent-Programming/01-Process-and-Event-Based-Concurrency
- 12-Concurrent-Programming/02-Threads
- 12-Concurrent-Programming/03-Shared-Variables-and-Semaphores
- 12-Concurrent-Programming/Practice-Concurrent-Programming
- 01-Computer-Systems-Tour/04-Amdahls-Law-and-Concurrency-Themes
- 05-Program-Optimization/04-Limiting-Factors-and-Memory-Performance
- 09-Virtual-Memory/06-Garbage-Collection-and-Memory-Bugs
- 11-Network-Programming/03-Sockets-Interface