目錄
1.資源共享問題?
1.1多線程并發訪問?
1.2臨界區和臨界資源?
1.3互斥鎖?
2.多線程搶票?
2.1并發搶票?
2.2 引發問題?
3.線程互斥?
3.1互斥鎖相關操作?
3.1.1互斥鎖創建與銷毀?
3.1.2、加鎖操作?
3.1.3 解鎖操作?
3.2.解決搶票問題?
3.2.1互斥鎖細節?
3.3互斥鎖原理?
3.4多線程封裝?
3.5互斥鎖的封裝
3.5.1RAII風格
4.線程安全VS重入
5、常見鎖概念
5.1、死鎖問題
6.線程同步
6.1同步概念
6.2.同步相關操作
6.2.1條件變量創建與銷毀?
6.2.2條件等待
6.2.3喚醒線程
6.3同步demo
1.資源共享問題?
1.1多線程并發訪問?
?比如存在全局變量?g_val
?以及兩個線程?thread_A
?和?thread_B
,兩個線程同時不斷對?g_val
?做?減減?--
?操作
?
注意:用戶的代碼無法直接對內存中的?
g_val
?做修改,需要借助?CPU
?
如果想要對?g_val
?進行修改,至少要分為三步:
- 先將?
g_val
?的值拷貝至寄存器中 - 在?
CPU
?內部通過運算寄存器完成計算 - 將寄存器中的值拷貝回內存
假設?g_val
?初始值為?100
,如果?thread_A
?想要進行?g_val--
,就必須這樣做
?
也就是說,簡單的一句?g_val--
?語句實際上至少會被分成?三步
單線程場景下步驟分得再細也沒事,因為沒有其他線程干擾它,但我們現在是在?多線程?場景中,存在?線程調度問題,假設此時?thread_A
?在執行完第2步后被強行切走了,換成?thread_B
?運行
?
thread_A 的第3步還沒有完成,內存中 g_val 的值還沒有被修改,但 thread_A 認為自己已經修改了(完成了第2步),在線程調度時,thread_A 的上下文及相關數據會被保存,thread_A 被切走后,thread_B 會被即刻調度入場,不斷執行 g_val-- 操作
thread_B 的運氣比較好,進行很多次 g_val-- 操作后都沒有被切走
?
當?thread_B
?將?g_val
?中的值修改為?10
?后,就被操作系統切走了,此時輪到?thread_A
?登場,thread_A
?帶著自己的之前的上下文數據,繼續進行它的未盡事業(完成第3步操作),當然?thread_B
?的上下文數據也會被保存?
?
??此時尷尬的事情發生了:thread_A
?把?g_val
?的值改成了?99
,這對于?thread_B
?來說很不公平,倘若下次再從內存中讀取?g_val
時,結果為?99
,自己又得重新進行計算,但站在兩個線程的角度來說,兩者都沒有錯
thread_A:?
將自己的上下文恢復后繼續執行操作,合情合理thread_B:?
按照要求不斷對?g_val
?進行操作,也是合情合理
?
錯就錯在 thread_A 在錯誤的時機被切走了,保存了老舊的 g_val 值(對于 thread_B 來說),直接影響就是 g_val 的值飄忽不定
倘若再出現一個線程 thread_C 不斷打印 g_val 的值,那么將會看到 g_val 值減為 10 后又突然變為 99 的 “靈異現象”
產出結論:多線程場景中對全局變量并發訪問不是 100% 可靠的
1.2臨界區和臨界資源?
?在多線程場景中,對于諸如?g_val
?這種可以被多線程看到的同一份資源稱為?臨界資源,涉及對?臨界資源?進行操作的上下文代碼區域稱為?臨界區
?
?臨界資源?本質上就是?多線程共享資源,而?臨界區?則是?涉及共享資源操作的代碼區間
1.3互斥鎖?
?臨界資源?要想被安全的訪問,就得確保?臨界資源使用時的安全性
對于?臨界資源?訪問時的安全問題,也可以通過?加鎖?來保證,實現多線程間的?互斥訪問,互斥鎖?就是解決多線程并發訪問問題的手段之一?
我們可以 在進入臨界區之前加鎖,出臨界區之后解鎖, 這樣可以確保并發訪問 臨界資源 時的絕對串行化,比如之前的 thread_A 和 thread_B 在并發訪問 g_val 時,如果進行了 加鎖,在 thread_A 被切走后,thread_B 無法對 g_val 進行操作,因為此時 鎖 被 thread_A 持有,thread_B 只能 阻塞式等待鎖,直到 thread_A 解鎖(意味著 thread_A 的整個操作都完成了)
?因此,對于?
thread_A
?來說,在?加鎖?環境中,只要接手了訪問臨界資源?g_val
?的任務,要么完成、要么不完成,不會出現中間狀態,像這種不會出現中間狀態、結果可預期的特性稱為 原子性
?說白了?加鎖?的本質就是為了實現?原子性
注意:
- 加鎖、解鎖是比較耗費系統資源的,會在一定程序上降低程序的運行速度
- 加鎖后的代碼是串行化執行的,勢必會影響多線程場景中的運行速度
- 所以為了盡可能的降低影響,加鎖粒度要盡可能的細
2.多線程搶票?
?實踐出真知,接下來通過代碼演示多線程并發訪問問題
2.1并發搶票?
思路很簡單:存在?1000
?張票和?5
?個線程,5
?個線程同時搶票,直到票數為?0
,程序結束后,可以看看每個線程分別搶到了幾張票,以及最終的票數是否為?0
共識:購票需要時間,搶票成功后也需要時間,這里通過?usleep
?函數模擬耗費時間
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
using namespace std;int tickets = 1000; // 有 1000 張票void* threadRoutine(void* args)
{int sum = 0;const char* name = static_cast<const char*>(args); while(true){// 如果票數 > 0 才能搶if(tickets > 0){usleep(2000); // 耗時 2mssum++;--tickets;}elsebreak; // 沒有票了usleep(2000); //搶到票后也需要時間處理}cout << "線程 " << name << " 搶票完畢,最終搶到的票數 " << sum << endl;delete name;return nullptr;
}int main()
{pthread_t pt[5];for(int i = 0; i < 5; i++){char* name = new char(16);snprintf(name, 16, "thread-%d", i);pthread_create(pt + i, nullptr, threadRoutine, name);}for(int i = 0; i < 5; i++)pthread_join(pt[i], nullptr);cout << "所有線程均已退出,剩余票數: " << tickets << endl;return 0;
}
?最終剩余票數?
-1
,難道?12306
?還欠了 1張票?這顯然是不可能的,5
?個線程搶到的票數之和為?1001
,這就更奇怪了,總共?1000
張票還多出來 1?張?
?顯然多線程并發訪問是絕對存在問題的
2.2 引發問題?
這其實就是 thread_A 和 thread_B 并發訪問 g_val 時遇到的問題,舉個例子:假設 tickets = 500,thread-0 在搶票,準備完成第3步,將數據拷貝回內存時被切走了,thread-1 搶票后,tickets = 499;輪到 thread-0 回來時,它也是把 tickets 修改成了 499,這就意味著 thread-0 和 thread-1 之間有一個人白嫖了一張票(按理來說 tickets = 498 才對)
?對于?票?這種?臨界資源,可以通過?加鎖?進行保護,即實現?線程間的互斥訪問,確保多線程購票時的?原子性
3
?條匯編指令要么不執行,要么全部一起執行完
?
--tickets
?本質上是?3
?條匯編指令,在任意一條執行過程中切走線程都會引發并發訪問問題
3.線程互斥?
互斥 -> 互斥排斥:事件?A
?與事件?B
?不會同時發生
比如 多線程并發搶票場景中可以通過添加?互斥鎖?的方式,來確保同一張票不會被多個線程同時搶到
3.1互斥鎖相關操作?
3.1.1互斥鎖創建與銷毀?
?互斥鎖?同樣出自?原生線程庫,類型為?pthread_mutex_t
,互斥鎖?在創建后需要進行?初始化
#include <pthread.h>pthread_mutex_t mtx; // 定義一把互斥鎖
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
其中:
參數1 pthread_mutex_t* 表示想要初始化的鎖,這里傳的是地址,因為需要在初始化函數中對 互斥鎖 進行初始化
參數2 const pthread_mutexattr_t* 表示初始化時 互斥鎖 的相關屬性設置,傳遞 nullptr 使用默認屬性
返回值:初始化成功返回 0,失敗返回 error number
互斥鎖 是一種向系統申請的資源,在 使用完畢后需要銷毀
#include <pthread.h>int pthread_mutex_destroy(pthread_mutex_t *mutex);
其中只有一個參數?pthread_mutex_t*
?表示想要銷毀的 互斥鎖
返回值:銷毀成功返回?0
,失敗返回?error number
以下是創建并銷毀一把?互斥鎖?的示例代碼
#include <iostream>
#include <pthread.h>
using namespace std;int main()
{pthread_mutex_t mtx; //定義互斥鎖pthread_mutex_init(&mtx, nullptr); // 初始化互斥鎖// ...pthread_mutex_destroy(&mtx); // 銷毀互斥鎖return 0;
}
注意:
- 互斥鎖是一種資源,一種線程依賴的資源,因此 [初始化互斥鎖] 操作應該在線程創建之前完成,[銷毀互斥鎖] 操作應該在線程運行結束后執行;總結就是 使用前先創建,使用后需銷毀
- 對于多線程來說,應該讓他們看到同一把鎖,否則就沒有意義
- 不能重復銷毀互斥鎖
- 已經銷毀的互斥鎖不能再使用
使用?pthread_mutex_init
?初始化?互斥鎖?的方式稱為?動態分配,需要手動初始化和銷毀,除此之外還存在?靜態分配,即在定義?互斥鎖?時初始化為?PTHREAD_MUTEX_INITIALIZER
?
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
靜態分配?的優點在于?無需手動初始化和手動銷毀,鎖的生命周期伴隨程序,缺點就是定義的?互斥鎖?必須為?全局互斥鎖
?
?注意:?使用靜態分配時,互斥鎖必須定義為全局鎖
3.1.2、加鎖操作?
互斥鎖最重要的功能就是 加鎖與解鎖操作,主要使用pthread_mutex_lock 進行加鎖
#include <pthread.h>int pthread_mutex_lock(pthread_mutex_t *mutex);
參數?pthread_mutex_t*
?表示想要使用哪把互斥鎖進行加鎖操作
返回值:成功返回?0
,失敗返回?error number
使用?pthread_mutex_lock
?加鎖時可能遇到的情況:
- 當前互斥鎖沒有被別人持有,正常加鎖,函數返回?
0
- 當前互斥鎖被別人持有,加鎖失敗,當前線程被阻塞(執行流被掛起),無法向后運行,直到獲得 [鎖資源]
3.1.3 解鎖操作?
?使用?pthread_mutex_unlock
?進行?解鎖
#include <pthread.h>int pthread_mutex_unlock(pthread_mutex_t *mutex);
參數 pthread_mutex_t* 表示想要對哪把互斥鎖進行解鎖
返回值:解鎖成功返回 0,失敗返回 error number
在 加鎖 成功并完成對 臨界資源 的訪問后,就應該進行 解鎖,將 [鎖資源] 讓出,供其他線程(執行流)進行 加鎖
注意: 如果不進行解鎖操作,會導致后續線程無法申請到 [鎖資源] 而永久等待,引發 死鎖 問題
3.2.解決搶票問題?
為了方便所有線程看到同一把?鎖,可以給線程信息創建一個類?TData
,其中包括?name
?和?pmtx
pmtx
?表示指向?互斥鎖?的指針
?
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
using namespace std;int tickets = 1000; // 有 1000 張票// 需要定義在 threadRoutine 之前
class TData
{
public:TData(const string &name, pthread_mutex_t* pmtx):_name(name), _pmtx(pmtx){}public:string _name;pthread_mutex_t* _pmtx;
};void* threadRoutine(void* args)
{int sum = 0;TData* td = static_cast<TData*>(args); while(true){// 進入臨界區,加鎖pthread_mutex_lock(td->_pmtx);// 如果票數 > 0 才能搶if(tickets > 0){usleep(2000); // 耗時 2mssum++;tickets--;// 出臨界區了,解鎖pthread_mutex_unlock(td->_pmtx);}else{// 如果判斷沒有票了,也應該解鎖pthread_mutex_unlock(td->_pmtx);break; // 沒有票了}// 搶到票后還有后續動作usleep(2000); //搶到票后也需要時間處理}// 屏幕也是共享資源,加鎖可以有效防止打印結果錯行pthread_mutex_lock(td->_pmtx);cout << "線程 " << td->_name << " 搶票完畢,最終搶到的票數 " << sum << endl;pthread_mutex_unlock(td->_pmtx);delete td;return nullptr;
}int main()
{// 創建一把鎖pthread_mutex_t mtx;// 在線程創建前,初始化互斥鎖pthread_mutex_init(&mtx, nullptr);pthread_t pt[5];for(int i = 0; i < 5; i++){char* name = new char(16);snprintf(name, 16, "thread-%d", i);TData *td = new TData(name, &mtx);pthread_create(pt + i, nullptr, threadRoutine, td);}for(int i = 0; i < 5; i++)pthread_join(pt[i], nullptr);cout << "所有線程均已退出,剩余票數: " << tickets << endl;// 線程退出后,銷毀互斥鎖pthread_mutex_destroy(&mtx);return 0;
}
?
假設某個線程在解鎖后,沒有后續動作,那么它會再次加鎖,繼續干自己的事,如此重復形成競爭鎖,該線程獨享一段時間的資源
- 解決方法:解鎖后讓當前線程執行其他動作,也可以選擇休眠一段時間,確保 [鎖資源] 能盡可能均勻的分發給其他線程
3.2.1互斥鎖細節?
多線程加鎖互斥中的細節處理才是重頭戲
細節1:?凡是訪問同一個臨界資源的線程,都要進行加鎖保護,而且必須加同一把鎖,這是游戲規則,必須遵守
比如在上面的代碼中,5
?個并發線程看到的是同一把?互斥鎖,只有看到同一把?互斥鎖?才能確保線程間?互斥
?
細節2:?每一個線程訪問臨界區資源之前都要加鎖,本質上是給臨界區加鎖
并且建議加鎖時,粒度要盡可能的細,因為加鎖后區域的代碼是串行化執行的,代碼量少一些可以提高多線程并發時的效率
細節3: 線程在訪問臨界區前,需要先加鎖 -> 所有線程都要看到同一把鎖 -> 鎖本身也是臨界資源 -> 鎖如何保證自己的安全?
加鎖 是為了保護 臨界資源 的安全,但 鎖 本身也是 臨界資源,這就像是一個 先有雞還是先有蛋的問題,鎖 的設計者也考慮到了這個問題,于是對于 鎖 這種 臨界資源 進行了特殊化處理:加鎖 和 解鎖 操作都是原子的,不存在中間狀態,也就不需要保護了
?
細節4: 臨界區本身是一行代碼,或者一批代碼
線程在執行臨界區內的代碼時可以被調度嗎?
調度切換后,對于鎖及臨界資源有影響嗎?
首先,線程在執行臨界區內的代碼時,是允許被調度的,比如線程 1 在持有 [鎖資源] 后結束運行,是完全可行的(證明可以被調度);其次,線程在持有鎖的情況下被調度是沒有影響的,不會擾亂原有的加鎖次序
簡單舉例說明
假設你的學校里有一個 頂級 VIP 自習室,一次只允許一個人使用。作為學校里的公共資源,這個 頂級 VIP 自習室 開放給所有學生使用
使用規則:
一次只允許一個人使用
????????自習室的門上裝有一把鎖,優先到達自習室的可以獲取鑰匙并進入自習室
自習室內無限制,允許一直自習,直到自愿退出,退出后需要把鑰匙交給下一個想要自習的同學
假設某天早上 6:00 張三就到達了 頂級 VIP 自習室,并成功獲取鑰匙,解鎖后進入了自習室自習;之后陸陸續續有同學來到了 頂級 VIP 自習室 門口,因為他們都沒有鑰匙,只能默默等待張三或上一個進入自習室的人交接鑰匙。
????????此時的張三不就是持有 [鎖資源],并且在進行 臨界資源 訪問的 線程(執行流) 嗎?其他線程(執行流)無法進入 臨界區,只有等待張三 解鎖(交出 [鎖資源] / 鑰匙)
????????假如張三此時想上廁所,并且不想失去鑰匙,那么此時他就會帶著鑰匙去上廁所,即便自習室空無一人,但其他同學也無法進入自習室!
????????張三上廁所的行為可以看作線程在持有?[鎖資源]?的情況下被調度了,顯然此時對于整體程序是沒有影響的,因為?鎖還是處于?lock
?狀態,其他線程無法進入臨界區
????????假若張三自習夠了,瀟灑出門,把鑰匙往門上一放,正好被李四同學搶到了,那么此時?頂級?VIP
?自習室?就是屬于李四的
????????交接鑰匙的本質是讓出?自習室?的訪問權,這不就是?線程解鎖后離開臨界區,其他線程加鎖并進入臨界區嗎
綜上可以借助?張三與頂級?VIP
?自習室?的故事理解?線程持有鎖時的各種狀態
細節5:?互斥會給其他線程帶來影響
當某個線程持有?[鎖資源]?時,對于其他線程的有意義的狀態:
- 鎖被我申請了(其他線程無法獲取)
- 鎖被我釋放了(其他線程可以獲取鎖)
在這兩種狀態的劃分下,確保了多線程并發訪問時的?原子性
細節6:?加鎖與解鎖配套出現,并且這兩個對于鎖的操作本身就是原子的
至于如何確保?加鎖和解鎖?時的原子性,可以接著往下看
3.3互斥鎖原理?
?在如今,大多數?CPU
?的體系結構(比如?ARM
、X86
、AMD
?等)都提供了?swap
?或者?exchange
?指令,這種指令可以把?寄存器?和?內存單元?的數據?直接交換,由于這種指令只有一條語句,可以保證指令執行時的?原子性
?即便是在多處理器環境下(總線只有一套),訪問內存的周期也有先后,一個處理器上的交換指令執行時另一個處理器的交換指令只能等待總線周期,即?
swap
?和?exchange
?指令在多處理器環境下也是原子的
首先看一段偽匯編代碼(加鎖相關的)
本質上就是?pthread_mutex_lock()
?函數
lock:movb $0, %alxchgb %al, mutexif(al寄存器里的內容 > 0){return 0;} else掛起等待;goto lock;
其中 movb 表示賦值,al 為一個寄存器,xchgb 就是支持原子操作的 exchange 交換語句
共識:計算機中的硬件,如 CPU 中的寄存器只有一份,被所有線程共享,但其中的內容隨線程,不同線程的內容可能不同,也就是我們常說的上下文數據
寄存器 != 寄存器中的內容(執行流的上下文)
當線程 thread_A 首次加鎖時,整體流程如下:
將 0 賦值給 al 寄存器,這里假設 mutex 默認值為 1(其他不為 0 的整數也行)
movb $0, %al
將?al
?寄存器中的值與?mutex
?的值交換(原子操作)?
xchgb %al, mutex
?
判斷當前?al
?寄存器中的值是否?> 0
?
if(al寄存器里的內容 > 0){return 0;} else掛起等待;
此時線程?thread_A
?就可以快快樂樂的訪問?臨界區?代碼了,如果此時線程?thread_A
?被切走了(并沒有出臨界區,[鎖資源] 也沒有釋放),OS
?會保存?thread_A
?的上下文數據,并讓線程?thread_B
?入場
thread_B
?也是執行?pthread_mutex_lock()
?的代碼,試圖進入?臨界區
首先將?al
?寄存器中的值賦為?0
?
movb $0, %al
其次將?al
?寄存器中的值與?mutex
?的值交換(原子操作)?
mutex
?作為內存中的值,被所有線程共享,因此?thread_B
?看到的?mutex
?是被?thread_A
?修改后的值
顯然此時交換了個寂寞
最后判斷?al
?寄存器中的值是否?> 0
?
if(al寄存器里的內容 > 0){return 0;
} else掛起等待;
此時的 thread_B 因為沒有 [鎖資源] 而被拒絕進入 臨界區,不止是 thread_B, 后續再多線程(除了 thread_A) 都無法進入 臨界區
不難看出,此時 thread_A 的上下文數據中,al = 1 正是解開 臨界區 的 鑰匙,其他線程是無法獲取的,因為 鑰匙 只能有一份
而匯編代碼中 xchgb %al, mutex 的本質就是 加鎖,當 mutex 不為 0 時,表示 鑰匙 可用,可以進行 加鎖;并且因為 xchgb %al, mutex 只有一條匯編指令,足以確保 加鎖 過程是 原子性 的
?現在再來看看?解鎖?操作吧,本質上就是執行?pthread_mutex_unlock()
?函數
?原理相同:
unlock:movb $1, mutex喚醒等待 [鎖資源] 的線程;return
注意:
- 加鎖是一個讓不讓你通過的策略
- 交換指令?
swap
?或?exchange
?是原子的,確保 鎖 這個臨界資源不會出現問題 - 未獲取到 [鎖資源] 的線程會被阻塞至?
pthread_mutex_lock()
?處
3.4多線程封裝?
目標:對 原生線程庫 提供的接口進行封裝,進一步提高對線程相關接口的熟練程度
既然是封裝,那必然離不開類,這里的類成員包括:
- 線程?
ID
- 線程名?
name
- 線程狀態?
status
- 線程回調函數?
fun_t
- 傳遞給回調函數的參數?
args
#pragma once#include <iostream>
#include <string>
#include <pthread.h>enum class Status
{NEW = 0, // 新建RUNNING, // 運行中EXIT // 已退出
};// 參數、返回值為 void 的函數類型
typedef void (*func_t)(void*);class Thread
{
private:pthread_t _tid; // 線程 IDstd::string _name; // 線程名Status _status; // 線程狀態func_t _func; // 線程回調函數void* args; // 傳遞給回調函數的參數
};
首先完成?構造函數,初始化時只需要傳遞?編號、函數、參數?就行了
Thread(int num = 0, func_t func = nullptr, void* args = nullptr):_tid(0), _status(Status::NEW), _func(func), _args(args)
{// 根據編號寫入名字char name[128];snprintf(name, sizeof name, "thread-%d", num);_name = name;
}
?其次完成各種獲取具體信息的接口
// 獲取 ID
pthread_t getTID() const
{return _tid;
}// 獲取線程名
std::string getName() const
{return _name;
}// 獲取狀態
Status getStatus() const
{return _status;
}
接下來就是處理?線程啟動
// 啟動線程
void run()
{int ret = pthread_create(&_tid, nullptr, runHelper, nullptr /*需要考慮*/);if(ret != 0){std::cerr << "create thread fail!" << std::endl;exit(1); // 創建線程失敗,直接退出}_status = Status::RUNNING; // 更改狀態為 運行中
}
線程執行的方法依賴于回調函數?runHelper
// 回調方法
void* runHelper(void* args)
{// 很簡單,回調用戶傳進來的 func 函數即可_func(_args);
}
?此時這里出現問題了,pthread_create
?無法使用?runHelper
?進行回調
?
?
參數類型不匹配
原因在于:類中的函數(方法)默認有一個隱藏的 this 指針,指向當前對象,顯然此時 tunHelper 中的參數列表無法匹配
解決方法:有幾種解決方法,這里選一個比較簡單粗暴的,直接把 runHelper 函數定義為 static 靜態函數,這樣他就會失去隱藏的 this 指針
不過此時又出現了一個新問題:失去?this
?指針后就無法訪問類內成員了,也就無法進行回調了!
有點尷尬,不過換個思路,既然他想要?this
?指針,那我們直接利用?pthread_create
?的參數4
?進行傳遞就好了,實現曲線救國
?
// 回調方法
static void* runHelper(void* args)
{Thread* myThis = static_cast<Thread*>(args);// 很簡單,回調用戶傳進來的 func 函數即可myThis->_func(myThis->_args);return nullptr;
}// 啟動線程
void run()
{int ret = pthread_create(&_tid, nullptr, runHelper, this);if(ret != 0){std::cerr << "create thread fail!" << std::endl;exit(1); // 創建線程失敗,直接退出}_status = Status::RUNNING; // 更改狀態為 運行中
}
在最后完成 線程等待
// 線程等待
void join()
{int ret = pthread_join(_tid, nullptr);if(ret != 0){std::cerr << "thread join fail!" << std::endl;exit(1); // 等待失敗,直接退出}_status = Status::EXIT; // 更改狀態為 退出
}
現在使用自己封裝的?Demo
版線程庫,簡單編寫多線程程序
注意:?需要包含頭文件,我這里是?Thread.hpp
#include <iostream>
#include <unistd.h>
#include "Thread.hpp"
using namespace std;void threadRoutine(void* args)
{}int main()
{Thread t1(1, threadRoutine, nullptr);cout << "thread ID: " << t1.getTID() << ", thread name: " << t1.getName() << ", thread status: " << (int)t1.getStatus() << endl;t1.run();cout << "thread ID: " << t1.getTID() << ", thread name: " << t1.getName() << ", thread status: " << (int)t1.getStatus() << endl;t1.join();cout << "thread ID: " << t1.getTID() << ", thread name: " << t1.getName() << ", thread status: " << (int)t1.getStatus() << endl;return 0;
}
3.5互斥鎖的封裝
原生線程庫 提供的 互斥鎖 相關代碼比較簡單,也比較好用,但有一個很麻煩的地方:就是每次都得手動加鎖、解鎖,如果忘記解鎖,還會導致其他線程陷入無限阻塞的狀態
因此我們對鎖進行封裝,實現一個簡單易用的 小組件
封裝思路:利用創建對象時調用構造函數,對象生命周期結束時調用析構函數的特點,融入 加鎖、解鎖 操作即可
非常簡單,直接創建一個 LockGuard 類
#include <iostream>
#include <unistd.h>
#include "Thread.hpp"
#include "LockGuard.hpp"
using namespace std;// 創建一把全局鎖
pthread_mutex_t mtx;
int tickets = 1000; // 有 1000 張票// 自己封裝的線程庫返回值為 void
void threadRoutine(void *args)
{int sum = 0;const char* name = static_cast<const char*>(args);while (true){// 進入臨界區,加鎖{// 自動加鎖、解鎖LockGuard guard(&mtx);// 如果票數 > 0 才能搶if (tickets > 0){usleep(2000); // 耗時 2mssum++;tickets--;}elsebreak; // 沒有票了}// 搶到票后還有后續動作usleep(2000); // 搶到票后也需要時間處理}// 屏幕也是共享資源,加鎖可以有效防止打印結果錯行{LockGuard guard(&mtx);cout << "線程 " << name << " 搶票完畢,最終搶到的票數 " << sum << endl;}
}int main()
{// 在線程創建前,初始化互斥鎖pthread_mutex_init(&mtx, nullptr);// 創建一批線程Thread t1(1, threadRoutine, (void*)"thread-1");Thread t2(2, threadRoutine, (void*)"thread-2");Thread t3(3, threadRoutine, (void*)"thread-3");// 啟動t1.run();t2.run();t3.run();// 等待t1.join();t2.join();t3.join();// 線程退出后,銷毀互斥鎖pthread_mutex_destroy(&mtx);cout << "剩余票數: " << tickets << endl;return 0;
}
3.5.1RAII風格
?像這種?獲取資源即初始化?的風格稱為?RAII
?風格,由?C++
?之父?本賈尼·斯特勞斯特盧普?提出,非常巧妙的運用了?類和對象?的特性,實現半自動化操作
4.線程安全VS重入
線程安全:多線程并發訪問同一段代碼時,不會出現不同的結果,此時就是線程安全的;但如果在沒有加鎖保護的情況下訪問全局變量或靜態變量,導致出現不同的結果,此時線程就是不安全的
重入:同一個函數被多個線程(執行流)調用,當前一個執行流還沒有執行完函數時,其他執行流可以進入該函數,這種行為稱之為 重入;在發生重入時,函數運行結果不會出現問題,稱該函數為 可重入函數,否則稱為 不可重入函數
常見線程不安全的情況
- 不保護共享變量,比如全局變量和靜態變量
- 函數的狀態隨著被調用,而導致狀態發生變化
- 返回指向靜態變量指針的函數
- 調用 線程不安全函數 的函數
常見線程安全的情況
- 每個線程對全局變量或靜態變量只有讀取權限,而沒有寫入權限,一般來說都是線程安全的
- 類或者接口對于線程來說都是原子操作
- 多個線程之間的切換不會導致執行結果存在二義性
常見不可重入的情況
- 調用了?
malloc / free
?函數,因為這些都是?C
語言 提供的接口,通過全局鏈表進行管理 - 調用了標準?
I/O
?庫函數,其中很多實現都是以不可重入的方式來使用全局數據結構 - 可重入函數體內使用了靜態的數據結構
常見可重入的情況
- 不使用全局變量或靜態變量
- 不使用?
malloc
?或?new
?開辟空間 - 不調用不可重入函數
- 不返回全局或靜態數據,所有的數據都由函數調用者提供
- 使用本地數據或者通過制作全局數據的本地拷貝來保護全局數據
?重入與線程安全的聯系
- 如果函數是可重入的,那么函數就是線程安全的;不可重入的函數有可能引發線程安全問題
- 如果一個函數中使用了全局數據,那么這個函數既不是線程安全的,也不是可重入的
重入與線程安全的區別
- 可重入函數是線程安全函數的一種
- 線程安全不一定是可重入的,反過來可重入函數一定是線程安全的
- 如果對于臨界資源的訪問加上鎖,則這個函數是線程安全的;但如果這個重入函數中沒有被釋放會引發死鎖,因此是不可被重入的
一句話總結:是否可重入只是函數的一種特征,沒有好壞之分,但線程不安全是需要規避的
5、常見鎖概念
5.1、死鎖問題
死鎖:指在一組進程中的各個線程均占有不會釋放的資源,但因相互申請被其他線程所占用不會釋放的資源處于一種永久等待狀態
概念比較繞,簡單舉個例子
兩個小朋各持 五毛錢 去商店買東西,倆人同時看中了一包 辣條,但這包 辣條 售價 一塊錢,兩個小朋友都想買了自己吃,但彼此的錢都不夠,雙方互不謙讓,此時局面就會僵持不下
兩個小朋友:兩個不同的線程
辣條:臨界資源
售價:訪問臨界資源需要的鎖資源數量,這里需要兩把鎖
兩個小朋友各自手里的錢:一把鎖資源
僵持不下的場面:形成死鎖,導致程序無法繼續運行
所以死鎖就是?多個線程都因鎖資源的等待而被同時掛起,導致程序陷入 死循環
只有一把鎖會造成死鎖嗎?
答案是?會的,如果線程?thread_A
?申請鎖資源,訪問完臨界資源后沒有釋放,會導致 線程?thread_B
?無法申請到鎖資源,同時線程?thread_A
?自己也申請不到鎖資源了,不就是?死鎖?嗎
死鎖?產生的四個必要條件
- 互斥:一個資源每次只能被一個執行流使用
- 請求與保持:一個執行流因請求資源而阻塞時,對已獲得的資源保持不釋放
- 環路等待:若干執行流之間形成一種首尾相接的循環等待資源關系
- 不剝奪條件:不能強行剝奪其他線程的資源
只有四個條件都滿足了,才會引發?死鎖?問題
如何避免?死鎖?問題?
核心思想:破壞四個必要條件的其中一個或多個
方法1:不加鎖
不加鎖的本質是不保證?互斥,即破壞條件1
方法2:嘗試主動釋放鎖
比如進入 臨界區 訪問 臨界資源,需要兩把鎖,thread_A 和 thread_B 各自持有一把鎖,并且都在嘗試申請第二把鎖,但如果此時 thread_A 放棄申請,主動把鎖釋放,這樣就能打破 死鎖 的局面,主打的就是一個犧牲自己
可以借助 pthread_mutex_trylock 函數實現這種方案
#include <pthread.h>int pthread_mutex_trylock(pthread_mutex_t *mutex);
這個函數就是嘗試申請鎖,如果長時間申請不到鎖,就會把自己當前持有的鎖釋放,然后放棄加鎖,給其他想要加鎖的線程一個機會
方法3:按照順序申請鎖
按照順序申請鎖 -> 按照順序釋放鎖 ->?就不會出現環路等待的情況
方法4:控制線程統一釋放鎖
首先要明白:鎖不一定要由申請鎖的線程釋放,其他線程也可以釋放鎖
比如在下面這個程序中,主線程就釋放了次線程申請的鎖,打破了?死鎖?的局面
?
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;// 全局互斥鎖,無需手動初始化和銷毀
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;void* threadRoutine(void* args)
{cout << "我是次線程,我開始運行了" << endl;// 申請鎖pthread_mutex_lock(&mtx);cout << "我是次線程,我申請到了一把鎖" << endl;// 在不釋放鎖的情況下,再次申請鎖,陷入 死鎖 狀態pthread_mutex_lock(&mtx);cout << "我是次線程,我又再次申請到了一把鎖" << endl;pthread_mutex_unlock(&mtx);return nullptr;
}int main()
{pthread_t t;pthread_create(&t, nullptr, threadRoutine, nullptr);// 等待次線程先跑sleep(3);// 主線程幫忙釋放鎖pthread_mutex_unlock(&mtx);cout << "我是主線程,我已經幫次線程釋放了一把鎖" << endl;// 等待次線程后續動作sleep(3);pthread_join(t, nullptr);cout << "線程等待成功" << endl;return 0;
}
因此,我們可以設計一個 控制線程,專門掌管所有的鎖資源,如果識別到發生了 死鎖 問題,就釋放所有的鎖,讓線程重新競爭
注意: 規定只有申請鎖的人才能釋放鎖,規定可以不遵守,但最好遵守
死鎖 一般比較少見,因為這是因代碼編寫失誤而引發的問題
常見的避免 死鎖 問題的算法:死鎖檢測算法、銀行家算法
6.線程同步
6.1同步概念
同步:在保證數據安全的前提下,讓線程能夠按照某種特定的順序訪問臨界資源,從而有效避免 饑餓問題
至于該如何正確理解?饑餓問題,需要再次請張三出場
話說張三在早上?6:00
?搶到了自習室的鑰匙,并開開心心的進入了自習室自習
此時自習室外人聲鼎沸,顯然有很多人都在等待張三交出鑰匙,但張三不急,慢悠悠的自習到了中午 12:00,此時張三有些餓了,想出去吃個飯,吃飯就意味著張三需要把鑰匙歸還(這是規定)
張三剛把鑰匙放到門上,扭頭就發現了大批的同學正在等待鑰匙,張三心想:要是我就這樣把鑰匙歸還了,那等我吃完飯回來豈不是也需要等待
于是法外狂徒張三決定放棄吃飯,強忍著饑餓再次拿起鑰匙進入了自習室自習;剛進入自習室沒幾分鐘,肚子就餓的咕咕叫,于是張三就又想出去吃飯,剛出門歸還了鑰匙,扭頭看見大批同學就感覺很虧,一咬牙就又拿起鑰匙進入了自習室,就這樣張三反復橫跳,直到下午 6:00 都還沒吃上午飯,不僅自己沒吃上午飯、沒好好自習,還導致其他同學無法自習!
張三錯了嗎?張三沒錯,十分符合自習室的規定,只是 不合理
因為張三這種不合理的行為,導致 自習室 資源被浪費了,在外等待的同學也失去了自習,陷入 饑餓狀態,活生生被張三 “餓慘了”
為此校方更新了 自習室 的規則:
所有自習完的同學在歸還鑰匙之后,不能立即再次申請
在外面等待鑰匙的同學必須排隊,遵守規則
規則更新之后,就不會出現這種 饑餓問題 了,所以解決 饑餓問題 的關鍵是:在安全的規則下,使多線程訪問資源具有一定的順序性
即通過 線程同步 解決 饑餓問題
原生線程庫?中提供了?條件變量?這種方式來實現?線程同步
邏輯鏈:通過條件變量 -> 實現線程同步 -> 解決饑餓問題
條件變量:當一個線程互斥的訪問某個變量時,它可能發現在其他線程改變狀態之前,什么也做不了
比如當一個線程訪問隊列時,發現隊列為空,它只能等待,直到其他線程往隊列中添加數據,此時就可以考慮使用 條件變量
條件變量的本質就是 衡量訪問資源的狀態
競態條件:因為時序問題而導致程序出現異常
可以把?條件變量?看作一個結構體,其中包含一個?隊列?結構,用來存儲正在排隊等候的線程信息,當條件滿足時,就會取?隊頭?線程進行操作,操作完成后重新進入?隊尾
隊列是保證順序性的重要工具?
6.2.同步相關操作
6.2.1條件變量創建與銷毀?
?作為出自?原生線程庫?的?條件變量,使用接口與?互斥鎖?風格差不多,比如?條件變量?的類型為?pthread_cond_t
,同樣在創建后需要初始化
#include <pthread.h>pthread_cond_t cond; // 定義一個條件變量
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
參數1 pthread_cond_t* 表示想要初始化的條件變量
參數2 const pthread_condattr_t* 表示初始化時的相關屬性,設置為 nullptr 表示使用默認屬性
返回值:成功返回 0,失敗返回 error number
條件變量 在使用結束后需要銷毀
?
#include <pthread.h>int pthread_cond_destroy(pthread_cond_t *cond);
pthread_cond_t*
?表示想要銷毀的條件變量
返回值:成功返回?0
,失敗返回?error number
注:同互斥鎖一樣,條件變量支持靜態分配,即在創建全局條件變量時,定義為?PTHREAD_COND_INITIALIZER
,表示自動初始化、自動銷毀
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
注意:?這種定義方式只支持全局條件變量
6.2.2條件等待
?原生線程庫?中提供了?pthread_cond_wait
?函數用于等待
#include <pthread.h>int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
參數1 pthread_cond_t* 想要加入等待的條件變量
參數2 pthread_mutex_t* 互斥鎖,用于輔助條件變量
返回值:成功返回 0,失敗返回 error number
參數2值得詳細說一說,首先要明白 條件變量是需要配合互斥鎖使用的,需要在獲取 [鎖資源] 之后,在通過條件變量判斷條件是否滿足
傳遞互斥鎖的理由:
- 條件變量也是臨界資源,需要保護
- 當條件不滿足時(沒有被喚醒),當前持有鎖的線程就會被掛起,其他線程還在等待鎖資源呢,為了避免死鎖問題,條件變量需要具備自動釋放鎖的能力
當某個線程被喚醒時,條件變量釋放鎖,該線程會獲取鎖資源,并進入?條件等待?狀態
6.2.3喚醒線程
?條件變量?中的線程是需要被喚醒的,否則它也不知道何時對?隊頭線程?進行判斷,可以使用?pthread_cond_signal
?函數進行喚醒
#include <pthread.h>int pthread_cond_signal(pthread_cond_t *cond);
pthread_cond_t 表示想要從哪個條件變量中喚醒線程
返回值:成功返回 0,失敗返回 error number
注意: 使用 pthread_cond_signal 一次只會喚醒一個線程,即隊頭線程
如果想喚醒全部線程,可以使用 pthread_cond_broadcast
#include <pthread.h>int pthread_cond_broadcast(pthread_cond_t *cond);
6.3同步demo
接下來簡單使用一下?線程同步?相關接口
目標:創建?5
?個次線程,等待條件滿足,主線程負責喚醒
這里演示?單個喚醒?與?廣播?兩種方式,先來看看?單個喚醒?相關代碼
?
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;// 互斥鎖和條件變量都定義為自動初始化和釋放
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;const int num = 5; // 創建五個線程void* Active(void* args)
{const char* name = static_cast<const char*>(args);while(true){// 加鎖pthread_mutex_lock(&mtx);// 等待條件滿足pthread_cond_wait(&cond, &mtx);cout << "\t線程 " << name << " 正在運行" << endl;// 解鎖pthread_mutex_unlock(&mtx);}delete[] name;return nullptr;
}int main()
{pthread_t pt[num];for(int i = 0; i < num; i++){char* name = new char[32];snprintf(name, 32, "thread-%d", i);pthread_create(pt + i, nullptr, Active, name);}// 等待所有次線程就位sleep(3);// 主線程喚醒次線程while(true){cout << "Main thread wake up Other thread!" << endl;pthread_cond_signal(&cond); // 單個喚醒sleep(1);}for(int i = 0; i < num; i++)pthread_join(pt[i], nullptr);return 0;
}
可以看到,在?單個喚醒?模式下,一次只會有一個線程蘇醒,并且得益于?條件變量,線程蘇醒的順序都是一樣的?
?可以將喚醒方式換成?廣播
// ......
pthread_cond_broadcast(&cond); // 廣播
// ......
?
互斥鎖+條件變量?可以實現?生產者消費者模型,關于?生產者消費者的實現?與?條件變量?的更多細節將會在下一篇文章中揭曉?