在之前的課程中,我們已經學習了進程相關的知識。進程是計算機程序被執行的一個實例(instance),一個進程可能由一個或多個線程(thread)組成,同時執行指令。在這一章我們將學習線程的知識,包括如何寫一個多線程的程序,如何處理多線程引發的問題。
線程是啥?線程(thread) 的正式英語名稱是“thread of execution”。它代表一列有順序的,需要 CPU執行的指令,可以隨時被內核開始、中斷或繼續運行。
線程使用棧來記憶如何從函數中返回,以及存儲變量和參數等等。在多線程狀態下,每個線程擁有一個自己的棧和程序計數?(PC),而堆,地址空間和全局變量則是多個線程共用的。每個線程對應一個叫做** 線程控制塊(Thread Control Block,TCB) **的數據結構,里面存儲了這個線程的棧指針、程序計數?、處理?狀態、線程標識符(類似于進程標識符,是系統中唯一標明這個線程的一個數字)等等。
我們過去應用多進程是為了使得多個程序并發,改善資源利用效率。但是各個進程之間不共享內存地址,給我們造成了許多不便。使用多線程則可以避免這些問題。不同線程之間共享地址空間,只是棧和程序計數器不同,因此兩個線程間通信也較為容易。更為重要的是,在不同進程間切換時,由于進程地址空間不同,我們需要清空翻譯快表和基于邏輯地址的高速緩沖存儲器,這一過程會使我們的程序運行效率大打折扣;線程間切換就沒有這個問題,對于提高程序運行效率大有裨益。
當然有得必有失,下面這首小詩就表現出了多線程程序的問題:
一個程序員遇到了一個難題
她決定用多線程來解決
于是現在兩個問題了她有
本來詩的最后一句應該是“于是現在她有了兩個問題”,但我們得到的結果卻是“于是現在兩個問題了她有”。多線程經常面臨的問題是多個線程的運行順序是不確定的,且當它們同時修改一個變量時我們最終得到的結果也是不確定的。為了控制多線程運行的結果,我們將在這一章引入互斥和同步的概念,并介紹實現互斥和同步的方法。不過在開始介紹有關互斥和同步的概念以前,讓我們先來夯實基礎,學習線程的不同類型、Linux 對于線程的實現和 Linux Pthread 庫包含的函數。
不同系統中對于線程的實現主要分三種類型:
- 內核級線程
- 用戶級線程
- 混合式線程
要強調一點,就是這里的內核級和用戶級指的 并不是 線程的特權等級!一個用戶進程建立的線程還是只有原來這個進程的特權等級,不能執行只有內核才能執行的指令。用戶級和內核級指的是這個線程是會被用戶進程還是內核調度。
我們雖然還沒有學到任務調度的算法,但我們已經知道,系統會用某種方式調度不同的任務,使他們在處理器上交替運行,這個過程就是內核對于任務的調度。一個內核級的線程就將在系統中獲得被調度的權限,在這種情況下,如果一個進程有兩個線程,那么這兩個線程會被獨立地調度,因此這個進程占取的處理器時間就是這兩個線程占取的處理器時間之和。
在內核級線程模型中,一個線程如果進行 I/O 操作而被阻塞,那么這個進程剩余的線程還可以繼續運行,因此線程的狀態不等于進程的狀態。同樣地,如果一個線程給自己的進程發送 sleep 信號,這個線程依然可以繼續運行。
與內核級相對的用戶級線程就沒有這種權限了。
如果一個系統使用用戶級線程的模式,那么它就只會調度進程,進程內部再自行調度線程。這就是說
- 屬于同一個進程的兩個線程永遠不會同時在兩個處理器上運行;
- 進程也不會因為多分出了幾個線程就獲得更多的處理器時間。
更糟糕的是,如果一個線程由于 I/O 或其它原因被阻塞,那么整個進程就都會被阻塞;而在內核級線程的模型中,一個線程被阻塞后其它線程還可以繼續運行。看到這里你可能會問,這種用戶級線程對于分出線程的進程來講有什么好處呢?
相對于分出子進程運行同樣的內容,用戶級線程的優勢是顯而易見的——多個線程間可以共享地址空間,減少通信帶來的麻煩。
相對于內核級線程來講,用戶級線程有兩個優點。
- 一方面,它減少了線程間上下文切換的代價。內核級線程被系統調度,因此同一進程中不同線程間的上下文切換也會導致用戶態向內核態的轉換,這就包含了從一個用戶線程進入內核線程,再進入另一個用戶級線程的繁瑣過程。用戶級線程由于不涉及用戶態向內核態的轉換,也就沒有這些缺點。
- 另一方面,同一進程不同用戶級線程間的調度完全由用戶進程本身決定,用戶進程就可以用自己的調度算法選擇最優的調度方法;內核級線程在這方面就沒有這種優勢——內核對于用戶級進程來講相當于一個黑箱,我們無法知道里面運行的是何種調度算法,因此不能預測或控制它的效率。
學完了內核級線程和用戶級線程,你可能會問一個問題:有沒有一種線程的設計方法,能綜合內核級線程和用戶級線程的優點呢?這就是混合式線程。在混合式線程中,用戶即可以建立用戶級線程,也可以通過系統調用建立內核級線程。假設一個進程建立了 N 個用戶級線程,M 個內核級線程,那么 ,且用戶可以自己調整用戶級進程向內核級線程的映射。這種方法使對應著同一個內核級線程的用戶級線程之間的切換更加高效,同時又使一個進程能夠獲得更多處理器時間、不會被一個線程阻塞。
這種線程模型的缺點就在于它非常復雜、不利于實現。FreeBSD 和 NetBSD 兩個系統都曾經使用過華盛頓大學在一篇論文中提出的混合式線程模型,但它們現在都已經放棄了這種模型;大部分 Linux 系統中使用的也是較為簡單的內核級線程模型。在這一章中,可以假設我們所講的線程都是內核級線程,而非用戶級線程。
在命名線程模型時,我們也可以用不同模型中內核級線程和用戶級線程之間的對應關系來區別這些模型。
- 內核級線程模型中,由于每個線程都是一個可以被內核調度的任務,用戶級線程與內核級線程的對應關系是1:1的,我們就叫它1:1線程模型;
- 與之形成對比的是用戶級線程模型,在這種模型中一個進程中的多個用戶級線程都被映射到一個可以調度的任務中因此用戶級線程與內核級線程的對應關系是N:1的,我們就叫它N:1模型;
- 最后,混合式線程模型中,一個進程可以建立N個用戶級線程和M個內核級線程,因此它就被稱為N:M模型。
練習:
競爭與互斥
上一節我們學習了如何寫一個簡單的多線程程序。多線程雖然方便快捷,但是問題也不少。在第一節的小詩中我們已經見識到了多線程執行順序的不確定性所帶來的問題;多線程程序還面臨著另一個問題:由于不同線程間共享內存地址空間,一旦兩個線程同時試圖修改同一個地址,我們就面臨著 資源競爭(race condition),而競爭的結果是不確定的。這一節我們來見識一下資源競爭帶來的問題。
出于某種原因,你需要將一個整數從零逐個增加到一千萬。為了節約時間,我們將使用兩個線程分別增加這個整數。我們有如下代碼:
#include <pthread.h>
#include <stdlib.h>
#include <stdio.h>
int *number;
void *adding(void *arg){
for(int i = 0; i < 5000000; i++){
(*(int *)arg)++;
}
return NULL;
}
void *adding(void *arg);
int main(){
number = malloc(sizeof(int));
pthread_t p[2];
pthread_create(&p[0], 0, adding, number);
pthread_create(&p[1], 0, adding, number);
pthread_join(p[0], (void**)NULL);
pthread_join(p[1], (void**)NULL);
printf("%d\n", *number);
return 0;
}
編譯執行之后,我們的程序會在屏幕上打印出什么呢?會是一個 1000000010000000 么? 么?或者程序崩潰因為你不能同時修改同一片內存?答案是:都不是,我們都不確定會打印出什么。結果一定是 50000005000000 到 1000000010000000 之間的一個數。
我們增加 number 的方法是使用 += 操作符。問題在于,這個指令并不是不可拆分的(atomic)。如果你學過一些計算機組成原理,你可能知道,這個指令實際上是 CPU 先將這個數字讀取到寄存?中,然后增加1,然后寫回內存。在這個過程中,很可能出現的一個情況是當一個線程從內存中讀取這個整數之后,還尚未增加或者寫回它,另一個線程也從內存中讀取了它。這樣兩個線程寫回內存的值其實是一樣的。也就是說,兩個線程同時抓取內存資源時若無一定先后順序,會造成資源競爭。
牛奶過量
上一節中我們定義了資源競爭出現的條件,這一節中我們就來介紹一下解決資源競爭的方法。在講解這一部分內容時,教授經常使用的一個例子就是牛奶過量問題(Too Much Milk)。
假設你和你媽每天早晨都要喝牛奶,有一天你回家發現冰箱里沒有牛奶了,于是你出去買牛奶;等你回到家、打開冰箱,卻發現已經有一盒牛奶了,因為你媽媽不知道你去買牛奶了,所以也去買了一盒牛奶。這樣有了兩盒牛奶,你們就不一定能在牛奶過期以前把它們全部喝完了。我們可以把買牛奶這一行為看做臨界區,它必須遵守由 Dijkstra 提出的臨界區調度的三個原則:
- 一次只有一個線程執行臨界區的指令;
- 如果一個線程已經在執行臨界區指令則其它線程必須等待;
- 一個等待中的線程在有限的等待時間后會進入臨界區;
前兩條原則保證了線程的安全性,第三條保證了程序運行的活性(即,程序不會在某個指令永遠的卡住)。
為了實現上面三條原則,我們需要一種通知另一個線程的辦法;現實生活中我們可以在冰箱上留一個便簽,寫上“我去買牛奶了”,對方就不會再買牛奶了,我們可以嘗試把這個過程寫成代碼:
int note = 0;
int milk = 0;
if (!milk ) { //A
if (!note) { //B
note = 1; //C
milk++; //D
note = 0; //E
}
}
現在加入我們有兩個線程執行這段代碼,會不會出現問題呢?讓我們將這兩個線程命名為 1,2
;我們用數字角標表示這個指令是由哪個線程執行的,那么下面這個指令序列就會給我們造成問題:A_1
,B_1
,A_2
,B_2
,C_1
,D_1
,E_1
,C_2
,D_2
,E_2
。
兩個線程分別判斷沒有牛奶也沒有字條以后分別進入了買牛奶的過程,這樣盡管我們有了字條,還是買了兩盒牛奶,違反了第一條原則。
這里我們面臨的主要問題是人的行為是連續的,所以我們不會在發現沒有紙條之后離開一段時間再去留下紙條、買牛奶(當然,如果你非要這么干那也沒有辦法),但線程可能在任何指令處被系統調度?打斷,所以我們不能保證上面這段代碼在兩個線程中運行的結果。
怎樣才能解決上面提到的問題呢?上面的問題似乎主要來源于一點,就是我們先檢查了 note 的值、后將 note 值設為1 。如果在這兩個指令之間另一個線程查看了 note 的值就不會知道我們已經決定去買牛奶了。因此我們可以先寫字條,然后再查看對方是否已經留下字條來決定我們是否去買牛奶:
int note1 = 1; //線程2會把這一行替換為 note2 = 1;
//并把下面的 if 判斷換為判斷 note1 是否為 1
if (!milk) {
if (!note2) {
milk++;
}
}
note1 = 0;
很可惜,這種方法會違反第三條原則,因為如果線程 1 和線程 2 分別檢查發現沒有牛奶,然后分別發現對方已經留下字條,那么就沒有人會去買牛奶了。我們希望如果兩個線程中有一個在發現對方已經留下字條后再等待一會兒,之后重新查看冰箱里是否有牛奶。這就給了我們下面這種解決方法:
- 線程 1 執行的代碼與前面差別不大:
int note1 = 1;
if (!milk) {
if (!note2) {
milk++;
}
}
note1 = 0;
- 線程 2 執行的代碼卻與前面的有一定的差別:
int note2 = 1;
while (note1){
;
}
if (!milk) {
milk++;
}
note2 = 0;
這段代碼可以確保在沒有牛奶的情況下,有且只有一個人回去買牛奶。如果你不信的話我們可以來分析一下這兩個線程的運行過程。
- 假如線程 1 先開始運行,那么線程 1 就會先將 note1 設為 ,之后分為兩種情況,
- 如果線程 2 在線程 1 買牛奶以前就開始運行,那么 note2 也會被設為 1,然后線程 2 就會卡在 whileloop 中,直到系統調度使得線程 1 繼續運行,線程 1 發現 note2 為1 ,就不會買牛奶,而直接將 note1 設為 0,這時線程 2 就可以繼續運行,去買牛奶。
- 另一種情況是,線程 2 在線程 1 買完牛奶后再進入系統,那么它就會直接跳過 while loop,發現已經有牛奶,就會拿走字條。
- 最后一種情況是,線程 2 先進入系統,那它就會直接去買牛奶,這時線程 1 如果進入發現有牛奶或有字條就會直接拿走字條。因此我們可以保證有且只有一個人會買牛奶。
上面這種算法實際上就是 Peterson 算法,我們在這里不作介紹,有興趣的同學可以在網上查看這個算法的應用。從上面這個例子中我們可以看出,在并發多線程中實現互斥是比較復雜的。我們上面提到的方法雖然確實保證了中間的臨界區只有一段代碼運行,但它只適用于 2 個線程,如果我們想將它推廣到多個進程那么問題就會更加復雜,而且如果我們想用兩個線程執行同一段代碼,那么上面代碼中的note1,note2就不適用了,因為運行中的線程不會知道自己是 1 還是 2。接下來的章節我們會介紹兩種更為簡便實用的概念:鎖和條件變量。
鎖
為了實現互斥,我們需要簡便的方法來判定一個線程是否有權利執行一段臨界區的代碼。鎖就是這樣一種方法:為了防止你媽媽和你同時去買牛奶,你可以在去買牛奶以前給冰箱上個鎖,這樣你媽媽看到冰箱已經上鎖就知道,你一定已經去買牛奶了。在程序中,鎖也是這樣一種存在——我們為保護一些共享的數據而建立一個鎖,每次修改這些數據以前都先試圖獲得鎖,成功獲得鎖后再進入臨界區修改數據。
在這里我們需要強調一點:鎖是用來鎖住數據的,而不是用來鎖住代碼的。你的每個鎖一定會有它保護的共享數據,所有調用和修改該數據的操作都會被鎖保護。如果你發現自己想不明白自己寫的代碼中的鎖在保護哪些數據,那么你可能需要重新設計鎖了。
鎖最常用的地方是在一個共享的對象中。雖然 C 不是一門面向對象的語言,但我們仍然可以用它來實現面向對象的思想:現在就讓我們來用一個面向對象的例子來帶你認識鎖的應用。假設我們已經實現了一個鎖struct lock
和用來獲得鎖、解鎖的函數void lock_acquire(struct lock *my_lock);
和void lock_unlock(struct lock *my_lock);
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct word{
struct lock *word_lock;
char *word;
int count;
}
int count_word(struct word *my_word){
lock_acquire(my_word->word_lock);
my_word->count += 1;
lock_unlock(my_word->word_lock);
return 0;
}
int modify_word(struct word *my_word, char *new_word){
lock_acquire(my_word->word_lock);
my_word->word = realloc(my_word->word, (strlen(new_word)+1)*sizeof(char));
strcpy(my_word->word, new_word);
lock_unlock(my_word->word_lock);
return 0;
}
這里struct word中的word_lock保護了struct word中的兩個成員,word和count,在我們需要修改這兩個變量時,我們都必須先獲得鎖,然后再修改其中的值。
鎖的用法是比較好理解的,現在我們需要考慮的是如何實現一個這樣的鎖。
你可能很容易就能想到上一節中買牛奶的例子:如果我們用一個位表示目前鎖是否被持有, 0表示空閑,1表示被持有,那我們就可以在發現這一位為 時將其設為 ,成功獲得鎖。
但這樣有一個問題:如果在一個線程檢查這一位為0后立刻被內核中斷、切換至下一個線程,下一個線程查看這一位為 0,獲得了鎖,執行臨界區代碼至一半,又被切換回原來的線程,原來的線程也認為鎖是空閑的,因此將其設為1 ,獲得鎖后運行臨界區。這樣我們就有兩個線程同時執行臨界區代碼,鎖就形同虛設了。
為了避免上面提到的這種問題,我們希望讀取和設置值能成為一個不可分割的操作,這樣就沒有第二個進程可以插進這兩個操作之間了。這就是 測試并設置指令(Test and Set)
。這一指令由硬件提供,它會讀取一個內存位置的值,然后將 存入這個位置。這樣,如果我們將一個整數設為 ,然后在試圖獲得鎖用 Test and Set 將它設為 1,在返回值為 0時成功獲得鎖,并在解鎖時將這個值重新設為 ,這個整數就可以成為一個鎖。
struct lock{
int value;
}
void lock_acquire(struct lock *my_lock){
while (test_and_set(&my_lock->value))
;
return;
}
void lock_unlock(struct lock *my_lock){
my_lock->value = 0;
return;
}
上面的代碼就是一種實現鎖的方法;由于不能獲得鎖的線程會在 while loop 中不斷循環,這種實現方法被很形象地稱為 自旋鎖(Spinlock),。這種方法有一個顯而易見的缺點,那就是它的效率很低,假設一個線程企圖獲得一個另一個線程已經持有的鎖時,它會不斷循環,浪費處理器時間。因此,自旋鎖只適用于已知單個線程持有鎖時間很短的情況。
為了解決上面提到的效率低的問題,我們可以把未能成功獲得鎖的線程加入到這個鎖的等待名單上,在持有鎖的線程解鎖時再選擇一個線程獲得鎖。為了實現這一功能,我們就需要使用下面的章節中我們要講到的條件變量。
練習:對換自旋鎖
條件變量
在講到鎖的時候我們已經提到,在一個鎖已經被一個線程持有時,我們應該把想要獲得這個鎖的線程加入等待名單,然后阻塞這些線程,在解鎖時從等待名單中選擇一個線程解鎖,從而提高運行效率。你可能已經注意到,這個過程跟我們之前提到的互斥有所不同——互斥的定義是一段代碼不能同時被兩個線程運行,而我們現在希望達到的目的是使得兩個線程不能同時運行兩段需要鎖的代碼,因此我們需要一個工具通知一個線程另一個線程已經解鎖,然后讓想要獲得鎖的線程按一定順序獲得鎖、分別運行。這種協調多個線程運行速度的行為被稱作 同步(synchronization)
在某個條件不成立時,需要該條件成立的線程可以對與這個條件相對應的條件變量調用 wait()
函數, wait()
函數會將這個線程加入等待名單,并進入阻塞態,并自動解鎖;當某個線程修改這個條件涉及到的變量時,線程必須自行調用 signal()
或 broadcast()
。 signal()
和 broadcast()
的區別是 signal()
只會將等待名單上一個線程喚醒,由阻塞態變為就緒態,而broadcast()
會將等待名單上所有線程都有阻塞態變為就緒態。收到信號的線程回到就緒態的線程此時仍然處于wait()
函數中 沒有返回,當內核選擇繼續運行該線程時,線程會獲得其 原來持有的鎖,然后再從wait()
函數返回。
你可能注意到 wait()
和signal()
這兩個函數恰好和Unix
系統中等待子進程結束的 wait()
函數和修改進程對信號的處理方式的 signal()
函數的命名沖突了,因此我們不能直接叫它們 wait()
和 signal()
。在下面的實例中,我們會管這兩個函數叫 void cond_var_wait(struct cond_var *variable,struct spinlock *lock)
和 void cond_var_signal(struct cond_var *variable)
。假設我們已經實現了這兩個函數,以及一個條件變量的結構 struct cond_var
,一個自旋鎖 struct spinlock
,以及用來獲得自旋鎖和解鎖自旋鎖的函數 void spinlock_acquire(struct spinlock *lock);
和void spinlock_unlock(struct spinlock *lock);
。現在我們就來看一看鎖結構的實現。
struct my_lock{
int value;
struct spinlock *spin;
struct cond_var *condition;
}
我們先來定義一個數據結構。struct my_lock
是我們新定義的鎖,它包含三個成員: value
與前兩節中我們定義的自旋鎖中的 value
功能相同,其值為 1時表示鎖已被一個進程持有,其值為0 時表示鎖空閑;spin
是一個自旋鎖,用來保護 struct my_lock
的內部結構;condition
是一個指向我們自己定義的條件變量結構的指針,它與這個鎖相匹配,用來在鎖已被持有時使想要獲得鎖的線程等待。下面我們就來寫獲得鎖的 void acquire_my_lock(struct my_lock *lock)
函數與解鎖的void unlock_my_lock(struct my_lock *lock)
函數。
void acquire_my_lock(struct my_lock *lock){
spinlock_acquire(lock->spin);
while (lock->value) {
cond_var_wait(lock->condition);
}
lock->value = 1;
spinlock_unlock(lock->spin);
}
void unlock_my_lock(struct my_lock *lock){
spinlock_acquire(lock->spin);
lock->value = 0;
cond_var_signal(lock->condition);
spinlock_unlock(lock->spin);
}
由于我們用自旋鎖保護鎖的內部狀態,在想要獲得鎖的時候,我們必須先獲得鎖內部的自旋鎖,然后才能獲得查看、修改鎖的內部狀態的權限。你可能會問一個問題:既然我們仍然要用自旋鎖來鎖住struct my_lock
的內部狀態,那這種鎖的效率為什么會比普通的自旋鎖高呢?這個問題的答案與鎖的持有時間有關。
首先,除了自旋鎖以外,我們幾乎只有一種其它實現鎖的辦法,那就是禁用中斷——在禁用中斷的情況下,我們不會收到系統的計時器或任何硬件發來的中斷,因此我們的代碼在獲得鎖后一定可以作為一個整體執行完成。然而,禁用中斷這種做法比自旋鎖更不被提倡,因為在禁用中斷的過程中,我們可能失去硬件發來的 I/O 中斷等重要的信息。盡管我們在少數情況下必須禁用中斷,在持續時間未知的情況下我們還是不希望使用禁用中斷的手段達到鎖的效果。
因此,我們只有盡量縮短持有自旋鎖的時間。我們無法控制一個線程持有一個鎖的時間,但我們知道,一個線程花在獲得鎖的函數里的時間是固定且較短的。因此我們用一個自旋鎖來保護鎖的內部狀態,而不是直接在 while loop 里反復檢查鎖的狀態。
在上面很短的代碼中,有一點我們要特別提醒你注意:我們在一個 while loop 中執行cond_var_wait(lock->condition);
,而不是在一個 if 語句后執行。我們將具體講解這一做法的原因。
void cond_var_wait(struct cond_var *condition, struct spinlock *lock){
TCB *curr = current_thread();
struct list_elem *new_waiter = calloc(1, sizeof(struct list_elem));
new_waiter->thread = curr;
new_waiter->next = NULL;
struct list_element *temp = condition->waiters;
if (!temp) {
condition->waiters = new_waiter;
} else {
while (temp->next) {
temp = temp->next;
}
temp->next = new_waiter;
}
disable_interrupt();
spinlock_unlock(lock);
thread_block(curr);
spinlock_acquire(lock);
enable_interrupt();
}
void cond_var_signal(struct cond_var *condition){
if(!condition->waiters) return;
struct list_element *head = condition->waiters;
condition->waiters = head->next;
thread_unblock(head->TCB);
free(head);
}
條件變量理解測試
void cond_var_wait(struct cond_var *condition, struct spinlock *lock){
TCB *curr = current_thread();
struct list_elem *new_waiter = calloc(1, sizeof(struct list_elem));
new_waiter->thread = curr;
new_waiter->next = NULL;
struct list_element *temp = condition->waiters;
if (!temp) {
condition->waiters = new_waiter;
} else {
while (temp->next) {
temp = temp->next;
}
temp->next = new_waiter;
}
disable_interrupt();
spinlock_unlock(lock);
thread_block(curr);
spinlock_acquire(lock);
enable_interrupt();
}
void cond_var_signal(struct cond_var *condition){
if(!condition->waiters) return;
struct list_element *head = condition->waiters;
condition->waiters = head->next;
thread_unblock(head->TCB);
free(head);
}
信號量
在前面的章節中,我們講解了互斥和同步這兩個概念,以及用來實現互斥和同步的鎖和條件變量。現在我們來講一個介于鎖和條件變量之間的工具,信號量。信號量(semaphore) 在被初始化時帶有一個自定的非負整數值和一個空的等待名單,之后線程可以對這個信號量進行兩個操作 P() 與 V() 。 P() 會等待信號量的值變為正數,然后將信號量的值減1 ; P() 是一個不可分割的操作,因此我們不用擔心檢查信號量的值變為正數后會有其它線程先把信號量的值降低到0 。 V() 會將信號量的值加 ,如果此時信號量的等待名單上有線程,則喚醒其中一個。
我們可以看到,信號量的兩個操作似乎與條件變量的 wait() 和 signal() 非常相似,但它其實更適合被用來實現互斥。我們可以在初始化時將它的值設為1 ,這時 P() 就相當于 acquire_lock() , V() 就相當于 unlock_lock() 。由于初始值為1 ,且 P() 是一個不可分割的操作,我們知道同時只能有一個線程會成功地從 P() 返回,進入臨界區。這個線程完成臨界區代碼后可以調用 V() ,使信號量的值重新變為1 ,這時下一個線程又可以進入臨界區。
如果我們想要用信號量來模仿條件變量的用法,那就比較困難了。信號量與條件變量的一大區別在于條件變量 沒有內部狀態。比如,如果一個線程調用了 signal() ,此時沒有變量在等待這個條件發生變化,那么這個 signal() 就不會產生任何影響。在條件變為不成立后,下一個來等待這個條件的線程不會因為前面曾經有線程調用過 signal() 就不等待。
信號量就不同了。如果一個信號量的初始值為0 ,一個線程在沒有線程在 P() 中等待時調用了 V() ,信號量的值就會被增加至1 。這時如果有一個線程調用 P() ,則它無需經過任何等待,因為前面線程的歷史被信號量保留了下來。
信號量與條件變量相比還有一個缺點,那就是條件變量在等待時會將保護共享數據的鎖自動解鎖,但 P()沒有這個功能,因此我們一般會在調用 P() 以前解鎖,否則其它線程就無法修改共享數據,造成永遠等待的局面。
所幸,還是有一種可以用信號量模仿條件變量的方法的。這種方法由 Andrew Birrell 在微軟 Windows 支持條件變量以前實現。下面我們寫的代碼沒有包含有關的數據結構和函數的定義,你可以聯系前幾節講的鎖和條件變量的定義和這一節講的信號量的定義、將這段代碼當做偽代碼來理解:
void cond_var_wait(struct cond_var *condition, struct lock *my_lock){
struct semaphore *my_sema;
semaphore_init(my_sema, 0); // 將信號量初始值設為 1
// 將信號量加入條件變量的等待名單
append_to_list(condition->waiters, my_sema);
lock_unlock(my_lock);
semaphore_P(my_sema);
lock_acquire(my_lock);
}
void cond_var_signal(struct cond_var *condition){
if (condition->waiters) {
semaphore_V(remove_from_list(condition->waiters));
}
}
從上面的代碼中我們可以看出,用信號量可以實現互斥和同步的功能,但這兩種用法背后的想法是截然不同的,剛剛接觸多線程編程的人很容易混淆這兩種用法。如果你覺得自己對于同步和互斥的概念的理解仍然不透徹,那么我們就建議你使用鎖和條件變量,以鞏固你對于互斥和同步的認識。
Pthread 庫中的鎖和條件變量
前幾節中我們講了如何實現鎖和條件變量;這些知識雖然能幫助你對鎖和條件變量有更深的認識,但除非你在設計一個操作系統,否則你是不需要自己實現鎖和條件變量的。如果你在寫一個用戶級別的程序,那么你需要的就只是了解 Linux 中提供了哪些已經實現好的鎖和條件變量。這一節中,我們就來講一講 Linux Pthread 庫中包含的鎖和條件變量的相關函數。
死鎖
死鎖的引入
顧名思義,死鎖死鎖肯定與鎖有關,我們知道引入鎖又是為了解決多進程或多線程之間的同步與互斥問題,那么到底怎樣的情形才會產生死鎖呢?
典型的兩種死鎖情形:
線程自己將自己鎖住
一般情況下,如果同一個線程先后兩次調用lock,在第二次調?用時,由于鎖已經被占用,該線程會掛起等待占用鎖的線程釋放鎖,然而鎖正是被自己占用著的,該線程又被掛起而沒有機會釋放鎖,因此 就永遠處于掛起等待狀態了,于是就形成了死鎖(Deadlock)
。多線程搶占鎖資源被困
又如線程A獲 得了鎖1,線程B獲得了鎖2,這時線程A調用lock試圖獲得鎖2,結果是需要掛起等待線程B釋放 鎖2,而這時線程B也調用lock試圖獲得鎖1,結果是需要掛起等待線程A釋放鎖1,于是線程A和B都 永遠處于掛起狀態了,死鎖再次形成。
計算機系統中的死鎖
1、資源分類
(一)可重用性資源和消耗性資源
可重用資源:可供用戶重復使用多次的資源。
性質:
- 每一個可重用性資源中的單元只能分配給一個進程(或線程)使用,不允許多個進程(或線程)共享。
- 可重用性資源的使用順序: 請求資源—->使用資源—->釋放資源
- 系統中每一類可重用性資源中的單元數目是相對固定的,進程(或線程)在運行期間既不能創建也不能刪除它。
可消耗性資源:又稱臨時性資源,是由進程(或線程)在運行期間動態的創建和消耗的。
性質:
- 每一類可消耗性資源的單元數目在進程(或線程)運行期間是可以不斷變化的,有時可能為0.
- 進程,或線程)在運行過程中可以不斷的創建可消耗性資源的單元,將它們放入該資源類的緩沖區中,以增加該資源類的單元數目。
- 進程(或線程)在運行過程中可請求若干個可消耗性資源,用于進程(或線程)自己的消耗不再將它們返回給該資源類中。
可消耗性資源通常是由生產者進程(或線程)創建,由消費者進程(或線程)消耗。
(二)可搶占性資源和不可搶占性資源
- 可搶占性資源:?某進程(或線程)在獲得該類資源后,該資源可以被其他進程(或線程)或系統搶占。
CPU和主存均屬于可搶占性資源。 - 不可搶占性資源:?系統一旦把某資源分配該進程(或線程)之后,就不能強行收回,只能在進程(或線程)用完之后自行釋放。
磁帶機、打印機等都屬于不可搶占性資源。
引起死鎖的原因
(一)競爭不可搶占資源引起死鎖
如:共享文件時引起死鎖
系統中擁有兩個進程P1和P2,它們都準備寫兩個文件F1和F2。而這兩者都屬于可重用和不可搶占性資源。如果進程P1在打開F1的同時,P2進程打開F2文件,當P1想打開F2時由于F2已結被占用而阻塞,當P2想打開1時由于F1已結被占用而阻塞,此時就會無線等待下去,形成死鎖。
(二)競爭可消耗資源引起死鎖
如:進程通信時引起死鎖
系統中擁有三個進程P1、P2和P3,m1、m2、m3是3可消耗資源。進程P1一方面產生消息m1,將其發送給P2,另一方面要從P3接收消息m3。而進程P2一方面產生消息m2,將其發送給P3,另一方面要從P1接收消息m1。類似的,進程P3一方面產生消息m3,將其發送給P1,另一方面要從P2接收消息m2。
如果三個進程都先發送自己產生的消息后接收別人發來的消息,則可以順利的運行下去不會產生死鎖,但要是三個進程都先接收別人的消息而不產生消息則會永遠等待下去,產生死鎖。
(三)進程推進順序不當引起死鎖
上圖中,如果按曲線1的順序推進,兩個進程可順利完成;如果按曲線2的順序推進,兩個進程可順利完成;如果按曲線3的順序推進,兩個進程可順利完成;如果按曲線4的順序推進,兩個進程將進入不安全區D中,此時P1保持了資源R1,P2保持了資源R2,系統處于不安全狀態,如果繼續向前推進,則可能產生死鎖。
死鎖的定義、必要條件和處理方法
1、死鎖的定義:如果一組進程(或線程)中的每一個進程(或線程)都在等待僅由該組進程中的其他進程(或線程)才能引發的事件,那么該組進程(或線程)是死鎖的(Deadlock)。
2、產生死鎖的必要條件
(1)互斥條件。進程(線程)所申請的資源在一段時間內只能被一個進程(線程)鎖占用。
(2)請求和保持條件。進程(線程)已經占有至少一個資源,但又提出了新的資源請求,而該資源卻被其他進程(線程)占用。
(3)不可搶占條件(不可剝奪條件)。進程(線程)已獲得的資源在未使用完之前不能被搶占。
(4)循環等待條件(環路等待條件)。在發生死鎖時,必然存在一個進程(線程)—-資源的循環鏈。
3、處理死鎖的方法
(1)預防死鎖。破壞死鎖產生的必要條件中的一個或多個。注意,互斥條件不能被破壞,否則會造成結果的不可再現性。
(2)避免死鎖。在資源分匹配過程中,防止系統進入不安全區域。
(3)檢測死鎖。通過檢測機構檢測死鎖的發生,然后采取適當措施解除死鎖。
(4)解除死鎖。在檢測機構檢測死鎖發生后,采取適當措施解除死鎖。
利用銀行家算法避免死鎖
1、銀行家算法中的數據結構
(1)可利用資源向量Available[m]。m為系統中的資源種類數,如果向量Available[j] = K,則表示系統中Rj類資源由K個。
(2)最大需求矩陣Max[n][m]。m為系統中的資源種類數,n為系統中正在運行的進程(線程)數,如果Max[i][j] = K,則表示進程i需要Rj類資源的最大數目為K個。
(3)分配矩陣Allocation[n][m]。m為系統中的資源種類數,n為系統中正在運行的進程(線程)數,如果Allocation[i][j] = K,則表示進程i當前已分得Rj類資源的數目為K個。
(4)需求矩陣Need[n][m]。m為系統中的資源種類數,n為系統中正在運行的進程(線程)數,如果Need[i][j] = K,則表示進程i還需要Rj類資源K個。
以上三個矩陣間的關系:
Need[i][j] = Max[i][j] - Allocation[i][j]
2、銀行家算法
設Request( i)是進程Pi的請求向量,如果Request(i) [j] = K,表示進程Pi需要K個Rj類型的資源。
(1)如果Request(i) [j] <= Need[i][j],轉向步驟(2)。
(2)如果Request(i) [j] <= Available[j] ,轉向步驟(3)。
(3)系統嘗試著把資源分給進程Pi。
Available[j] = Available[j] - Request(i) [j];
Allocation[i][j] = Allocation[i][j] + Request(i) [j];
Need[i][j] = Need[i][j] - Request(i) [j];
(4)系統執行安全性算法,檢查此次資源分配后系統是否處于安全狀態。
3、安全性算法
(1)設置兩個向量:
1》工作向量Work[m],它表示系統可提供給進程繼續運行所需要的各類資源數目,初始值Work = Available。
2》Finish:它表示系統是否有足夠的資源分配給進程,使其運行完成。開始時Finish[i] = false,當有足夠的資源分配給進程時Finish[i] = true。
(2)從進程(線程)集合中找到一個能滿足下述條件的進程(線程)。
1》Finish[i] = false
2》Need[i][j] <= Work[j],如果找到轉到步驟3》,沒找到轉到步驟4》。
3》Work[j] = Work[j] + Allocation[i][j] ;
Finish[i] = true;
go to step 2;
4》如果所有進程(線程)的Finish[i] = true都滿足,表示系統處于安全狀態,反之系統處于不安全狀態。
https://blog.csdn.net/hj605635529/article/details/69214903
文章摘自計算機操作系統(第四版)楊小丹書籍