平行化與執行緒安全 (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 Sp=T1/Tp、efficiency Ep=Sp/p 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 排程到多個核心「平行」執行,而非在單核上循序切換。三種程式集合的關係:

+--------------------------------------------------+
|                All programs                      |
|  +----------------------------+  +------------+  |
|  |    Concurrent programs     |  | Sequential |  |
|  |  +----------------------+  |  |  programs  |  |
|  |  |  Parallel programs   |  |  +------------+  |
|  |  +----------------------+  |                  |
|  +----------------------------+                  |
+--------------------------------------------------+

平行加總範例:三個版本的演進 (p.1050-1053)

問題:用 t 條執行緒平行計算 0+1++(n1)(有閉式解 n(n1)/2 可驗證結果)。作法:把序列切成 t 個不相交區域,每條執行緒負責 n/t 個元素;main thread 傳給每條 peer thread 一個小整數 thread ID(myid[i] = i 再傳 &myid[i]),thread 用 ID 算出自己的區間 start = myid * nelems_per_threadend = start + nelems_per_thread。這種「傳唯一 thread ID」是平行程式的通用技巧。

版本 累加位置 同步方式 特徵
psum-mutex 共享全域變數 gsum 每次更新都 P(&mutex) / V(&mutex) 慢到災難等級,加核心反而更慢
psum-array 全域陣列 psum[myid] 無(每 thread 專屬元素) 快數個數量級
psum-local 區域變數 sum,最後才寫 psum[myid] 再快一個數量級

四核心、n=231 的實測執行時間(秒):

版本 \ 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)。

看似微小的程式碼改動會對效能造成巨大影響——寫平行程式是 tricky 的;而且 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)
Threads t 1 2 4 8 16
Cores p 1 2 4 4 4
Tp (s) 1.06 0.54 0.28 0.29 0.30
Sp 1 1.9 3.8 3.7 3.5
Ep 100% 98% 95% 91% 88%
比較 Strong scaling Weak scaling
問題大小 固定 隨核心數等比放大
目標 固定工作量盡快做完 用更大的機器做更多工作
適用 問題規模不易擴大者(如 real-time signal processing,工作量由實體 sensor 決定) 科學計算(問題可放大、越大預測越準),通常是更真實的指標
Amdahl's Law 連結

加速比受程式中無法平行化部分的限制:S=1(1α)+α/k(可平行比例 α、加速倍數 k),上限 S=1/(1α)。詳見 01-Computer-Systems-Tour/04-Amdahls-Law-and-Concurrency-Themes

Aside (p.1049):thread 也能寫 event-driven 程式——12.5 的 prethreaded server 本質上就是 event-driven server:main thread 有兩個狀態(等 connection request / 等 buffer slot)、兩個 I/O 事件、兩個轉移;每條 worker thread 有一個狀態(等 buffer item)。I/O multiplexing 不是 event-driven 的唯一寫法(對照 12-Concurrent-Programming/01-Process-and-Event-Based-Concurrency)。

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 的指標 ctimegethostbyname (a) 改寫成由 caller 傳入結果位址(需有 source);(b) lock-and-copy (a) 需改雙方;(b) 同步變慢、複雜結構需 deep copy
4 呼叫 thread-unsafe function 的函式 任何呼叫 rand 的 f 視被呼叫者的 class 而定(見下)

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 互斥
類型 條件 可否僅看程式碼判定
Explicitly reentrant 所有參數 pass by value(無指標)且只存取區域自動(stack)變數 可——無論怎麼被呼叫都 reentrant
Implicitly reentrant 允許部分參數 pass by reference(傳指標) 否——僅當呼叫的 threads 小心地傳入非共享資料的指標時才 reentrant(rand_r 屬此類)
Reentrancy 有時是 caller 與 callee 共同的屬性,不只是 callee 自己的事:implicitly reentrant 函式若被傳入指向共享資料的指標,就不再 reentrant。書中的 "reentrant" 一詞同時涵蓋 explicit 與 implicit 兩種。

12.7.3 在多執行緒程式中使用既有函式庫 (p.1060-1061)

大多數 Linux/標準 C 函式庫函式(mallocfreereallocprintfscanf⋯)都是 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

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 排程,不可預期

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 可得三個洞見:

Mutex lock ordering rule(防死結法則)

給定所有 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)。

此規則適用於以 binary semaphore(mutex)做互斥的情境;一般性的死結預防是困難問題(程式因各種原因死結)。另外,即使初始值設定不當(如 Practice Problem 12.15 的 s=1, t=0,兩 thread 都會卡在 P(t)),也會造成必然死結——修法是把 t 的初值改為 1。

12.8 Summary (p.1066)

機制 排程者 位址空間 資料共享
Processes kernel 自動 各自獨立的 virtual address space 需要顯式 IPC 機制
I/O multiplexing(event-driven) 程式自己顯式排程(flows 模型化為 state machines) 單一 process 快速、容易
Threads kernel 自動(像 process) 單一 process(像 I/O multiplexing) 快速、容易

Exam/Test Patterns

情境 / 關鍵字 答案
parallel、concurrent、sequential 三者關係 parallel ⊊ concurrent;sequential 與 concurrent 互斥且窮盡所有程式
psum-mutex 為何多執行緒反而更慢 P/V 同步操作成本遠高於一次記憶體更新;synchronization overhead 未被有用計算攤提
T1,Tp,p 求 speedup / efficiency Sp=T1/Tp;Ep=Sp/p=T1/(pTp)(如 Problem 12.11:T1=16,T4=8,T8=4S4=2,E4=50%;S8=4,E8=50%)
absolute vs. relative speedup 哪個較真實 absolute(T1 取循序版);relative 會因平行版單核也有同步開銷而膨脹數字
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)