1. 程式人生 > >Linux多執行緒學習(2)--執行緒的同步與互斥及死鎖問題(互斥量和條件變數)

Linux多執行緒學習(2)--執行緒的同步與互斥及死鎖問題(互斥量和條件變數)

Linux多執行緒學習總結

一.互斥量

  • 大部分情況,執行緒使用的資料都屬於區域性變數區域性變數儲存在執行緒的棧幀中,這種變數屬於單個執行緒,其他執行緒無法獲得者種變數
  • 有些情況,一些變數需要線上程間共享,這樣的變數稱為共享變數(一般指全域性變數),可以通過資料之間的共享來實現執行緒之間的互動
  • 多個執行緒併發的操作共享變數
    ,一定會導致問題的,互斥量就是為了解決這種問題

1.名詞理解

  • 臨界資源:多執行緒執行共享的資源就叫做臨界資源
  • 臨界區:每個執行緒內部訪問臨界資源的程式碼叫做臨界區
  • 互斥:任何時候,互斥保證有且只有一個執行流進入臨界區,訪問臨界資源,互斥量通常是對臨界資源起到保護作用
  • 同步:同步是在互斥的基礎上,按照某種特定的次序去訪問臨界資源
  • 原子性:一個操作只有兩種狀態,要麼完成,要麼沒有完成

2.什麼是互斥量(mutex)

我們可以以一個多執行緒實現的簡單售票系統來說明互斥量是什麼:

#include <iostream>
#include
<pthread.h>
#include <unistd.h> using namespace std; int ticket = 100; void *route(void* arg) { char* name = (char*)arg; while(1) { if(ticket > 0) { usleep(1000); //沒有對臨界資源加鎖,會產生問題 cout << name << " buy ticket:" << ticket << endl; --ticket; } else { break; } } } int main() { pthread_t t1,t2,t3,t4; pthread_create(&t1, NULL, route, (void*)"thread 1 "); pthread_create(&t2, NULL, route, (void*)"thread 2 "); pthread_create(&t3, NULL, route, (void*)"thread 3 "); pthread_create(&t4, NULL, route, (void*)"thread 4 "); pthread_join(t1, NULL); pthread_join(t2, NULL); pthread_join(t3, NULL); pthread_join(t4, NULL); return 0; }

下邊為執行結果:
在這裡插入圖片描述

可以發現票數竟然出現負數了,這個售票系統肯定是存在問題的,原因在與我們並沒有將臨界資源ticket保護起來,假設當ticket=1時,程序1判斷條件成立進入if中,但是還沒有執行--ticket的時候,它的時間片到了,OS切換到下一個執行緒,此時ticket依然等於1,該執行緒依然會進入到if條件中,此時就會導致問題了,本來上一個執行緒在執行時就沒有票了,但是這個執行緒拿到卻依然有票。這個問題我們就可以通過加上互斥量來解決,將臨界資源保護起來。

出現錯誤的原因在於:

  • if 語句判斷條件為真以後,程式碼可以併發的切換到其他執行緒
  • usleep這個模擬漫長業務的過程,在這個漫長的業務過程中,可能有很多個執行緒會進入該程式碼段
  • --ticket根本就不是一個原子性操作,站在彙編的角度去看這個操作,其實對於的是三條彙編:load(將ticket從記憶體載入到暫存器)、updata(更新暫存器中的值,執行-1操作)、store(將新值從暫存器寫回共享變數ticket的記憶體地址中)

解決該錯誤的方法:

  • 執行--ticket這個非原子性的操作時必須要有互斥行為,當一個執行緒進入臨界區時,不允許其他執行緒進入
  • 如果該執行緒沒有在臨界區中執行,那麼該執行緒不能阻止其他執行緒進入臨界區
  • 多個執行緒同時要執行臨界區中的程式碼,並且臨界期沒有執行緒正在執行,那麼只允許一個執行緒進入該臨界區

上述的三點其實就是互斥量。互斥量的本質其實是一把鎖,也叫做互斥鎖,也可以理解為一個二元訊號量。互斥量是最基本的同步形式,它用來保護臨界區資源,以保證任何時刻只有一個執行緒在執行其中的程式碼

//上鎖
pthread_mutex_lock()
...
//臨界區(只允許有一個執行緒執行)
...
//解鎖
pthread_mutex_unlock()

//非臨界區,可以允許多個執行緒同時執行

3.互斥量的介面

3.1 初始化訊號量

Posix互斥鎖被宣告為pthread_t型別的變數。初始化互斥鎖:

  • 靜態分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER 
  • 動態分配在共享記憶體
 int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr _t *restrict attr);   
引數:       
   mutex:要初始化的互斥量        
   attr:屬性,先設定為NULL

3.2 銷燬訊號量

int pthread_mutex_destroy(pthread_mutex_t *mutex);
引數:mutex銷燬哪一個訊號量

銷燬訊號量時要注意:

  • 使用PTHREAD_ MUTEX_ INITIALIZER初始化的互斥量不需要銷燬
  • 不能銷燬一個已經加鎖互斥量
  • 已經銷燬的互斥量,要確保後面不會有執行緒再嘗試加鎖

3.3 互斥量加鎖和解鎖

  • 加鎖
int pthread_mutex_lock(pthread_mutex_t *mutex); 
返回值:成功返回0,失敗返回錯誤碼

加鎖時需要注意: 如果互斥量處於未鎖狀態,lock函式會將該互斥量鎖定,同時返回成功。如果其他執行緒已經鎖定互斥量,或者存在其他執行緒時申請互斥量,但沒有競爭到互斥量,那麼pthread_ lock呼叫會陷入阻塞,等待互斥量解鎖

  • 解鎖
//解鎖
int pthread_mutex_unlock(pthread_mutex_t *mutex); 
返回值:成功返回0,失敗返回錯誤碼

我們可以根據上邊學習到的互斥量對售票系統進行修改,在臨界區加上互斥鎖,以保護臨界資源,不被多個執行緒重入

修改程式碼位於我的github:https://github.com/hansionz/Linux_Code/tree/master/pthread/ticket

二.條件變數

當一個執行緒互斥的訪問某個變數時,它可能發現在其他執行緒改變狀態之前,它什麼也做不了。例如,一個執行緒訪問佇列時,發現佇列為空,它只能等待,直到其它執行緒將一個節點新增到佇列中,這種情況就需要用到條件變數。

1.什麼是條件變數

條件變數使我們可以睡眠等待某種條件出現,條件變數是利用執行緒間共享的全域性變數進行同步的一種機制。主要包括兩個動作:一個執行緒等待"條件變數的條件成立"而掛起;另一個執行緒使"條件成立"(給出條件成立訊號)。為了防止競爭,條件變數的使用總是和一個互斥鎖結合在一起。

2.條件變數介面

2.1 初始化

條件變數其實是以pthread_cond_t為型別的變數,

int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *rest rict attr); 
引數:    
	cond:要初始化的條件變數    
	attr:NULL(屬性)

2.2 銷燬條件變數

只要初始化了條件變數,就必須得銷燬

int pthread_cond_destroy(pthread_cond_t *cond)
引數:
	cond:表示要初始化的條件變數

2.3 等待條件滿足

2.3.1 函式說明

此操作對應概念中的一個執行緒為等待條件變數的條件成立而掛起

int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mute x); 
引數:    
	cond:要在這個條件變數上等待    
	mutex:互斥量

2.3.2 為什麼pthread_cond_wait需要互斥量

  • pthread_cond_wait的功能包括兩步解鎖和掛起等待既然涉及到解鎖,那我們就必須要存在互斥量對其操作
  • 條件等待是執行緒間同步的一種手段,如果只有一個執行緒,條件不滿足,一直等下去都不會滿足,所以必須要有一個執行緒通過某些操作,改變共享變數,使原先不滿足的條件變得滿足,並且友好的通知等待在條件變數上的執行緒
  • 條件不會無緣無故的突然變得滿足了,必然會牽扯到共享資料的變化。所以一定要用互斥鎖來保護。沒有互斥鎖就無法安全的獲取和修改共享資料

對於pthread_cond_wait的兩個操作是否是必須的呢?這兩個操作是否可以分離開來操作:我們可以想到先上鎖,發現條件不滿足,解鎖,然後在條件變數下等待,這樣是否可行?

lock();
while(條件為假){
	unlock();
	///在解鎖之後,等待之前,條件可能已經滿⾜足,訊號已經發出,但是該訊號可能被錯過
	wait();
	lock();
}
unlock();
  • 由於解鎖和等待不是原子操作。呼叫解鎖之後,pthread_ cond_ wait之前,如果已經有其他執行緒獲取到互斥量,摒棄條件滿足,傳送了訊號,那麼pthread_ cond_ wait錯過這個訊號,可能會導致執行緒永遠阻塞在這個pthread_ cond_ wait所以解鎖和等待必須是一個原子操作。

2.4 喚醒等待

此操作對應概念中的一個執行緒使得條件成立(給出條件成立的訊號)

//廣播喚醒在該條件變數等待下的所有執行緒
int pthread_cond_broadcast(pthread_cond_t *cond);   
/喚醒在該條件變數下等待的一個執行緒(佇列的第一個)
int pthread_cond_signal(pthread_cond_t *cond);

2.5 條件變數使用規範

  • 標準的等待條件程式碼應該這麼寫:
pthread_mutex_lock();
while(條件為假)
	pthread_cond_wait();
	//條件成立則返回
	//新的執行緒被喚醒會自動的重新申請鎖
修改條件
pthread_cond_unlock();

對於上邊的條件判斷為什麼要使用while而不是if呢?

上邊使用while而不是if的原因在與防止假喚醒假喚醒是指在多核處理器上,pthread_cond_signal函式不僅僅只會喚醒一個執行緒,而是可能會喚醒多個執行緒,在喚醒的這多個執行緒中,可能只有1個是滿足條件的。所以我們需要在pthread_cond_wait函式返回後再次判斷是否滿足條件,如果使用if判斷,不管第一個被喚醒的執行緒是否滿足要求,就直接向下執行就會導致問題;如果採用while判斷,如果第一個條件為假,則繼續輪詢判斷,直到條件為,才跳出迴圈,繼續執行後續程式碼。

  • 設定條件為真,並傳送訊號給在該條件變數下等待的執行緒
pthread_mutex_lock(&mutex);    
//設定條件為真    
pthread_cond_signal(cond);    
pthread_mutex_unlock(&mutex);

設定條件為真為什麼要加鎖: 因為條件變數是利用執行緒之間的全域性變數進行同步的機制,要設定條件變數,就說明要對共享的全域性變數進行改變,如果不加鎖,可能會導致一些執行緒安全的問題。

三.死鎖問題

1. 什麼是死鎖問題?

如果一個執行緒試圖對同一個互斥量加鎖兩次,自身就會陷入死鎖狀態。當該執行緒第一次去向互斥量加鎖時,由於該互斥量上並沒有鎖,所以可以加鎖成功,但是該執行緒第二次去向互斥量加鎖,由於該互斥量上已經加過鎖了,所以會把自身掛起阻塞,直到該鎖被釋放,但是自己又被掛起了,所以不會有人去釋放的,這就造成了死鎖問題。

當一個執行緒去申請一個已經被持有,但是還沒有釋放的互斥量時,執行緒將會被阻塞,直到該互斥量被釋放。如果該互斥量不被釋放,該執行緒將會被一直阻塞。死鎖就是,一個執行緒阻塞的等待一個永遠不會為真的條件

2. 產生死鎖的幾個常見場景

  • 假設程式中現在有一個互斥量,然後一個執行緒對該互斥量已經加鎖,但是在加鎖和解鎖的這段程式碼中,如果該區域程式碼又試圖向該互斥量申請鎖,那麼就會造成自身掛起等待,從而導致死鎖

  • 假設程式中使用兩個互斥量執行緒A首先鎖住一個互斥量,然後執行緒B也鎖住另外一個互斥量,擁有第一個互斥量執行緒A又去試圖鎖住第二個互斥量,而擁有第二個互斥量執行緒B試圖申請鎖住第一個互斥量,這就會導致兩個執行緒此時都在掛起堵塞中,兩個執行緒都在相互請求另一個執行緒的資源導致兩個執行緒都無法向前執行,於是產生了死鎖問題

3.死鎖產生的四個必要條件

上邊的兩種產生死鎖的場景是在互斥量的條件下,但是這造成死鎖的場景,並不侷限於互斥量,只要滿足產生死鎖的條件,就會出現死鎖。針對死鎖的概念,大牛們總結出來了四條產生死鎖的必要條件

  • 互斥條件

互斥條件與鎖一樣,要麼能被申請,要麼就只能等待。在任意時刻,某份資源只能被一個程序或執行緒使用。

  • 佔有和等待條件

佔有和等待條件是指某個執行緒或程序,在佔有某份資源後還可以申請其他的資源。

  • 不可搶佔條件

當某份資源被某一程序或執行緒佔有時,不能被其他執行緒或程序強制性的搶佔,只能被佔有它的執行緒主動的釋放。

  • 環路等待條件

死鎖發生時,系統中一定有兩個或兩個以上的程序組成的一條環路,該環路中的每一個執行緒或程序都在等待下一個程序所佔用的資源。

以上的四個條件必須同時滿足,才會可能造成死鎖。只要有一個條件不滿足,就不會造成死鎖死鎖的產生並不僅會只有使用互斥量時會發生,只要滿足以上四個條件也可能產生死鎖。在系統中,有許多隻能被互斥性訪問的獨佔資源,如請求獨佔性的io裝置,印表機等,在對其進行操作時,也有可能造成死鎖

4. 處理死鎖的四種策略

  • 忽略死鎖問題: 將死鎖忽略,不注意死鎖。有的死鎖產生的時間並不確定。而且死鎖發生的頻度,造成問題的嚴重性不同。假如對於一個死鎖每隔幾個月或者每幾年出現一次,而且每次造成的問題並不嚴重,那麼此時,工程師可能並不會以損失可用性或效能損失的代價去防止死鎖。此種情況下就屬於忽略死鎖的問題。

  • 檢測死鎖並恢復:當出現死鎖時,通過檢測死鎖的技術,檢測到出現的死鎖,對於找到的死鎖進行恢復

  • 仔細對資源分配,動態的避免死鎖

  • 通過破壞引起死鎖的四個必要條件之一,以此來避免死鎖

5.避免死鎖的常見方法

  • 設定加鎖順序

多個執行緒需要相同的一些鎖,但是按照不同的順序加鎖死鎖就很容易發生。如果能確保所有的執行緒都是按照相同的順序獲得鎖,那麼死鎖就不會發生。如果一個執行緒(執行緒3)需要一些鎖,那麼它必須按照確定的順序獲取鎖。它只有獲得了從順序上排在前面的鎖之後,才能獲取後面的鎖例如,執行緒2和執行緒3只有在獲取了鎖A之後才能嘗試獲取鎖。因為執行緒1已經擁有了鎖A,所以執行緒2和3需要一直等到鎖A被釋放。然後在它們嘗試對B或C加鎖之前,必須成功地對A加了鎖。

Thread 1:
  lock A 
  lock B

Thread 2:
   wait for A
   lock C //此時,要鎖C,必須先將釋放的A鎖鎖住

Thread 3:
   wait for A
   wait for B
   wait for C

缺點: 按照順序加鎖是一種有效的死鎖預防機制。但是,這種方式需要你事先知道所有可能會用到的鎖,但總有些時候是無法預知的。

  • 設定加鎖時限(超時檢測)

獲取鎖的時候嘗試加一個獲取鎖的時限超過時限不需要再獲取鎖,放棄操作(對鎖的請求)。若一個執行緒在一定的時間裡沒有成功的獲取到鎖,則會進行回退並釋放之前獲取到的鎖,然後等待一段時間後進行重試。在這段等待時間中其他執行緒有機會嘗試獲取相同的鎖,這樣就能保證在沒有獲取鎖的時候繼續執行自己的事情。

缺點: 由於存在鎖的超時,通過設定時限並不能確定出現了死鎖,每種方法總是有缺陷的。有時為了執行某個任務,某個執行緒花了很長的時間去執行任務,如果在其他執行緒看來,可能這個時間已經超過了等待的時限,可能出現了死鎖。在大量執行緒去操作相同的資源的時候,這個情況又是一個不可避免的事情。例如,現在只有兩個執行緒,一個執行緒執行的時候,超過了等待的時間,下一個執行緒會嘗試獲取相同的鎖,避免出現死鎖。但是這時候不是兩個執行緒了,可能是幾百個執行緒同時去執行,讓事件出現的概率變大,假如執行緒還是等待那麼長時間,但是多個執行緒的等待時間就有可能重疊,因此又會出現競爭超時,由於他們的超時發生時間正好趕在了一起,而超時等待的時間又是一致的,那麼他們下一次又會競爭,等待,這就又出現了死鎖

  • 死鎖檢測

當一個執行緒獲取鎖的時候,會在相應的資料結構中記錄下來,如果有執行緒請求鎖,也會在相應的結構中記錄下來。當一個執行緒請求失敗時,需要遍歷一下這個資料結構檢查是否有死鎖產生。例如:執行緒A請求鎖住一個方法1,但是現在這個方法是執行緒B所有的,這時候執行緒A可以檢查一下執行緒B是否已經請求了執行緒A當前所持有的鎖,像是一個環,執行緒A擁有鎖1,請求鎖2,執行緒B擁有鎖2,請求鎖1。當遍歷這個儲存結構的時候,如果發現了死鎖,一個可行的辦法就是釋放所有的鎖,回退,並且等待一段時間後再次嘗試。

缺點: 這個這個方法和上面的超時重試的策略是一樣的。但是在大量執行緒的時候問題還是會出現和設定加鎖時限相同的問題。每次執行緒之間發生競爭。 還有一種解決方法是設定執行緒優先順序,這樣其中幾個執行緒回退,其餘的執行緒繼續保持著他們獲取的鎖,也可以嘗試隨機設定優先順序,這樣保證執行緒的執行