👀樊梓慕:個人主頁
?🎥個人專欄:《C語言》《數據結構》《藍橋杯試題》《LeetCode刷題筆記》《實訓項目》《C++》《Linux》《算法》
🌝每一個不曾起舞的日子,都是對生命的辜負
目錄
前言
1.Linux線程互斥
1.1互斥量的接口
1.1.1初始化互斥量
?1.1.2銷毀動態分配的互斥量
1.1.3互斥量加鎖
1.1.4互斥量解鎖
1.2利用RAII思想封裝一個管理互斥量的對象??
1.3如何保證申請鎖的過程是原子的?
2.Linux線程同步
2.1條件變量
2.1.1初始化條件變量
2.1.2銷毀動態分配的條件變量
2.1.3等待條件變量滿足?
2.1.4喚醒等待的線程
2.2條件變量函數使用規范
3.可重入VS線程安全
3.1概念
3.2常見的線程不安全的情況
3.3常見的線程安全的情況
3.4常見的不可重入的情況
3.5常見的可重入的情況
3.6可重入與線程安全聯系
3.7可重入與線程安全區別
4.常見鎖概念
4.1死鎖
4.2死鎖的四個必要條件
前言
本篇文章內容:線程互斥、互斥量的使用、線程同步、條件變量的使用、可重入函數與線程安全相關內容。
歡迎大家📂收藏📂以便未來做題時可以快速找到思路,巧妙的方法可以事半功倍。?
=========================================================================
GITEE相關代碼:🌟樊飛 (fanfei_c) - Gitee.com🌟
=========================================================================
1.Linux線程互斥
首先我們先來學習一組概念:
- 臨界資源:?多線程執行流共享的資源叫做臨界資源(全局變量)。
- 臨界區:?每個線程內部,訪問臨界資源的代碼,就叫做臨界區(訪問或修改臨界資源的代碼)。
- 互斥:?任何時刻,互斥保證有且只有一個執行流進入臨界區,訪問臨界資源,通常對臨界資源起保護作用。
- 原子性:?不會被任何調度機制打斷的操作,該操作只有兩態,要么完成,要么未完成。
原子性:如果操作只有一條匯編指令,那么該操作就是原子的。
為了更好的理解,這里模擬實現一個搶票系統,我們將記錄票的剩余張數的變量定義為全局變量,主線程創建四個新線程,讓這四個新線程進行搶票,當票被搶完后這四個線程自動退出。
#include <iostream>
#include <unistd.h>
#include <pthread.h>int tickets = 1000;
void *route(void *arg)
{const char *name = (char *)arg;while (1){if (tickets > 0){usleep(10000);std::cout << name << " get a ticket, remain: " << --tickets << std::endl;}else{break;}}std::cout << name << "quit!" << std::endl;pthread_exit((void *)0);
}
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;
}
?奇怪的是,最后剩余的票數為負值。
我們明明判斷了當tickets>0時,才會對總票數--,可為什么這里是負值呢?
- if語句判斷條件為真以后,代碼可以并發的切換到其他線程。
- usleep用于模擬漫長業務的過程,在這個漫長的業務過程中,可能有很多個線程會進入該代碼段。
--tickets
操作本身就不是一個原子操作。
--tickets的操作并不是原子操作,因為它對應著三條匯編指令:
load
:將共享變量tickets從內存加載到寄存器中。update
:更新寄存器里面的值,執行-1操作。store
:將新值從寄存器寫回共享變量tickets的內存地址。
要解決以上問題,需要做到三點:?
- 代碼必須有互斥行為:當代碼進入臨界區執行時,不允許其他線程進入該臨界區。
- 如果多個線程同時要求執行臨界區的代碼,并且此時臨界區沒有線程在執行,那么只能允許一個線程進入該臨界區。
- 如果線程不在臨界區中執行,那么該線程不能阻止其他線程進入臨界區。
要做到以上三點,本質上就是需要一把鎖,進入臨界區之前加鎖,離開臨界區后解鎖,Linux上提供的這把鎖叫做互斥量mutex。
1.1互斥量的接口
有關于互斥量的操作非常簡單,初始化互斥量,銷毀互斥量,上鎖,解鎖等,并且我們還可以利用RAII的思想來管理動態分配的互斥量,具體如何使用都可以。
1.1.1初始化互斥量
對互斥量的初始化,我們有兩種方式,一種是靜態分配,另一種是動態分配。
靜態分配就是當互斥量是全局或者static時使用:
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;//PTHREAD_MUTEX_INITIALIZER是一個宏
對于這種全局或static互斥量,不需要銷毀。
動態分配就是當互斥量是局部時,利用pthread_mutex_init函數初始化,然后使用(動態分配的互斥量需要銷毀):?
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
參數說明:
- mutex:需要初始化的互斥量。
- attr:初始化互斥量的屬性,一般設置為nullptr即可。
返回值說明:
- 互斥量初始化成功返回0,失敗返回錯誤碼。
?1.1.2銷毀動態分配的互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
參數說明:
- mutex:需要銷毀的互斥量。
返回值說明:
- 互斥量銷毀成功返回0,失敗返回錯誤碼。
銷毀互斥量需要注意:
- 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要銷毀。
- 不要銷毀一個已經加鎖的互斥量。
1.1.3互斥量加鎖
int pthread_mutex_lock(pthread_mutex_t *mutex);
參數說明:
- mutex:需要加鎖的互斥量。
返回值說明:
- 互斥量加鎖成功返回0,失敗返回錯誤碼。
調用pthread_mutex_lock時,可能會遇到以下情況:
- 互斥量處于未鎖狀態,該函數會將互斥量鎖定,同時返回成功。
- 發起函數調用時,其他線程已經鎖定互斥量,或者存在其他線程同時申請互斥量,但沒有競爭到互斥量,那么pthread_mutex_lock調用會陷入阻塞(執行流被掛起),等待互斥量解鎖。
1.1.4互斥量解鎖
int pthread_mutex_unlock(pthread_mutex_t *mutex);
參數說明:
- mutex:需要解鎖的互斥量。
返回值說明:
- 互斥量解鎖成功返回0,失敗返回錯誤碼。
1.2利用RAII思想封裝一個管理互斥量的對象??
#ifndef __LOCK_GUARD_HPP__
#define __LOCK_GUARD_HPP__#include <iostream>
#include <pthread.h>class LockGuard
{
public:LockGuard(pthread_mutex_t *mutex):_mutex(mutex){pthread_mutex_lock(_mutex); // 構造加鎖}~LockGuard(){pthread_mutex_unlock(_mutex);}
private:pthread_mutex_t *_mutex;
};#endif
?有了這個類,你就可以將互斥量交給該類管理,當該類構造時,進行加鎖,當該類析構時解鎖。
1.3如何保證申請鎖的過程是原子的?
上面大家已經意識到了--和++操作不是原子操作,可能會導致數據不一致問題。
其實為了實現互斥鎖操作,大多數體系結構都提供了swap或exchange指令,該指令的作用就是把寄存器和內存單元的數據相交換。
由于只有一條匯編指令,保證了原子性。
加鎖和解鎖的過程我們可以通過下面的偽代碼來理解:
下面我們再以圖示來理解:
?圖示中我們在內存中定義了一個mutex互斥量,當我們進行加鎖時,cpu會原子地將兩個數據進行交換,?交換后,內存中變為0,寄存器%al中變為1,并且這個1的值是唯一的,因為數據在內存中時,所有線程都能訪問,屬于共享的,但是如果轉移到CPU內部寄存器中,就屬于一個線程私有的了,因為CPU寄存器內部的數據是線程的硬件上下文。
所以這個1的值就是唯一的,當任何一個線程將這個1交換走后(交換是原子的),哪怕此時線程的時間片到了被切換走,這個1也被線程作為硬件上下文帶走了,別的線程也拿不到,判斷時也會認為該鎖被占用了。
2.Linux線程同步
同步:?在保證數據安全的前提下,讓線程能夠按照某種特定的順序訪問臨界資源,從而有效避免饑餓問題,這就叫做同步。
那么什么情況下需要同步呢?
如果個別的線程競爭能力非常強,?每次都能夠申請到鎖,但申請到鎖之后什么也不做,所以在我們看來這個線程就一直在申請鎖和釋放鎖,這就可能導致其他線程長時間競爭不到鎖,引起饑餓問題。
那如何解決呢?
我們可以讓線程每次釋放鎖后,不能立即申請鎖,而是去排隊,有順序的申請鎖。
2.1條件變量
條件變量就是實現線程同步的解決方案,是用來描述某種資源是否就緒的一種數據化描述。
換句話說,我們可以利用條件變量實現對其他線程的控制。
比如:
- 控制某個線程在某一條件變量下等待;
- 條件滿足后,對該線程進行喚醒。
2.1.1初始化條件變量
對條件變量的初始化,我們有兩種方式,一種是靜態分配,另一種是動態分配。
靜態分配就是當條件變量是全局或者static時使用:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//PTHREAD_COND_INITIALIZER是一個宏
對于這種全局或static互斥量,不需要銷毀。
動態分配就是當條件變量是局部時,利用pthread_cond_init函數初始化,然后使用(動態分配的條件變量需要銷毀):?
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
參數說明:
- cond:需要初始化的條件變量。
- attr:初始化條件變量的屬性,一般設置為nullptr即可。
返回值說明:
- 條件變量初始化成功返回0,失敗返回錯誤碼。
2.1.2銷毀動態分配的條件變量
int pthread_cond_destroy(pthread_cond_t *cond);
參數說明:
- cond:需要銷毀的條件變量。
返回值說明:
- 條件變量銷毀成功返回0,失敗返回錯誤碼。
銷毀互斥量需要注意:
- 使用PTHREAD_COND_INITIALIZER初始化的條件變量不需要銷毀。
2.1.3等待條件變量滿足?
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
參數說明:
- cond:讓線程在該條件變量下等待。
- mutex:當前線程所處臨界區對應的互斥鎖(為什么要傳互斥鎖???)。
返回值說明:
- 函數調用成功返回0,失敗返回錯誤碼。
2.1.4喚醒等待的線程
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
區別:
- pthread_cond_signal函數用于喚醒等待隊列中首個線程。
- pthread_cond_broadcast函數用于喚醒等待隊列中的全部線程。
參數說明:
- cond:喚醒在cond條件變量下等待的線程。
返回值說明:
- 函數調用成功返回0,失敗返回錯誤碼。
等待條件變量滿足的函數參數為什么要傳互斥鎖?
- 首先你需要明確的是,使用條件變量一定會搭配使用互斥鎖,因為線程同步的場景本身就是在互斥的前提下,即兩個線程訪問同一資源(臨界資源),而現在需要保證的是資源使用的順序性,所以才引入了條件變量。
- 而如果此時某個線程由于條件并不滿足(這個條件不滿足一定是臨界資源不滿足該線程運行的條件),被設置了等待條件變量滿足,從而進入了阻塞狀態,能使條件滿足一定是該臨界資源被修改了,從而滿足了線程的運行需要,所以此時你就可以喚醒等待的線程。
- 也就是說我們想要讓條件得到滿足,就一定會修改臨界資源,而如果你在等待條件變量滿足的時候,仍然持有著該臨界資源的鎖,那么就會導致其他能使該臨界資源滿足線程運行需要的其他線程訪問不了該臨界資源,所以給等待條件變量滿足的函數傳入互斥鎖的目的就是讓這個鎖臨時被釋放,讓其他線程可以訪問該臨界資源。
總結:
- 等待的時候往往是在臨界區內等待的,當該線程進入等待的時候,互斥鎖會自動釋放,而當該線程被喚醒時,又會自動獲得對應的互斥鎖。
- 條件變量需要配合互斥鎖使用,其中條件變量是用來完成同步的,而互斥鎖是用來完成互斥的。
- pthread_cond_wait函數有兩個功能,一就是讓線程在特定的條件變量下等待,二就是讓線程釋放對應的互斥鎖。
2.2條件變量函數使用規范
等待條件變量的代碼
pthread_mutex_lock(&mutex);
while (條件為假)pthread_cond_wait(&cond, &mutex);
修改條件
pthread_mutex_unlock(&mutex);
喚醒等待線程的代碼
pthread_mutex_lock(&mutex);
設置條件為真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
3.可重入VS線程安全
3.1概念
- 線程安全: 多個線程并發同一段代碼時,不會出現不同的結果。常見對全局變量或者靜態變量進行操作,并且沒有鎖保護的情況下,會出現線程安全問題。
- 重入: 同一個函數被不同的執行流調用,當前一個流程還沒有執行完,就有其他的執行流再次進入,我們稱之為重入。一個函數在重入的情況下,運行結果不會出現任何不同或者任何問題,則該函數被稱為可重入函數,否則是不可重入函數。
注意: 線程安全討論的是線程執行代碼時是否安全,重入討論的是函數被重入進入。
3.2常見的線程不安全的情況
- 不保護共享變量的函數。
- 函數狀態隨著被調用,狀態發生變化的函數。
- 返回指向靜態變量指針的函數。
- 調用線程不安全函數的函數。
3.3常見的線程安全的情況
- 每個線程對全局變量或者靜態變量只有讀取的權限,而沒有寫入的權限,一般來說這些線程是安全的。
- 類或者接口對于線程來說都是原子操作。
- 多個線程之間的切換不會導致該接口的執行結果存在二義性。
3.4常見的不可重入的情況
- 調用了malloc/free函數,因為malloc函數是用全局鏈表來管理堆的。
- 調用了標準I/O庫函數,標準I/O可以的很多實現都是以不可重入的方式使用全局數據結構。
- 可重入函數體內使用了靜態的數據結構。
3.5常見的可重入的情況
- 不使用全局變量或靜態變量。
- 不使用malloc或者new開辟出的空間。
- 不調用不可重入函數。
- 不返回靜態或全局數據,所有數據都由函數的調用者提供。
- 使用本地數據,或者通過制作全局數據的本地拷貝來保護全局數據。
3.6可重入與線程安全聯系
- 函數是可重入的,那就是線程安全的。
- 函數是不可重入的,那就不能由多個線程使用,有可能引發線程安全問題。
- 如果一個函數中有全局變量,那么這個函數既不是線程安全也不是可重入的。
3.7可重入與線程安全區別
- 可重入函數是線程安全函數的一種。
- 線程安全不一定是可重入的,而可重入函數則一定是線程安全的。
- 如果對臨界資源的訪問加上鎖,則這個函數是線程安全的,但如果這個重入函數的鎖還未釋放則會產生死鎖,因此是不可重入的。
可重入函數一定是線程安全的,函數不可重入是引發線程安全問題的一種常見情況。?
4.常見鎖概念
4.1死鎖
死鎖(Deadlock)是數據庫系統、操作系統或并發編程中常見的一種現象,它指的是兩個或兩個以上的進程(或線程)在執行過程中,由于競爭資源或者由于彼此通信而造成的一種阻塞的現象,若無外力作用,它們都將無法向前推進。此時稱系統處于死鎖狀態或系統產生了死鎖,這些永遠在互相等待的進程稱為死鎖進程。
舉例:假設有兩個進程P1和P2,它們都需要兩個資源R1和R2。如果P1獲得了R1并請求R2,同時P2獲得了R2并請求R1,那么這兩個進程都會阻塞等待對方釋放資源,從而導致死鎖。
單執行流可能產生死鎖嗎?
單執行流也有可能產生死鎖,如果某一執行流連續申請了兩次鎖,那么此時該執行流就會被掛起。
因為該執行流第一次申請鎖的時候是申請成功的,但第二次申請鎖時因為該鎖已經被申請過了,于是申請失敗導致被掛起直到該鎖被釋放時才會被喚醒,但是這個鎖本來就在自己手上,自己現在處于被掛起的狀態根本沒有機會釋放鎖,所以該執行流將永遠不會被喚醒,此時該執行流也就處于一種死鎖的狀態。
4.2死鎖的四個必要條件
- 互斥條件: 一個資源每次只能被一個執行流使用。
- 請求與保持條件: 一個執行流因請求資源而阻塞時,對已獲得的資源保持不放。
- 不剝奪條件: 一個執行流已獲得的資源,在未使用完之前,不能強行剝奪。
- 循環等待條件: 若干執行流之間形成一種頭尾相接的循環等待資源的關系。
注意: 這是死鎖的四個必要條件,也就是說只有同時滿足了這四個條件才可能產生死鎖。
避免死鎖
- 破壞死鎖的四個必要條件。
- 按順序申請資源。
- 避免鎖未釋放的場景。
- 資源一次性分配。
除此之外,還有一些避免死鎖的算法,比如死鎖檢測算法和銀行家算法。
?=========================================================================
如果你對該系列文章有興趣的話,歡迎持續關注博主動態,博主會持續輸出優質內容
🍎博主很需要大家的支持,你的支持是我創作的不竭動力🍎
🌟~ 點贊收藏+關注 ~🌟
=========================================================================