【梳理】簡明操作系統原理 第九章 死鎖與活鎖(內附文檔高清截圖)

參考教材:
Operating Systems: Three Easy Pieces
Remzi H. Arpaci-Dusseau and Andrea C. Arpaci-Dusseau
在線閱讀:
http://pages.cs.wisc.edu/~remzi/OSTEP/
University of Wisconsin Madison 教授 Remzi Arpaci-Dusseau 認爲課本應該是免費的
————————————————————————————————————————
這是專業必修課《操作系統原理》的複習指引。
在本文的最後附有複習指導的高清截圖。需要掌握的概念在文檔截圖中以藍色標識,並用可讀性更好的字體顯示 Linux 命令和代碼。代碼部分語法高亮。
操作系統原理不是語言課,本複習指導對用到的編程語言的語法的講解也不會很細緻。如果不知道代碼中的一些關鍵字或函數的具體用法,你應該自行查找相關資料。

九 死鎖與活鎖

有學者研究了常見的開源軟件中的併發過程的Bug,它們大部分是非死鎖引起的:

在這一章,我們對併發中的錯誤進行一些稍微深入的討論。

1、違反原子性(atomicity-violation)是併發過程中的一種常見Bug。示例:
線程1:
if(thd->proc_info){
fputs(thd->proc_info, …);
}
線程2:
thd->proc_info = nullptr;
相信你已經很容易舉出問題發生的條件:如果線程1在判斷分支條件後就被打斷,轉而執行線程2,那麼proc_info就變成了空指針。再次切回線程1的時候,對空指針的解引用將報錯。
解決方法很簡單:在這些代碼前後增加獲得鎖和釋放鎖的語句。修正後的代碼在這裏略去。線程2雖然只有單條賦值語句,但是也需要用鎖來確保原子性。因爲很多時候寫操作是非原子的。如果寫的過程被調度器打斷,轉由其它線程對共享資源進行寫入,那麼接下來被打斷的線程繼續寫入時,剛纔的線程所做的更改就丟失了。這很可能會引發嚴重問題。

2、多個線程錯誤的執行順序也有概率引發嚴重問題。舉例:
線程1:
void init() {
mThread = PR_CreateThread(mMain, …);
}
線程2:
void mMain(…) {
mState = mThread->State;
}
顯然,線程2應該在線程1的第一個語句之後再執行。但是,如果線程2一被創建就立刻運行(返回的指針還沒來得及寫入到mThread),那麼線程2就是在嘗試訪問空指針或內存中的任意位置(當mThread未初始化時)。
解決方法是:引入條件變量來固定執行順序。一種修改如下:
pthread_mutex_t mtLock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t mtCond = PTHREAD_COND_INITIALIZER;
int mtInit = 0;
//Thread 1::
void init() {

mThread = PR_CreateThread(mMain, …);
// signal that the thread has been created…
pthread_mutex_lock(&mtLock);
mtInit = 1;
pthread_cond_signal(&mtCond);
pthread_mutex_unlock(&mtLock);

}
//Thread 2::
void mMain(…) {

// wait for the thread to be initialized…
pthread_mutex_lock(&mtLock);
while (mtInit == 0)
pthread_cond_wait(&mtCond, &mtLock);
pthread_mutex_unlock(&mtLock);
mState = mThread->State;

}
可見,當線程2搶先於線程1運行時,不會執行到mState = mThread->State一句,而是會在這之前就休眠。當然,mThread指針本身也可以代替指示變量mtInit。當有線程可能被等待時,沒有指示變量刻畫被等待線程是否結束的後果在第八章已經強調過了:當被等待的線程在等待的線程開始等待之前搶先結束,那麼等待的線程將無法被喚醒。

3、死鎖(deadlock),又譯為死結,計算機科學名詞。當兩個以上的運算單元,雙方都在等待對方停止執行,以取得資源,但是沒有一方提前退出時,就稱為死結。
例如:線程1持有鎖1,但線程2需要鎖1;線程2持有鎖2,但線程1需要鎖2。線程1等待線程2釋放鎖2,線程2等待線程1釋放鎖1。如果沒有干預,這兩個線程就會一直乾等下去。
可能你會覺得解決這種問題很容易:在獲取資源的順序上下功夫,讓它們都遵循特定的順序。但事實上,在龐大的計算機軟件中,依賴關係非常複雜。這是死鎖容易發生的第一個原因。例如在OS中,虛擬內存子系統要訪問文件系統,將一頁換入內存;文件系統隨後又從內存中讀出一頁。操作系統、文件系統這種大型軟件一旦出現死鎖,影響是極其嚴重的。如果系統中只有一個進程,當然不會產生死鎖。如果每個進程僅需不同的資源,也不會產生死鎖。不過這只是理想狀態,在現實中是可遇不可求的。
第二個原因是封裝(encapsulation)。有的庫本身會引發死鎖,例如Java的Vector類。看下面的語句:
Vector v1, v2;
v1.addAll(v2);
v1.addAll(v2);在執行前,會先請求需要的鎖。如果v1.addAll(v2);開始執行了,而另一個線程幾乎同時開始執行v2.addAll(v1);,則有引發死鎖的潛在風險。
Java中的Vector對所有add()、get()、set()方法都加了synchronized同步塊,但某些情況仍然需要自己處理同步。例如有個線程在遍歷某個Vector、有個線程同時在add這個Vector,依然很容易出現ConcurrentModificationException。
由於Vector類爲了預防死鎖,過多采用了相應的機制,但有的時候效果又不甚理想(還顯著影響了性能),目前Vector類已經不被建議在無需多線程同步的場景中使用。
庫本身引發死鎖,是使用庫的用戶很難預料到的。

4、如果系統中只有一個進程,當然不會產生死鎖。如果每個進程僅需求不同的資源,也不會產生死鎖。不過這只是理想狀態,在現實中是可遇不可求的。
死鎖的發生需要四個條件:
【1】互斥(mutual exclusion)。線程要求獨佔一些需要的資源(例如互斥鎖)。
【2】持有和等待(hold-and-wait)。線程們佔有了分配給它們的資源(例如已經獲得的互斥鎖),而又在等待另外的資源(例如要求另外的互斥鎖解鎖)。
【3】非搶佔(no preemption)。資源(例如:鎖)不能被強制從擁有它們的線程中剝奪。
【4】循環等待(circular wait)。存在一個關於線程的環形鏈,即鏈上的每個線程都需要下一個線程佔用的資源。
如果這四個條件有一個不滿足,那麼死鎖不會發生。所以我們從這四個條件着手去阻止死鎖。

5、避免死鎖最常見的切入點是阻止循環等待。即讓線程按照一定的順序獲得資源。一個小技巧是根據需要的資源(例如:鎖)所在的地址高低決定獲取順序。於是環形鏈變成了直線,不會發生循環等待。
但是這種方案也存在它的缺點,比如在大型系統當中,模塊隔離得非常徹底,不同模塊的研發人員之間都不清楚具體的實現細節,在這樣的情況下就很難做到整個系統層面的全局鎖排序了。不過我們可以對方案進行擴充,例如Linux在內存映射代碼就使用了一種鎖分組排序的方式來解決這個問題。鎖分組排序首先按模塊將鎖分爲了不同的組,每個組之間定義了嚴格的加鎖順序,然後再在組內對具體的鎖按規則進行排序,這樣就保證了全局的加鎖順序一致。在Linux的對應的源碼頂部,我們可以看到有非常詳盡的註釋定義了明確的鎖排序規則。
這種策略使得資源的利用率和系統吞吐量都有很大提高,但是也存在以下缺點:
(1)限制了進程對資源的請求,同時給系統中所有資源合理編號也是件困難事,並增加了系統開銷;
(2)爲了遵循按編號申請的次序,暫不使用的資源也需要提前申請,從而增加了進程對資源的佔用時間。

6、破壞保持和等待條件,是指一次性將所有的鎖都獲得。整個過程也是原子性的,於是別的線程無法在該線程獲得鎖期間也來獲得同樣的鎖,死鎖也就無法發生。如果某個線程所需的全部資源得不到滿足,則不分配任何資源,此線程暫不運行。只有當系統能夠滿足當前線程的全部資源需求時,才一次性地將所申請的資源全部分配給該線程。但是,這種策略也有如下缺點:
(1)許多情況下,一個線程在執行之前不可能知道它所需要的全部資源。這是由於線程在執行時是動態的,不可預測的;
(2)資源利用率低。無論所分資源何時用到,一個線程只有在佔有所需的全部資源後才能執行。即使有些資源最後才被該線程用到一次,但該線程在生存期間卻一直佔有它們,造成長期佔着不用的狀況。這顯然是一種極大的資源浪費;
(3)降低併發性。因爲資源有限,又加上存在浪費,能分配到所需全部資源的線程個數就必然少了。

7、如果一個線程已經獲取到了一些鎖,那麼這個線程釋放鎖之前這些鎖是不會被強制搶佔的。但是爲了防止死鎖的發生,可以讓線程在獲取後續的鎖失敗時主動放棄已經持有的鎖並在之後重新獲取,其它等待這些鎖的線程就得以繼續執行了。
同樣的,這個方案也有自己的缺陷:
(1)雖然這種方式可以避免死鎖,但是如果幾個互相存在競爭的線程不斷地放棄、重試、放棄,那麼就會導致活鎖(livelock),也稱活結。與死結相似,死結是行程都在等待對方先釋放資源(它們均因爲等待而被阻塞,即“拿不到就死等”);活結則是行程彼此釋放資源又同時佔用對方釋放的資源(即“拿不到就重試”),當此情況持續發生時,儘管資源的狀態不斷改變,但每個行程都無法取得所需資源,使得事情沒有任何進展。在這種情況下,雖然線程沒有因爲鎖衝突被卡死,有可能自行解開活鎖,但是線程仍然會被阻塞相當長的時間甚至一直處於重試當中。
一種解決方式是每次重試前添加一個隨機的延遲時間,這樣就能大大降低衝突概率了。在一些接口請求框架中也使用了這種技巧來分散服務高峯期的請求重試操作,避免過多的無用重試導致服務崩潰。
(2)還是因爲程序的封裝性,在一個模塊中難以釋放其他模塊中已經獲取到的鎖。
另外,在釋放鎖時,如果在獲得將要釋放的鎖之後還獲取了其它資源(例如申請了新的內存空間),那麼這些資源也要一併釋放。
其實這種方法也可以不算作真正的搶佔,因爲這並不是強行把線程擁有的資源奪走並且不歸還。
這種預防死鎖的方法實現起來比較困難,會降低系統性能。

8、還剩最後一個方向,就是破壞互斥條件。可是我們總不能永遠不編寫不具有臨界區的代碼。怎麼辦?
答案是藉助硬件實現的無鎖(lock-free)方法。一個例子是CAS(compare-and-swap)指令。第7章的第12點已經提到,該操作通過將內存中的值與指定數據進行比較,當數值一樣時將內存中的數據替換爲新的值。
以原子性地增加某個變量的值爲例,我們可以這樣做:
void AtomicIncrement(int* value, int amount) {
do {
int old = value;
} while (CompareAndSwap(value, old, old + amount) == 0);
}
這個過程無需獲得鎖,也就不會產生死鎖了。不過如果一個變量同時被多個線程以CAS方式修改,那麼就有可能導致出現活鎖,多個線程將會一直不斷重試CAS操作。所以CAS操作的成本和數據競爭的激烈程度密切相關,在一些競爭非常激烈的情況下,CAS操作的成本甚至會超過互斥鎖。
再舉一個複雜些的例子:鏈表的插入。在鏈表的頭部插入節點的過程如下:
void insert(int value) {
node_t
n = malloc(sizeof(node_t));
assert(n != NULL);
n->value = value;
n->next = head;
head = n;
}
當然,如果多個線程幾乎同時調用這個操作,也會引發競爭條件:如果一個線程在n->next = head;之後被打斷,然後另一個線程完整執行了一次插入,而後被打斷的線程繼續執行。最終就會有一個節點無法通過鏈表的一端被遍歷到。
因爲鏈表的頭結點head是共享的,所以在操作head的語句前後加上鎖的相關語句即可。修正後的代碼是這樣的:
void insert(int value) {
node_t* n = malloc(sizeof(node_t));
assert(n != NULL);
n->value = value;
pthread_mutex_lock(listlock); // begin critical section
n->next = head;
head = n;
pthread_mutex_unlock(listlock); // end critical section
}
如果使用CAS指令,代碼就變成這樣(答案不唯一):
void insert(int value) {
node_t* n = malloc(sizeof(node_t));
assert(n != NULL);
n->value = value;
do {
n->next = head;
} while (CompareAndSwap(&head, n->next, n) == 0);
}
當另一個線程把這個修改頭結點的過程打斷,do-while循環就會重試。請自行在草稿紙上畫圖,驗證該方法的正確性。

9、還可以通過調度策略來預防死鎖。
設有2個CPU核心和4個線程。如果我們知道線程T1、T2都需要獲得鎖L1和L2,T3只需要獲得鎖L2,T4不獲得鎖:

一個機智的調度器就會使T1和T2不同時運行,這樣就不會產生死鎖。一種調度方式是:

T3、T1或者T3、T2都是可以重疊運行的。因爲T3只獲得一個鎖,之後就不會再需要其它鎖,也就無法形成至少兩個進程互相持有對方請求的資源並互相等待對方釋放已有資源的換路。
如果鎖的需求是這樣的:

一種不產生死鎖的保守性調度策略如下:

T4不獲得鎖,所以可以和任何進程併發。T1、T2、T3都需要獲得鎖L1和L2,因此具有引發死鎖的可能性。如果調度策略一時不好決定,就只好保守地採用併發度較低的調度策略了。

10、對付死鎖的另一個策略是:允許死鎖發生,並且檢測到死鎖以後採取措施解除死鎖。這個策略多爲數據庫採用。常用的方法有:
(1)超時法。如果事務的等待時間超時,就一律認爲發生了死鎖。這個方法實現簡單,但是具有一定的誤判概率。
(2)等待圖法。等待圖的每個節點是一個事務,邊的起點和終點分別代表等待另一事務鎖的事務和被等待釋放鎖的事務。每隔一段時間(比如數秒)生成事務等待圖並檢測是否具有環路。如果是,則表示出現死鎖,以付出代價最小的方式結束引發死鎖的一個事務。

對操作系統中的死鎖,則利用資源分配圖(resource allocation graph)來描述。
資源分配圖G = (N, E)是一個對偶圖,節點集N = 進程節點集P∪資源節點集R。每一條邊都連接一個進程節點和一個資源節點。由進程指向資源的邊爲請求邊,代表該進程請求1單位該資源;由資源指向進程的邊爲分配邊,代表1單位該資源分配給該進程。
一個資源分配圖的示例如下。連接資源的邊實際上連接到該種資源中的一個。

11、根據資源分配圖,可以判斷是否陷入死鎖。每次刪去一個不阻塞的進程對應的非孤立節點及其請求邊和分配邊,直到無法再刪去。我們有死鎖定理:一張資源分配圖代表的狀態S爲死鎖,當且僅當S中仍然存在非孤立節點。
有關文獻已經證明:資源分配圖的簡化順序與簡化結果無關。
死鎖檢測方法的代價不低。如果每次分配資源後都進行死鎖檢測,雖然能儘早發現死鎖,但會耗費大量的CPU時間。於是我們可以選擇定期檢查,或者當CPU使用率下降到一定程度後自動觸發檢查。

12、剛纔講的破壞死鎖的四個條件的方法,都需要由程序員在編程期間做好相應工作。在操作系統中,如果發現了死鎖,怎麼當場處理呢?
【1】搶佔資源。即從一個或多個進程中強制剝奪足夠數量的資源分配給死鎖的進程來解除死鎖。
【2】終止或回滾進程。出現死鎖時,如果可能,也可以將進程回滾到獲得死鎖前的某個狀態,再繼續運行。如果不能,則需要結束進程。而終止(或回滾)進程也有不同的方案:
(1)終止全部死鎖進程。這種方法簡單粗暴,但代價很大。因爲強行結束進程會導致未保存的數據丟失,使得許多工作不得不從頭再來。還可能有其它方面的代價,這裏不一一列舉。
(2)逐個終止進程,直到擁有足夠的資源。怎樣處理使得破壞死鎖付出的代價最小呢?怎樣纔算代價最小,很難有一個精確的度量。這裏只簡單說明一下選擇被終止進程時需要考慮的若干因素:
·優先級。
·已執行時間,以及剩餘時間(如果可以得知的話)。
·已使用資源的多少,以及以後還需要的資源種類和數量(如果可以得知的話)。
·進程是交互式的還是批處理(batch)式的?

13、有時候可以不處理死鎖。例如如果某個操作系統一兩年纔出現一個死鎖,那麼出現死鎖後簡單重啓一下就可以(只要不會造成不可接受的損失)。Tom West定律告訴我們:“不是所有值得做的事情都值得把它們做好。”如果一件壞事幾乎不會發生,而且即使發生了,造成的損失也很小。那麼我們的首要選擇是:不去管它。當然,不是所有場合都可以這樣的。如果你在設計航天飛機,假設某一錯誤概率很低,但它可能令航天器直接爆炸導致機上人員全員罹難,那麼你不應該聽從這個建議。
在工程的世界裏,很多工作受到工期和成本的限制。工程師們應當有能力判斷哪些事情是緊要的,必須儘快完成;哪些事情可以在以後慢慢補上;又或者哪些事情根本不用去做。這個能力只能憑藉經驗去形成,並結合具體情境給出結論。

14、Dijkstra還發明瞭銀行家算法(banker’s algorithm)。但是銀行家算法並不是普適性的算法。它只在少數領域有用,例如只運行特定任務、對每個運行任務的行爲都知根知底的一些嵌入式系統。總之,死鎖一般是不可完全避免的。各種預防、避免死鎖的方法都有其弊端。具體採用哪些方案,應當結合操作系統面對的應用環境來決定。
關於銀行家算法,我會單獨寫一篇文章來解析。

在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章