執行緒基礎之資料競爭與鎖
原文地址 譯文地址 譯者:Alpha ; 校對: 蘑菇街-小寶
大多數現代多執行緒程式語言都可以避免順序一致性與效能之間的衝突,因為它們知道:
- 順序一致性的問題是由於某些程式轉換引起的,例如我們的例子中交換了無關變數的訪問順序,這不會改變單執行緒程式的意圖,但是會改變多執行緒程式的意圖(例如例子中允許r1和r2都為0)。
- 只有當代碼允許兩個執行緒同時訪問相同的共享資料,並且是以某種衝突的方式訪問時(例如當一個執行緒讀取資料的同時另一個執行緒寫入該資料),才有可能察覺到這種程式轉換。如果程式強制以特定順序來訪問共享變數,那麼我們就無法判斷對獨立變數的訪問是否被重排序,就如同在單執行緒程式中也無法判斷。
- 無限制地同時訪問普通共享變數會讓程式變得難以處理,一般需要避免這種情況。堅持完全的順序一致性對我們沒有好處。我們將在下文用單獨的一節來討論這個問題。
因此程式語言通常會提供方便的機制來限制通過不同的執行緒同時訪問變數,並且僅當不存在不受控的併發訪問時(例如我們的示例程式)才保證順序一致性。更準確地說:
如果兩個普通記憶體操作訪問相同的記憶體位置(例如變數、陣列元素),並且至少一個記憶體操作寫入該儲存單元,則稱這兩個記憶體操作是衝突的。
如果程式存在順序一致的執行,則稱其允許在特定輸入集合上有資料競爭(data race),即線上程操作的交錯執行中兩個衝突的操作可以“同時”執行。為了我們的目的,如果兩個操作在交錯執行中相鄰,並對應不同的執行緒,則這兩個操作可以被“同時”執行。如果兩個對應不同執行緒的常規記憶體操作在交錯執行中相鄰,我們知道如果他們以相反順序執行也會得到相同結果;如果一些操作強制了順序,則他們就必須要在交錯執行中間出現。因此模型中的相鄰實際上意味著他們本應該在真正的併發模型中同時發生。
僅當程式避免了資料競爭時我們才保證順序一致性。
這種保證是Java和下一代C++標準的執行緒程式設計模型的核心。
注意本文簡介中的例子確實有資料競爭,至少當變數x和y是普通整型變數時是這樣。
大多數現代程式語言都提供了簡單的方法來指定同步變數(synchronization variables)用於線上程間通訊,同步變數是特意用來進行併發訪問的。這種形式的併發訪問不被認為是資料競爭。換言之,只要當衝突的併發訪問僅發生在同步變數上時,就能確保順序一致性。
所以如果把我們例子中的x和y都設為同步變數,那麼程式就能保證程式按預期執行。在Java中,可以將它們宣告為volatile int。
將變數宣告為同步變數不僅保證了變數能被不可分割地訪問,還能阻止編譯器和硬體以對程式可見的方式對記憶體訪問進行重排序。這就能防止上文例子中出現r1 = r2 = 0這種結果。另一個更常見的例子如下,該例中只有標誌位x_init(初始值為false)是同步變數:
Thread 1 | Thread 2 |
x = 42; x_init = true; |
while(!x_init) {} r1 = x; |
這裡的思路是執行緒1初始化x,在實際程式中,可能比僅僅賦值為42更為複雜。然後設定x_init標誌位表明它已經完成了賦值。另一個執行緒2將等待x_init標誌位被設定,然後就能知道x已被初始化。
雖然x是一個普通變數,但這個程式中沒有資料競爭。執行緒2能保證直到執行緒1完成並設定了x_init之後才會執行第二條語句。所以交錯執行中不可能出現x = 42和r1 = x相鄰的情況。
這意味著我們確保了順序一致的執行,即保證r1 = 42。為了保證這一點,實現時必須讓執行緒1對x和x_init的賦值操作對其他執行緒按照順序可見,並且只有當設定了x_init之後執行緒2才能開始r1 = x操作。在許多機器架構中,這兩點都要求編譯器遵循額外的約束,生成特殊的程式碼來防止潛在的硬體優化,例如防止執行緒1因為先訪問到x_init的記憶體就在對x賦值之前先對x_init賦值。
鎖
實際上,大多數情況下不會通過將普通變數變為同步變數來避免資料競爭,而是通過防止對普通變數的併發訪問來避免資料競爭。這通常通過引入鎖(有時稱為互斥鎖)來實現,鎖是程式語言或支援庫提供的特殊同步變數。例如,如果我們想維護一個共享計數器變數c,它的值可以被多個執行緒遞增,並在執行緒結束時讀取其值。我們可以引入一個對應的鎖l,編寫如下程式碼:
void increment_c() { l.lock(); ++c; l.unlock(); }
lock()和unlock()的實現確保了在這兩個呼叫之間同時至多隻能有一個執行緒,所以同時只有一個執行緒能訪問c。我們可以將這看做對交錯執行進行了限制:對於給定的鎖l,l.lock()和l.unlock()交替呼叫,並且初始呼叫是l.lock()。(一般情況下還有一個額外的需求:鎖必須由獲取它的那個執行緒釋放,但不總是這樣。)因此即便increment_c()被多個執行緒併發呼叫,在c也上是沒有資料競爭的。交錯執行中任何兩個對c的訪問都會至少被第一個執行緒中的unlock()和第二個執行緒中的lock()分開。
舉例說明,假設一個程式有兩個執行緒,每個執行緒都執行increment_c()。如下是一個可接受的交錯執行:
l.lock(); +c; l.unlock(); l.lock(); ++c; l.unlock();
這個交錯執行沒有資料競爭。唯一的相鄰的對應不同執行緒的操作就是中間兩步。而這兩步操作都是對同步變數鎖l進行的,所以不會參與資料競爭。
如果我們想要重排操作來產生資料競爭,可能會是如下這種交錯存取:
l.lock(); l.lock(); ++c; ++c; l.unlock(); l.unlock();
但是這種交錯執行無疑是被禁止的,因為規則要求對鎖l的獲取和釋放必須交替進行。在任何可接受的交錯執行中,第二個l.lock()只有當第一個unlock()呼叫完成後才有可能在交錯執行中出現。因此,作為唯一潛在的資料競爭,兩次++c呼叫一定會被這兩個呼叫分開。
這也闡釋了資料競爭的另一個等價描述:資料競爭要求不同執行緒對相同普通共享位置的任意兩次衝突訪問必須被這兩次訪問中的同步(synchronization )所隔離,同步確保了訪問的順序。通常這可以通過在一個執行緒中釋放鎖並在另一個執行緒中獲取該鎖來實現。但是還可以這樣實現:在一個執行緒中設定一個整型同步變數(例如Java中的volatile),然後再第二個執行緒中等待該變數被設定(可能用一個簡單的迴圈)。程式語言標準通常用這種方式來定義資料競爭。