線程基礎之數據競爭與鎖

杜老師說 2022-01-07 13:31:07 阅读数:444

原文地址    譯文地址   譯者: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),然後再第二個線程中等待該變量被設置(可能用一個簡單的循環)。編程語言標准通常用這種方式來定義數據競爭。

原創文章,轉載請注明: 轉載自並發編程網 – ifeve.com本文鏈接地址: 線程基礎之數據競爭與鎖

FavoriteLoading添加本文到我的收藏
版权声明:本文为[杜老師說]所创,转载请带上原文链接,感谢。 https://gsmany.com/2022/01/202201071331073643.html