臨界區的實現

以下文字摘自《Windows 併發編程指南》,版權歸原作者所有,僅供學習和研究參考。

對於任何良好的臨界域實現方式來說,都存在一系列的需求。

1.  保持互斥性,無論在什麼情況下,只能有一個線程可以進入臨界域。

2.  保證進入臨界域和退出臨界域等操作的活躍性(Liveness)。系統可以不斷地向前推進,這意味着這個算法既不能產生死鎖,也不能產生活鎖。具體來說,如果給定無限長的時間,那麼每個到達臨界域的線程都將進入臨界域,沒有任何線程會無限期地停留在臨界域中。

3.  提供某種程度的公平性,例如根據線程到達臨界域的時間來確定它相對於其他線程的優先級。雖然這並不意味着必須存在某種確定的公平性保證(例如先進先出),但臨界域經常會努力實現某種程度的公平性。

4.  另一個主觀意義上的標準就是低開銷。在進入臨界域和離開臨界域時保持較低的開銷是非常重要的。在底層的系統軟件中經常會大量地使用臨界域,例如操作系統,因此在臨界域的實現效率上存在着很大的壓力。

可以採用多種方法來實現臨界域。不過,目前所有的互斥機制都依賴於一組原子的比較和交換(Compare  and Swap, CAS)硬件指令以及操作系統的支持。但在介紹這些指令之前,我們首先看看爲什麼需要硬件來提供支持。換句話說,通過我們熟悉的串行編程結構來實現EnterCriticalRegion和LeaveCriticalRegion難道不是很容易嗎?

這種最簡單,最初級的方法根本就不可行。我們可以設計一個標誌變量,初始值爲0,當有線程進入到臨界域時將這個變量設置爲1,而在離開臨界域時設置爲0,。每個試圖進入臨界域的線程都將首先檢查這個標誌,如果這個標誌爲0,這進入臨界域並且將標誌設置爲1.

int   taken = 0;

void EnterCriticalRegion()
{
    while(taken != 0)   // 忙等待
    taken = 1;             //表示臨界域已經被獲取了
}

void  LeaveCriticalRegion()
{
       taken = 0;    //表示臨界域已經被釋放了
}
這種實現方式非常糟糕。原因在於,在算法中使用的一系列讀操作和寫操作都不是原子的。假設有兩個線程在讀取taken時都獲得了0,並且根據這個信息都決定將1寫入taken。這兩個線程都會認爲自己獲得了這個臨界域,但它們卻在同時運行臨界域中的代碼。這正是我們最初使用臨界域所要避免發生的事情。

在分析現有的技術之前,即在現代臨界域中使用的技術,我們將首先回顧過去40多年中在互斥的實現方式上經歷的曲折發展道路

嚴格交替

我們首先可能會嘗試通過嚴格交替(Strict  Alteration) 來實現互斥行爲,首先將所有權交給線程0,然後線程0在執行完時再把所有權交個線程1,而線程1在執行完成時又接着把所有權交給線程2,以此類推,直到N個線程,最後由線程N-1將所有權交回給線程0,此時就完成了對臨界域中代碼的執行。這種方式的實現形式可能像下面的代碼這樣:

const  int  N= ....;        //系統中線程的編號
int  turn  = 0;        //線程0首先執行

void   EnterCriticalRegion(int i)
{
     while(turn != i)    // 忙等待
     // 其他某個線程將所有權交給了我們,現在我們擁有臨界域
}

void  LeaveCriticalRegion(int i)
{
    //將所有權交回給下一個線程(可能交回到線程0)
   turn = (i+1) % N;
}

這個算法能夠實現N個併發線程在使用臨界域時的互斥性。在這種模式中,每個線程都有一個唯一的標識,取值範圍爲【0....N】,這個標識將被作爲參數i的值傳遞給EnterCriticalRegion。變量turn表示當前哪個線程被允許在臨界域中執行,並且當線程試圖進入臨界域時,它必須等待另一個線程將所有權交給它,在上面的示例中使用的是忙等待。在這個算法中,我們必須指定哪個線程最先執行,在示例中選擇線程0首先執行並且在外部將turn初始化爲0.在離開臨界域時,每個線程都要通知下一個線程開始執行:設置相應的turn,如果達到了線程的最大數量,則將turn回捲爲0,否則將turn遞增1。

在嚴格交替中存在一個問題:在決定將臨界域的所有權交給某個線程時,並沒有考慮線程到達臨界域的時間。相反的是,在算法中存在一種預定義的順序:首先是0,然後是1,---,N-1,然後又回到0,等等。這種順序是固定的,不可修改。這種順序不可能是公平的,它經常導致一個不在臨界域中的線程阻礙另一個線程進入臨界域。這可能會降低系統的活躍性,但卻會降低程序的性能和可伸縮性。只有當線程有規律地進入臨界域和退出臨界域時,這個算法才能發揮作用。另一個問題是,在臨界域中不能容納可變數量的線程。我們很難知道有多少個線程將進入某個臨界域,並且更難知道線程的數量在進程的生命週期中是否保持不變。

Dekker和Dijkstra提出的算法(1965)

在第一個被廣泛接受的互斥問題解決方案中並沒有使用嚴格交替,它是由一位讀者在迴應E.W.Dijkstra於1965年發表的一篇文章時提出的,Dijkstra在這篇文章中提出了互斥問題,並且尋求對這個問題的解決方案。一個叫做T.Dekker的讀者提交了一個解決方案能夠滿足Dijkstra的標準,但只能適用於兩個併發的線程。這種算法稱爲“Dekker算法”,Dijkstra隨後在同年發佈的另一篇文章中擴展了這種算法使其可以適用於N個線程。

Dekker提出的解決方案與嚴格交替是類似的,都需要分配次序,但在這個解決方案中,每個線程可以表示是否希望進入臨界域。當一個線程想要進入臨界域但卻還沒有輪到它的次序時,它可以“竊取”其他不打算進入臨界域(即不在臨界域中)的線程的次序。

在下面的示例中定義了一個共享的布爾數組flags,這個數組包含兩個元素並且都被初始化爲false。當線程希望進入臨界域時,它將true保存到相應位置的元素中(線程0使用下標爲0的元素,線程1使用下標爲1的元素),並且在退出時將false寫入這個元素。當且僅當線程希望進入臨界域時才執行這些操作。這種方式是可行的,因爲線程首先寫入到一個共享的數組flags,然後判斷另一個線程是否也已經寫入到數組flags。我們可以確保,如果將true寫入flags,然後從另一個線程對應的元素中讀取false,那麼另一個線程將看到這個true值。(注意,在現代處理器中是以亂序方式來執行讀操作和寫操作,這種執行方式將破壞這種假設。)

在算法中必須處理這兩個線程同時進入臨界域的情況。算法通過共享變量turn來做出決策,正如在前面已經看到的情形。就像在嚴格交替中,當兩個線程都希望進入臨界域時,只有當其中一個線程看到turn等於它自己的索引時,纔會進入到臨界域,而其他的線程將不再希望進入(也就是,它在flags中元素的值爲false)。如果某個線程發現兩個線程都希望進入但卻還沒有輪到它的次序,那麼這個線程將“後退”並且將它的flags元素設置爲false,然後等待turn 發生變化。這就使另一個線程進入臨界域。當線程離開臨界域時,它只是將對應的flags元素重置爲false,並且修改turn。

下面的代碼給出了完整的算法

static bool[] flags = new bool[2];
static int turn = 0;

void EnterCriticalRegion(int i)   // i只能是0或者1
{
       int  j = 1-i;        //另一個線程在flags中的索引
       flags[i] = true;   // 表示希望進入臨界域
       while(flags[j])   //等待直到另一個線程不希望進入臨界域
      {
            if(turn == j)  //還沒有輪到我們,因此必須後退並且等待
            {
                  flags[i] = false;
                  while(turn == j)    // 忙等待
                  flags[i] = true;
             }
      }
}

void LeaveCriticalRegion(int i)
{
    turn = 1-i;          // 讓出次序
    flags[i] = false;  // 退出臨界域
}

Dijkstra對這個算法進行了修改以支持N個線程。雖然這個算法仍然要求N是一個確定值,但它能夠適用於線程數量小於N的系統,從而使得算法更爲適用。

這個算法與Deckker的算法略微不同。首先定義一個大小爲N的數組flags,但數組中的元素不是布爾類型而是一種三值(Tri-Value)類型。每個元素的值都可以是以下三個值之一: passive,表示這個線程此時不打算進入臨界域;requesting,表示這個線程正在嘗試進入臨界域;active,表示這個線程當時正在臨界域中執行。

線程在到達臨界域時,它將把數組flags中的對應元素設置爲requesting以表示希望進入臨界域。然後,它將嘗試“竊取”當前的次序:如果當前的次序被分配給一個並不打算進入臨界域的線程,那麼這個已達到的線程將把turn設置爲它自己的索引。一旦這個線程獲得了turn,那麼它將把狀態設置爲active。然而,在線程繼續執行之前,它必須驗證沒有其他的線程在同時也竊取了次序並且可能已經進入到了臨界域,否則將破壞互斥行爲。具體的驗證方式就是確保沒有其他線程的標誌是active。如果發現了另一個活躍的線程,那麼這個已到達的線程將後退並且回到requesting狀態,並且直到它能進入臨界域才繼續執行。當線程離開臨界域時,它會將相應的標誌設置爲passive。

下面是一個用C#編寫的示例實現。

const  int  N = ...;  // 可以進入臨界域的線程編號
enum  F : int
{
    Passive,
    Requesting,
    Active
}

F[]  flags = new new F[N]; // 所有元素被初始化爲passive
int  turn = 0;
 
void   EnterCriticalRegion(int i)
{
     int j;
     do
     {
           flags [ i] = F.Requesting;  // 表示希望進入臨界域
           while(turn != i)  // 保持自旋,直到輪到自己的次序
                  if(flags[turn] == F.Passive)
                  turn = i;   // 竊取次序
                  flags[i]  = F.Active;   // 宣告正在進入臨界域
                  //確保沒有其他的線程已經進入到臨界域     
                  for(j = 0;  j<N &&(j == i || flags[j]  !=  F.Active); j++);
     }
    while (j<N);
} 

void LeaveCriticalRegion(int i)
{
      flags[i]  = F.Passive  // 表示離開臨界域
}

Peterson提出的算法(1981)

在Dekker算法發佈了大約16年之後,G.L。Peterson提出了一種簡化算法,並且在它的文章”Myths  about  Mutual  Exclusion“ 中給出了詳細的描述。這種算法簡稱爲Peterson算法。在不到兩頁的篇幅中,他給出了一個適用於兩個線程的算法,以及一個適用於N線程的算法,這兩個算法都比Dekker和Dijkstra最初提出的算法要簡單。

爲了簡單起見,我們這裏只介紹適用於兩個線程的算法。共享變量都是相同的,包括一個flags數組和一個turn變量,這與Dekker算法是一樣的。然而,與Dekker算法不同的是,當線程希望進入臨界域時,它將首先把flags中對應的元素設置爲true,然後立即把turn讓給其他的線程。接下來,這個線程將等待另一個線程不在臨界域中或者知道turn值重新回到這個線程。

bool[]   flags = new bool[2];
int  turn = 0;

void EnterCriticalRegion(int i)
{
     flags[i] = true;   // 表示希望進入臨界域
     turn  = 1-i;
     //等待直到臨界域可用或者輪到了我們的次序
     whle(flags[1-i] && turn != i)  // 忙等待
}

void  LeaveCriticalRegion(int i)
{
     flags[i] = false;  // 退出臨界域
}

與Dekker算法一樣,Peterson算法同樣盲足所有基本的互斥性、公平性以及活躍性。但這個算法更爲簡單,因此在講解互斥時比Dekker算法用得更多。

Lamport提出的Bakery算法(1974)

L.Lamport同樣提出了一種算法,稱爲Bekery算法。這種算法能夠很好地適用於不同數量線程的情況,此外這個算法的好處在於,如果某個線程在進入臨界域或者退出臨界域發生失敗,那麼並不會破壞系統的活躍性,而在之前介紹的其他算法中都存在這個問題。所要求的就是,這個線程必須將它的票號(Ticket Number)重新設置爲0並且移動到非臨界域。Lamport希望將它的算法應用與分佈式系統中,因爲在任何一個分佈式系統算法中,容錯(Fault  Tolerance)都是一個非常關鍵的屬性。

這個算法稱爲Bakery(麪包店)算法,主要是因爲它的工作原理有點類似於麪包店的運作方式。當一個線程到達時,它將獲得一個票號,只有當這個線程的票號被叫到時(或者更準確地說,只有當那些票號更小的線程都已經執行完畢後),這個線程才被允許進入臨界域。這個算法能夠正確地處理邊界情況:多個線程通過線程之間的某種排序方式被分配到了同一個票號-------例如,唯一的線程標識、名字或者其他可進行比較的屬性-------這種情況將破壞平衡。以下是這個算法的示例實現。

const int N= ....;   //可以進入臨界域的編程編號
int[]   choosing = new int[N];
int[]   number = new int[N];

void EnterCriticalRegion(int i)
{
     // 讓其他線程知道正在選擇一個票號
     // 然後找到當前最大的票號並且加1
     choosing[i]  = 1;
     int m = 0;
     for(int j = 0; j<N; j++)
     {
           int jn = number[j];
           m = jn > m? jn : m;
      }
      number[i]  = 1+ m;
      choosing[i] = 0;

 for(int j=0; j<N; j++)
{
    //等待線程完成選擇
    while(choosing[j] != 0)  //忙等待
    //等待擁有更低票號的線程執行完成。如果獲得了與另一個線程相同的票號,
    // 那麼ID值最小的線程將首先進入

     int jn;
     while((jn = number[j]) != 0 && (jn == number[i] && j<i)))       
     //忙等待
}
  //叫到我們的票號,進入臨界域....
}

void LeaveCriticalRegion( int i)
{
    number[i]  = 0;
}




發佈了25 篇原創文章 · 獲贊 16 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章