目錄

平行程式設計模型:Shared Memory Model 與 Race Condition 陷阱

知道什麼是平行程式設計、以及為什麼需要它之後,接下來要處理的問題是「實際上怎麼寫」。平行程式設計有三種常見的模型 (Parallel Programming Model),每一種的實作方式與適用場景都不太一樣。本篇文章介紹其中第一種:Shared Memory Model。

內容會從 Shared Memory Model 的記憶體結構講起,接著用一個「把陣列元素加總」的小例子,帶出這個模型最容易踩到的雷 —— Race Condition,最後說明怎麼用 Lock 與 Critical Section 把它擋掉。

如果你在程式中用過 Multi-Threading 的技巧,那你對 Shared Memory 的概念一定不陌生。用 Shared Memory Model 開發平行程式時,寫出來的通常就是所謂的「Multi-Thread Program」。

它的基本想法很單純:把 Program 中性質相同的 Task 拆給不同的 Thread 執行,這些 Thread 可以同時被不同的 Core 跑起來,Program 整體的執行時間因此縮短。原本要跑 8 秒的工作,理想上分給 2 個 Thread 之後大約 4 秒就能收工。

如果你對 Thread 與 Process 的區別還很陌生,可以參考 Program / Process / Thread 差異這篇說明。在這裡我們可以先把一個 Program 單純視為 Main Memory 中的一個 Process。在 Multi-Thread Program 中,這個 Process 底下包含了許多 Thread,每個 Thread 有自己的 Private Memory(例如 Local Stack Variable),所有 Thread 之間則共用一塊 Shared Memory(例如 Static Variable 與 Global Heap)。

Shared Memory Model 的記憶體架構示意圖,多個 Processor 各自連著自己的 Memory,同時共同連到一塊 Shared Memory。
在 Shared Memory Model 之下,每一個 Processor 除了有自己的 Memory 外,也會有一塊共用的 Memory

如上圖所示,假設每個 Processor 正在執行不同的 Thread,這些 Thread 有自己的 Private Memory 存放只有自己需要知道的資訊;同時也有一塊 Shared Memory 可以互相分享資訊。

這裡有個關鍵的觀念:在 Shared Memory Model 中,Thread 之間是用「Implicit(隱性)」的方式溝通的。程式碼裡我們不會直接叫某個 Thread 去 Send 或 Receive Data,而是各自對 Shared Memory 做讀取 (Read) 與寫入 (Write),資訊就這樣傳遞過去了。換個說法,共享記憶體本身就是通訊管道。

接著用一個簡單的問題與程式碼,把上面的概念以及它可能帶來的麻煩具體化。

一個含有 8 個元素的一維陣列示意圖。
假設有一個 Array,Array 中有 8 個元素

如上圖所示,假設我們現在有一個 Array,裡面有 8 個元素。每個元素都要先丟進 Function f 運算,再把全部的結果加總起來。

陣列被切成前後兩半,前 4 個元素交給 Thread 1、後 4 個元素交給 Thread 2 的分工示意圖。
將前面 4 個元素分給 Thread 1;將後面 4 個元素分給 Thread 2

為了減少 Program 的運行時間,我們在 Program 中建立 2 個 Thread,各自負責 4 個元素。Thread 1 與 Thread 2 會有自己的 (Private) Local Variable 紀錄這 4 個元素運算後的小計;另外還有一個 (Shared) Static Variable 紀錄整體的總和。

Multi-Thread 加總的程式碼片段,以藍色框標示出 fork 建立新 Thread 的部分,以及兩個 Thread 共同執行的 sum function。
程式碼範例 [source: Parallel Programming Course from NYCU]

若把上面的描述寫成程式碼,結構大致就像上圖。先看藍色框框的部分:透過 fork Function 建立一個新的 Thread (Thread 1),指定它要執行的 Function (sum),並把這個 Thread 負責的 Array 元素 (a[0 : n/2 - 1]) 傳進去。下一行則是原來的 Main Thread 自己執行 sum Function,負責 Array 後半部的元素 (a[n/2 : n-1])。

這樣就形成了 Thread 1 與 Thread 2,兩者執行的都是同一個 sum Function。sum 的內容主要是一個 for loop,把每個元素丟進 f Function 之後,再累加到 static variable s 裡面(s 是 Thread 1 與 Thread 2 都可以讀取與寫入的共享變數)。

看起來沒問題,但實際執行上面的程式碼,會發現結果不一定正確 —— 而且每次跑出來的數字還可能不一樣。

原因在於 Thread 1 與 Thread 2 是被不同的 Core 同時執行的。如果 Thread 1 正在讀取 s 變數的當下,Thread 2 剛好對 s 寫入,最後的結果就會出錯。這種因為多個 Core 對同一塊記憶體空間讀寫的時序交錯而造成的錯誤,稱為 Race Condition。

舉個具體的例子。假設目前 s = 16:

  1. Thread 1 讀到 s = 16,準備把 f(A[i]) 的結果與 16 相加後寫回 s。
  2. 就在 Thread 1 寫回去之前,Thread 2 已經先把它算出的新結果寫進 s,此時 s = 20。
  3. Thread 1 完全不知道這件事,它手上握的仍然是 16,於是用 16 去算並覆蓋回 s。

Thread 2 那次寫入等於憑空蒸發,後面的運算自然也就不正確了。麻煩的地方在於,這種錯誤取決於兩個 Thread 的執行時序,所以它是間歇性的:測試時可能跑十次對九次,上線之後才偶爾爆掉。

為了解決 Multi-Thread Program 中的 Race Condition,我們可以透過 Lock 機制,在 Program 中建立 Critical Section。

改良後的程式碼片段,每個 Thread 先用 local_s1、local_s2 各自累加,最後再以 lock 包住寫回共享變數 s 的那一行,圖中並有一行紅字提問。
建立 Critical Section 避免 Race Condition [source: Parallel Programming Course from NYCU]

如上圖程式碼所示,第一步是「減少」Race Condition 發生的機率:在每個 Thread 中建立自己的 Local Variable(例如 local_s1 與 local_s2)存放自己算出來的小計,最後才把小計與 s 變數相加並寫回 s。這樣一來,原本 for loop 裡每一輪都要碰共享變數,現在整個 Thread 只碰一次。

不過機率變小不等於不會發生。「把自己算出來的總和與 s 相加並寫回 s」這個動作本身,依然可能撞上另一個 Thread 的同一個動作。

所以第二步,是用 lock 機制把這段過程變成一個 Critical Section,才能徹底根絕 Race Condition。一段程式碼一旦成為 Critical Section,同一時間就只會有一個 Thread 被允許執行它,其他 Thread 必須等前一個離開才能進去,交錯讀寫的情況也就不可能出現了。

最後,上圖中還有一行紅字:「Why not do lock inside the loop ?」意思是說,為什麼不乾脆回到最原始的版本(也就是沒有 local_s1 與 local_s2 的那一版),直接在 for loop 裡 s = s + f(A[i]) 這行的前後加上 Lock?這樣同樣可以避免 Race Condition。

答案是成本。Lock 的機制是透過 System Call 實作的,而 System Call 是一項高成本的指令。如果把它放進迴圈裡,等於每處理一個元素就要付一次這個成本;呼叫次數一多,額外的時間開銷反而可能把 Multi-Thread 帶來的好處整個抵銷掉。把 Lock 移到迴圈外面、只鎖真正需要保護的那一行,才是划算的做法。

本篇文章介紹了 Parallel Programming Model 中的第一種 —— Shared Memory Model:Thread 之間透過共用的記憶體隱性溝通,寫起來直觀,但也因此容易踩到 Race Condition。

解法上,最基本的做法是用 Lock 機制圈出 Critical Section,確保同一時間只有一個 Thread 能碰到共享資料。同時也別忘了 Lock 本身是有成本的,鎖的粒度放在哪裡,往往直接決定了平行化到底有沒有賺到。