在實際的軟體編程中,經常會遇到資源的爭用,比如下面的例子:
class Counter{ private: int value; public: Counter(int c) { value = c; } int GetAndIncrement() { int temp = value; //進入危險區 value = temp +1; //離開危險區 return value; } }
這種實現在單線程系統中能夠正常工作,但是在多線程系統則有可能出錯。比如有2個線程,初始狀態value=0。第一個線程運行完第9行,這時temp=0。突然一個中斷來了,切換到第二個線程運行了,第二個線程運行完第9行也是temp=0,然後執行第10行賦值value=1。然後回到第一個線程繼續運行第10行對value進行寫覆蓋,結果value=1.而正確的情況應該是value=2了。
為什麼會產生這樣的情況呢?這時因為兩個線程同時對一個資源value進行爭用產生了衝突。為了避免上述情況,我們可以將這兩行置入臨界區內:某個時刻內僅能被一個線程執行的程式碼片段。從而實現互斥。對Counter類的增加對臨界區的互斥訪問:
class Counter { private: int value; lock lock; public: Counter(int c) { value = c; } int GetAndIncrement() { lock.lock();//擷取鎖 int temp = value; //進入臨界區 value = temp +1; //離開臨界區 lock.unlock();//釋放鎖 return value; } }
通過在程式中為了使用Lock域來保證對象的互斥特性,必須對稱的調用lock()和unlock()。需要滿足如下條件:
1. 一個臨界區之和一個lock對關聯。
2. 線程進入臨界區前調用lock()。
3. 線程離開臨界區後調用unlock().
編程的架構如下:
lock()
臨界區
unlock()
打個不是十分妥帖的比喻,就像是有一個倉庫資源,但是有多個人想去倉庫做點事情。這時候倉庫只需要一把鎖(鎖多了純粹是浪費^_^),初始狀態倉庫上的鎖是開啟的。每個人進去之前先把鎖鎖住(避免別的人進來),然後自己在倉庫裡搗弄,離開時再把倉庫的鎖開啟,讓別人可以進來。
接下來更加深入的是如何?互斥鎖呢?也就是lock()和unlock()方法。
class Lock{ public: virtual void lock() = 0; //進入臨界區前 virtual void unlock() = 0; //離開臨界區後}
互斥鎖需要滿足三個條件:
互斥 不同線程的臨界區沒有重疊
無死結 如果一個線程正在嘗試獲得一個鎖,那麼總會成功地獲得這個鎖。若線程A調用lock()但是無法獲得鎖,則一定存在其他線程正在無窮次地執行臨界區。
無饑餓 每一個試圖獲得鎖的線程最終都能成功。
首先看雙線程的互斥,首先從兩個存在不足(如果大家能不看後面的分析也能知道哪裡不足就更厲害了^_^),但十分有趣的鎖演算法說起:
LockOne類
這個類有一個標誌數組flag,繼續來個比喻,這個flag就相當於一個旗幟。LockOne類遵循這樣的協議:
1. 如果線程想進入臨界區,首先把自己的旗幟升起來(flag相應位置1),表示感興趣。然後等對方的旗幟降下來就可以進入臨界區了。
2. 如果線程離開臨界區,則把自己的旗幟降下來。
class LockOne: public Lock { private: bool flag[2]; public: void lock() { int i = ThreadID.get(); int j = 1-i; flag[i] = true; while(flag[j]); } void unlock() { int i = ThreadID.get(); flag[i] = false; } }
LockOne類的協議看起來挺樸實的,但是存在一個問題:當兩個線程都把旗幟升起來,然後等待對方的旗幟降下來就會出現死結的狀態(兩個線程都在那傻乎乎的等待對方的旗幟降下來,直到天荒地老:))
LockTwo類
觀察LockOne類存在的問題,就是在兩個線程同時升起旗幟的時候,需要有一個線程妥協吧,這樣就需要指定一個犧牲品,因此LockTwo類橫空出世。
class LockTwo: public Lock { private: int victim; public: void lock() { int i = ThreadID.get(); victim = i; //讓別人先走,暫時犧牲自己 while(victim == i); } void unlock(){] }
當兩個線程進行競爭的時候,總有一個犧牲品(較晚對victim賦值的線程),因此可以避免死結。但是,當沒有競爭的時候就杯具了,如果只有一個線程想進入臨界區,那麼犧牲品一直是自己,直到等待別人來替換自己才行。
Perterson鎖
通過上面兩個類可以發現,LockOne類適合沒有競爭的情境,LockTwo類適合有競爭的情境。那麼將LockOne類和LockTwo類結合起來,就可以構造出一種很好的鎖演算法。該演算法無疑是最簡潔、最完美的雙線程互斥演算法,按照其發明者的名字被命名為“Peterson演算法”。
class Peterson: public Lock{ private: bool flag[2]; int victim; public: void lock() { int i = ThreadID.get(); int j = 1-i; flag[i] = true; victim = i; while(flag[j] && victim==i); } void unlock() { int i = ThreadID.get(); flag[i] = false; }}
Perterson鎖是滿足互斥特性的。通過反證法來說明,如果兩個線程都想進入臨界區,但是都成功進入了。因為兩個線程都想進入,則說明flag對應位均為1,然後因為都能lock()成功,說明victim均不是自己。這和victim是其中之一矛盾。
但是,實際中線程不可能只有2個,接下來需要看看支援n線程的互斥協議。
Barkey鎖
有一種協議稱為Bakery鎖,是一種最簡單也最為人們鎖熟知的n線程鎖演算法。下面看看到底是神馬情況。思想很簡單,還是打個簡單的比喻來說明器協議:
1. 每個線程想進入臨界區之前都會升起自己的旗幟,並得到一個序號。然後升起旗幟的線程中序號最小的線程才能進入臨界區。
2. 每個線程離開臨界區的時候降下自己的旗幟。
class Bakery: public Lock{ private: bool flag[]; Label label[]; public: Bakery (int n) { flag = new bool[n]; label = new Label[n]; for(int i=0; i<n; i++) { flag[i] = flase; label[i] = 0; } void Lock() { int i = ThreadID.get(); flag[i] = true; label[i] = max(label[0], ..., label[n-1]) +1; while((exist k!=i)(flag[k] && (label[k],k)<<(label[i], i)) } void unlock() { flag[TheadID.get()] = false; } }}
首先,Barkey演算法是無死結的。因為正在等待的線程中(類似於所有升起旗幟flag的線程中),必定存在一個最小的序號label。該線程可以進入臨界區。
其次,Barkey演算法是先來先服務的。因此先來的線程,分到的label比較小。
最後,Barkey演算法是互斥的。如果兩個線程同時位於臨界區,則兩個線程都已經升起旗幟,同時label都是最小的,矛盾。
很重要的點是要實現一個n線程的互斥鎖,必須至少使用n個儲存單元。因為若此刻有某個線程正在臨界區內,而鎖的狀態卻與一個沒有線程在臨界區或正在臨界區的全域狀態相符,則狀態不一致。即每個線程共有2個狀態,則n個線程共有2^n個狀態,共需要n個儲存空間記錄全域狀態。