📃個人主頁:island1314
🔥個人專欄:Linux—登神長階
?? 歡迎關注:👍點贊 👂🏽留言 😍收藏 ?💞 💞 💞
1. 線程安全和重入問題🚀
1.1 基本概念?
- 線程安全:就是多個線程在訪問共享資源時,能夠正確地執行,不會相互干擾或破壞彼此的執行結果。一般而言,多個線程并發同一段只有局部變量的代碼時,不會出現不同的結果。但是對全局變量或者靜態變量進行操作,并且沒有鎖保護的情況下,容易出現該問題。
- 重入:同一個函數被不同的執行流調用,當前一個流程還沒有執行完,就有其他的執行流再次進入,我們稱之為重入。一個函數在重入的情況下,運行結果不會出現任何不同或者任何問題,則該函數被稱為可重入函數,否則,是不可重入函數。
1.2 線程安全和重入情況?
🍥?學到現在,其實我們已經能理解重入其實可以分為兩種情況
- 多線程重入函數
- 信號導致一個執行流重復進入函數
① 常見的線程不安全的情況
- 不保護共享變量的函數
- 函數狀態隨著被調用,狀態發生變化的函數
- 返回指向靜態變量指針的函數
- 調用線程不安全函數的函數
② 常見的線程安全的情況
- 每個線程對全局變量或者靜態變量只有讀取的權限,而沒有寫入的權限,一般來說這些線程是安全的
- 類或者接口對于線程來說都是原子操作
- 多個線程之間的切換不會導致該接口的執行結果存在二義性?
③ 常見不可重入的情況
- 調用了malloc/free函數,因為malloc函數是用全局鏈表來管理堆的
- 調用了標準I/O庫函數,標準I/O庫的很多實現都以不可重入的方式使用全局數據結構
- 可重入函數體內使用了靜態的數據結構?
④ 常見可重入的情況
- 不使用全局變量或靜態變量
- 不使用用malloc或者new開辟出的空間
- 不調用不可重入函數
- 不返回靜態或全局數據,所有數據都有函數的調用者提供
- 使用本地數據,或者通過制作全局數據的本地拷貝來保護全局數據?
提示 :不要被上面的一系列所弄暈,其實對應概念說的都是一回事
1.3?線程安全和重入的聯系區別
📌 可重入與線程安全聯系
- 函數是可重入的,那就是線程安全的
- 函數是不可重入的,那就不能由多個線程使用,有可能引發線程安全問題
- 如果一個函數中有全局變量,那么這個函數既不是線程安全也不是可重入的。?
📌 可重入與線程安全區別
- 可重入函數是線程安全函數的一種
- 線程安全不一定是可重入的,而可重入函數則一定是線程安全的。
- 如果將對臨界資源的訪問加上鎖,則這個函數是線程安全的,但如果這個重入函數若鎖還未釋放則會產生死鎖,因此是不可重入的。?
📌 注意:
- 如果不考慮 信號導致一個執行流重復進入函數 這種重入情況,線程安全和重入在安全角度不做區分
- 但是線程安全側重說明線程訪問公共資源的安全情況,表現的是 并發線程 的特點
- 可重入描述的是一個函數是否能被重復進入,表示的是 函數 的特點
2. 死鎖?🖊
2.1 死鎖基本概念
-
死鎖是指在一組進程中的各個進程均占有不會釋放的資源,但因互相申請被其他進程所站用不會釋放的資源而處于的一種永久等待狀態。
- 為了方便表述,假設現在線程A,線程B必須同時持有 鎖1和 鎖2 ,才能進行后續資源的訪問
- 🥑 申請一把鎖是原子的,但是申請兩把鎖就不一定了??????
- 🥑 造成的結果如下:
2.2 死鎖的四個必要條件
- 互斥條件:一個資源每次只能被一個執行流使用
- 請求與保持條件:一個執行流因請求資源而阻塞時,對已獲得的資源保持不放
- 不剝奪條件:一個執行流已獲得的資源,在末使用完之前,不能強行剝奪
- 循環等待條件:若干執行流之間形成一種頭尾相接的循環等待資源的關系?
2.3 避免死鎖
- 破壞死鎖的四個必要條件
- 破壞循環條件等待問題:資源一次性分配,使用超時機制,加鎖順序一致
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
#include <unistd.h>// 定義兩個共享資源(整數變量)和兩個互斥鎖
int shared_resource1 = 0;
int shared_resource2 = 0;
std::mutex mtx1, mtx2;// ?個函數,同時訪問兩個共享資源
void access_shared_resources()
{std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);// 使? std::lock 同時鎖定兩個互斥鎖std::lock(lock1, lock2);// 現在兩個互斥鎖都已鎖定,可以安全地訪問共享資源int cnt = 10000;while (cnt--){++shared_resource1;++shared_resource2;}// 當離開 access_shared_resources 的作?域時,lock1 和 lock2 的析構函數會被自動調?// 這會導致它們各?的互斥量被?動解鎖
}// 模擬多線程同時訪問共享資源的場景
void simulate_concurrent_access()
{std::vector<std::thread> threads;// 創建多個線程來模擬并發訪問for (int i = 0; i < 10; ++i){threads.emplace_back(access_shared_resources);}// 等待所有線程完成for (auto &thread : threads){thread.join();}// 輸出共享資源的最終狀態std::cout << "Shared Resource 1: " << shared_resource1 << std::endl;std::cout << "Shared Resource 2: " << shared_resource2 << std::endl;
}int main()
{simulate_concurrent_access();return 0;
}
一次申請
不一次申請
- 避免鎖未釋放場景
2.4 死鎖的預防
🔥 死鎖的預防是通過破壞產生死鎖的必要條件之一,是系統不會產生死鎖。
- 簡單方法是在系統運行之前就采取措施,即在系統設計時確定資源分配算法,消除發生死鎖的任何可能性。該方法雖然比較保守、資源利用率低,但因簡單明了并且安全可靠,仍被廣泛采用。這是一種預先的靜態策略。
🥝 破壞互斥條件
🎐 互斥條件:只有對必須互斥使用的資源的爭搶才會導致死鎖。
如果把只能互斥使用的資源改造為允許共享使用,則系統不會進入死鎖狀態。比如:SPOOLing 技術。操作系統可以采用?SPOOLing 技術? 把獨占設備在邏輯上改造成共享設備。比如,用SPOOLing 技術 將打印機改造為共享設備..
-
該策略的缺點:并不是所有的資源都可以改造成可共享使用的資源。并且為了系統安全,很多地方還必須保護這種互斥性。因此,很多時候都無法破壞互斥條件。
🥝 破壞不可剝奪條件
?💢 不剝奪條件:進程所獲得的資源在未使用完之前,不能由其他進程強行奪走,只能主動釋放。
破壞不剝奪條件
- 當某個進程請求新的資源得不到滿足時,它必須立即釋放保持的所有資源,待以后需要時再重新申請。也就是說,即使某些資源尚未使用完,也需要主動釋放,從而破壞了不可剝奪條件。
- 當某個進程需要的資源被其他進程所占有的時候,可以由操作系統協助,將想要的資源強行剝奪。這種方式一般需要考慮各進程的優先級(比如:剝奪調度方式,就是將處理機資源強行剝奪給優先級更高的進程使用)
該策略的缺點
- 實現起來比較復雜。
- 釋放已獲得的資源可能造成前一階段工作的失效。因此這種方法一般只適用于易保存和恢復狀態的資源,如CPU。
- 反復地申請和釋放資源會增加系統開銷,降低系統吞量。
- 若采用方案一,意味著只要暫時得不到某個資源,之前獲得的那些資源就都需要放棄,以后再重新申請。如果一直發生這樣的情況,就會導致進程饑餓。
🥝 破壞請求并保持條件
請求和保持條件:進程已經保持了至少一個資源,但又提出了新的資源請求,而該資源又被其他進程占有,此時請求進程被阻塞,但又對自己己有的資源保持不放。
- 可以采用靜態分配方法,即進程在運行前一次申請完它所需要的全部資源,在它的資源未滿足前,不讓它投入運行。一旦投入運行后,這些資源就一直歸它所有,該進程就不會再請求別的任何資源了。
該策略實現起來簡單,但也有明顯的缺點:有些資源可能只需要用很短的時間,因此如果進程的整個運行期間都一直保持著所有資源,就會造成嚴重的資源浪費,資源利用率極低。另外,該策略也有可能導致某些進程饑餓。
🥝 破壞循環等待條件
循環等待條件:存在一種進程資源的循環等待鏈,鏈中的每一個進程已獲得的資源同時被下一個進程所請求。
可采用順序資源分配法。首先給系統中的資源編號,規定每個進程必須按編號遞增的順序請求資源同類資源(即編號相同的資源)一次申請完。
原理分析:一個進程只有已占有小編號的資源時,才有資格申請更大編號的資源。按此規則,已持有大編號資源的進程不可能逆向地回來申請小編號的資源,從而就不會產生循環等待的現象。
該策略的缺點:
- 不方便增加新的設備,因為可能需要重新分配所有的編號
- 進程實際使用資源的順序可能和編號遞增順序不一致,會導致資源浪費
- 必須按規定次序申請資源,用戶編程麻煩。
2.5 死鎖的避免
避免死鎖同樣屬于事先預防策略,并不是采取某種限制措施破壞死鎖的必要條件,而是在資源動態分配過程中,防止系統進入不完全狀態。
🍉 安全序列
進程可以動態的申請資源,但是系統在進行資源分配之前,必須先計算此次分配的安全性。如果計算所得是安全的,則允許分配,但如果是不安全的,則讓進程等待。而所謂的安全狀態就是,系統可以按照某種進程的推進順序
這里舉了一個銀行給BAT三家公司借錢的例子用來引出銀行家算法?
這時候如果將 30億 借給了B公司,那么手里還有 10億元,這 10億 已經小于3家公司最小的最多還會借的錢數,沒有公司能夠達到提出的最大要求,這樣銀行的錢就會打水漂了!!!
如果是這種情況呢?
這樣按照T->B->A的順序借錢是沒有問題的,是安全的。
?按照A->T->B的順序借錢也是沒有問題的。
這樣我們就會得到安全序列、不安全序列和死鎖的關系了
?注意:
(1)系統在某一時刻的安全狀態可能不唯一,但這不影響對系統安全性的判斷。
(2)安全狀態是非死鎖狀態,而不安全狀態并不一定是死鎖狀態。即系統處于安全狀態一定可以避免死鎖,而系統處于不安全狀態則僅僅可能進入死鎖狀態。
原因是如果進入了不安全狀態,但是沒有進程去請求資源,并且有進程提前歸還了一些資源,這樣就不會死鎖。
🍉 銀行家算法
銀行家算法是荷蘭學者 Dijkstra 為銀行系統設計的,以確保銀行在發放現金貸款時,不會發生不能滿足所有客戶需要的情況。后來該算法被用在操作系統中,用于避免死鎖。
- 核心思想:在進程提出資源申請時,先預判此次分配是否會導致系統進入不安全狀態。如果會進入不安全狀態,就暫時不答應這次請求,讓該進程先阻塞等待。
- 換言之,遍歷所有的進程,比對當前的空閑資源數量和該進程仍然需要的資源數,判斷是否滿足最大需求,滿足則將這個進程加入安全序列,更新回收進程釋放的資源,不滿足則跳過該進程,依次循環檢測。
銀行家問題的本質:
要設法保證系統動態分配資源后不進入不安全狀態,以避免可能產生的死鎖。
即:每當進程提出資源請求且系統的資源能夠滿足該請求時,系統將判斷如果滿足此次資源請求,系統狀態是否安全,如果判斷結果為安全,則給該進程分配資源,否則不分配資源,申請資源的進程將阻塞。
當Pi發出資源請求后,系統按下述步驟進行檢查:
1. 如果Requesti?> Needi,則出錯。
2. 如果Requesti>Available,則Pi?阻塞;
3. 系統試探把要求的資源分配給進程Pi,并修改下面數據結構中的數值:Availablei=Availablei-Requesti;Allocationi=Allocationi+Requesti;Needi?= Needi- Requesti;
4. 系統執行安全性算法,檢查此次資源分配后,系統是否處于安全狀態。若安全,正式將資源分配給進程Pi,以完成本次分配;否則,將試探分配作廢,恢復原來的資源分配狀態,讓進程Pi等待。
數據結構:
- 長度為 m 的一維數組 Available 表示還有多少可用資源
- n*m 矩陣 Max 表示各進程對資源的最大需求數
- n*m 矩陣 Allocation 表示已經給各進程分配了多少資源
- Max-Allocation =Need 矩陣表示各進程最多還需要多少資源
- 用長度為 m 的一位數組 Request 表示進程此次申請的各種資源數
銀行家算法步驟:
- ① 檢查此次申請是否超過了之前聲明的最大需求數
- ② 檢查此時系統剩余的可用資源是否還能滿足這次請求
- ③ 試探著分配,更改各數據結構
- ④ 用安全性算法檢查此次分配是否會導致系統進入不安全狀態
安全性算法步驟:
- 檢查當前的剩余可用資源是否能滿足某個進程的最大需求,如果可以,就把該進程加入安全序列,并把該進程持有的資源全部回收。
- 不斷重復上述過程,看最終是否能讓所有進程都加入安全序列。
🧀 銀行家算法從避免死鎖的角度上說是非常有效的,但是,從某種意義上說,它缺乏實用價值,因為很少有進程能夠在運行前就知道其所需資源的最大值,而且進程數也不是固定的,往往在不斷地變化(如新用戶登錄或退出),況且原本可用的資源也可能突然間變成不可用(如磁帶機可能壞掉)。因此,在實際中,如果有,也只有極少的系統使用銀行家算法來避免死鎖。
2.6 死鎖檢測及解除
???🔥 死鎖預防和避免算法,其實都是在進程分配資源的時候試加限制條件或者檢測,但是如果系統為進程分配資源時不采取任何措施,則應該提供死鎖檢測和解除的手段。
- 死鎖的檢測和恢復技術是指定期啟動一個軟件檢測系統的狀態,若發現有死鎖存在,則采取措施恢復之。
🥑 死鎖檢測?
為了能對系統是否已發生了死鎖進行檢測,必須:
- 用某種數據結構來保存資源的請求和分配信息。
- 提供一種算法,利用上述信息來檢測系統是否已進入死鎖狀態。
🌮 如果系統中剩余的可用資源數足夠滿足進程的需求,那么這個進程暫時是不會阻塞的,可以順利地執行下去。如果這個進程執行結束了把資源歸還系統,就可能使某些正在等待資源的進程被激活,并順利地執行下去。相應的,這些被激活的進程執行完了之后又會歸還一些資源,這樣可能又會激活另外一些阻塞的進程..
如果按上述過程分析,最終能消除所有邊,就稱這個圖是 可完全簡化的。此時一定沒有發生死鎖(相當于能找到一個安全序列)
- 可以消除所有的邊,說明未發生死鎖,如果最終不能消除所有邊,那么此時就是發生了死鎖
死鎖的檢測可以利用資源分配圖來分析,該數據結構包含如下的內容
?檢測死鎖的算法如下:
🔥 在資源分配圖中,找出既不阻塞(請求資源節點的數量足夠)又不是孤點的進程pi?,該請求邊所申請的數量小于等于下同已有的空閑資源數量。所有的連接該進程的邊均滿足上述的條件,則這個進程就可以運行直至完成。然后釋放自己擁有的資源,消除進程的請求邊和分配邊,使后釋放自己擁有的資源,消除進程的請求邊和分配邊之成為孤立的節點。如果所有的節點可以被消除與其相連的邊,則成為該圖是可完全簡化的,而且一定不會發生死鎖。
- 對于可以消除所有的邊,則稱這個圖是可以簡化的,則一定沒有發生死鎖。
- 而如果最終不能消除所有邊,那么就一定發生了死鎖。
🎈 檢查死鎖的辦法結論:由軟件檢查系統中由進程和資源構成的有向圖是否構成一個或多個環路,若是,則存在死鎖,否則不存在。
由于死鎖是系統中的惡性小概率事件,死鎖檢測程序的多次執行往往都不會調用一次死鎖解除程序,而這卻增加了系統開銷,因此在設計操作系統時需要權衡檢測精度與時間開銷。
🥑 死鎖解除
一旦檢測出死鎖的發生,就應該立刻解除死鎖,死鎖的檢測就是通過簡化資源分配圖。解除死鎖的主要方法:
(1)撤消進程法
? 「撤消全部死鎖進程」:強制殺死該進程,剝奪這些進程的資源。雖然實現簡單,但是付出的代價可能會很大,部分進程很可能運行了很長時間,但是被殺之后,功虧一簣。代價太大,該做法很少用。
? 「最小代價撤消法」:首先計算死鎖進程的撤消代價,然后依次選擇撤消代價最小的進程,逐個地撤消死鎖進程,回收資源給其他進程,直至死鎖不復存在。進程的撤消代價往往與進程的優先級、占用處理機的時間等成正比。
(2)掛起進程法 (剝奪資源)
??「資源剝奪法」: 使用 掛起/激活 機構掛起一些進程,剝奪它們的資源以解除死鎖,并將這些資源分配給其他的死鎖進程,待條件滿足時,再激活進程。目前掛起法比較受到重視。?
🔥 顯然,無論哪一種解除死鎖的方法,都需要很大的開銷。但是死鎖的檢測與解除辦法不對系統的資源分配等加任何限制,因此是對付死鎖的諸辦法中導致資源利用率最高的一種辦法,在對安全性要求高的大型系統中常用。
3. STL 智能指針 線程安全?🐋
之前在這篇博客?【C++高階】:智能指針的全面解析_智能指針詳解??里面已經講過智能指針的內容,感興趣的可以看看這篇文章
3.1 STL中的容器是否是線程安全的? ?
不是,原因是:STL 的設計初衷是將性能挖掘到極致,?而一旦涉及到加鎖保證線程安全,?會對性能造成巨大的影響。?而且對于不同的容器,?加鎖方式的不同,?性能可能也不同(例如hash表的鎖表和鎖桶)。?因此 STL 默認不是線程安全,?如果需要在多線程環境下使用,往往需要調用者自行保證線程安全
3.2 智能指針是否是線程安全的??
對于 unique_ptr,由于只是在當前代碼塊范圍內生效, 因此不涉及線程安全問題. 對于 shared_ptr, 多個對象需要共用一個引用計數變量,?所以會存在線程安全問題. 但是標準庫實現的時候考慮到了這 個問題, 基于原子操作(CAS)的方式保證 shared_ptr 能夠高效, 原子的操作引用計數.?
3.3 其他常見的各種鎖
- 悲觀鎖:在每次取數據時,總是擔心數據會被其他線程修改,所以會在取數據前先加鎖(讀鎖,寫鎖,行鎖等),當其他線程想要訪問數據時,被阻塞掛起。
- 樂觀鎖:每次取數據時候,總是樂觀的認為數據不會被其他線程修改,因此不上鎖。但是在更新數據前, 會判斷其他數據在更新前有沒有對數據進行修改。主要采用兩種方式:版本號機制和CAS操作。
- CAS操作:當需要更新數據時,判斷當前內存值和之前取得的值是否相等。如果相等則用新值更新。若不等則失敗,失敗則重試,一般是一個自旋的過程,即不斷重試。?
- 以及讀寫鎖和自旋鎖?【Linux】:多線程(讀寫鎖 && 自旋鎖)?這篇博客里面有詳細說明
4. 補充 -- 深度理解互斥 📚
🔥 之前在這篇文章里面 【Linux】:多線程(互斥 && 同步)?我們已經了解了互斥的一些內容,并且手搓實現互斥量 Mutex 的封裝,現在對其來進行一個更詳細的理解
4.1 加鎖粒度
🔥 加鎖粒度(Lock Granularity)指的是在多線程或多進程程序中,鎖定資源的范圍或粒度大小。鎖粒度越大,所保護的資源越多;鎖粒度越小,保護的資源就越少
🍧?常見的加鎖粒度類型:
- 粗粒度鎖:
- 定義:鎖住較大的資源范圍,通常是整個數據結構或對象。
- 優點:實現簡單,容易理解和使用。
- 缺點:會導致較多的線程等待,即使它們并不需要訪問整個資源,容易引起性能瓶頸。
- 例子:鎖住整個數據結構(例如一個大數組或鏈表),即使每次只需要對其中的一部分進行修改。
- 細粒度鎖:
- 定義:鎖住較小的資源范圍,通常是數據結構中的單一元素或較小的部分。
- 優點:減少了線程間的競爭,提高了并發性和性能。
- 缺點:實現較復雜,可能需要更多的鎖管理和協調,容易引發死鎖。
- 例子:對數據結構中的每個元素或節點加鎖,或者使用不同的鎖保護數據的不同部分。
- 無鎖(Lock-Free):
- 定義:在某些情況下,避免使用顯式的鎖,而是通過原子操作或其他技術來保證線程安全。
- 優點:避免了鎖帶來的性能問題,減少了上下文切換和線程競爭。
- 缺點:實現復雜,調試和維護難度較大。
- 例子:使用原子操作(如
std::atomic
)進行數據更新
🍧?選擇加鎖粒度時的考慮因素:
- 性能:粗粒度鎖可能導致不必要的等待,而細粒度鎖可以提高并發性,減少線程間的競爭。
- 復雜度:細粒度鎖的管理復雜度較高,需要考慮死鎖和鎖的順序問題。
- 資源沖突:細粒度鎖適用于多個線程需要訪問不同部分的情況,而粗粒度鎖適用于訪問的資源較少,或資源沖突較少的情況。
🍧?加鎖粒度越小越好(理解):
- 減少阻塞范圍:較小的鎖粒度意味著臨界區代碼更短,線程持有鎖的時間更短。當一個線程持有鎖時,其他線程必須等待。因此,縮短鎖的持有時間可以減少線程間的等待時間,提高并發性能,降低因線程阻塞導致的上下文切換開銷。
- 避免死鎖可能性:細粒度鎖有助于減少鎖的嵌套,從而降低死鎖的風險。當多個鎖需要按照特定順序獲取時,如果鎖的粒度較大,可能會導致復雜的鎖依賴關系,增加死鎖的可能性。較小的鎖粒度使得鎖的管理更為簡單,更容易避免死鎖。
- 提高系統可伸縮性:在高并發場景下,細粒度鎖允許更多的線程并行執行,因為它們可以在不影響其他線程所需資源的情況下獨立工作。大粒度鎖可能導致大量線程因爭奪同一鎖而陷入等待,限制了系統的并發能力。
🧀 舉例說明:
💦 假設在一個電商系統中,我們需要對訂單進行操作,其中包括更新商品庫存、修改訂單狀態、記錄用戶購買行為等多個步驟。如果采用單一的粗粒度鎖,比如在整個訂單服務中只有一個全局鎖,那么每次處理訂單變更時都會鎖定整個服務,即使各個操作之間并無直接的數據沖突。
// 大粒度鎖:鎖定整個訂單服務
mutex orderServiceLock;void processOrder(Order& order) {orderServiceLock.lock();updateProductInventory(order.productId, order.quantity); // 更新庫存modifyOrderStatus(order.id, ORDER_STATUS_COMPLETED); // 修改訂單狀態logUserPurchaseBehavior(order.userId, order.productId); // 記錄購買行為orderServiceLock.unlock();
}
🥎 在上述代碼中,任何一個請求處理都需要獲得全局鎖才能進行操作,這就意味著如果有多個訂單同時進來,必須逐個執行,無法并行處理,極大地降低了系統的并發性能。
而改為細粒度鎖方案,我們可以根據業務邏輯分別對不同的資源加鎖
// 細粒度鎖:按資源分別加鎖
mutex productInventoryLock;
mutex orderStatusLock;
mutex userPurchaseLogLock;void processOrder(Order& order) {productInventoryLock.lock();updateProductInventory(order.productId, order.quantity);productInventoryLock.unlock();orderStatusLock.lock();modifyOrderStatus(order.id, ORDER_STATUS_COMPLETED);orderStatusLock.unlock();userPurchaseLogLock.lock();logUserPurchaseBehavior(order.userId, order.productId);userPurchaseLogLock.unlock();
}
🔥 在這種情況下,如果三個操作涉及的是不同的資源(例如不同商品的庫存、不同訂單的狀態和用戶的購買歷史),那么即使在高并發情況下,不同訂單的部分操作也能并行執行,提高了系統的并發能力和整體性能。同時,由于鎖的粒度較小,各線程持有鎖的時間較短,減少了因爭搶鎖而導致的阻塞等待時間,進一步降低了死鎖的發生概率。
實現細粒度鎖的策略
- 局部變量加鎖:如果可能,盡量將鎖的范圍限制在局部變量上,而不是整個數據結構。例如,如果全局變量是一個容器(如列表或字典),不要對整個容器加鎖,而是針對每次插入、刪除或查找操作單獨加鎖。
- 分段鎖:對于大型數據結構,可以使用分段鎖(如Java中的ConcurrentHashMap)或細粒度鎖數組,將數據分成多個部分,每個部分有自己的鎖。這樣,即使在高并發情況下,不同的線程可以同時操作數據的不同部分,而不必全部等待同一把鎖。
- 原子操作:對于簡單的數值型全局變量,如果支持的話,可以使用原子操作(如AtomicInteger、std::atomic等)代替鎖。原子操作在硬件級別保證了操作的完整性,無需顯式加鎖,提供了極細粒度的同步。
- 無鎖數據結構和算法:在某些場景下,可以使用專門設計的無鎖數據結構和算法,它們通過CAS(Compare-and-Swap)等非阻塞同步原語來實現線程安全,進一步降低鎖的使用和開銷。
🔥 綜上所述,為了防止多線程訪問全局變量時互相影響,應使用加鎖機制確保訪問的原子性和一致性。同時,遵循 “加鎖粒度越小越好” 的原則,通過減少阻塞范圍、避免死鎖以及提高系統可伸縮性,來優化多線程程序的并發性能和穩定性。
4.2 深入理解互斥鎖
加鎖后,線程在臨界區中是否會切換,會有問題嗎?
答案是:會切換,但這并不會引起問題
① 為什么加鎖后線程被切換不會引起問題?
- 持有鎖的線程被切換:當一個線程獲得了鎖并進入了臨界區,即使由于某種原因(如時間片輪換、I/O操作等)導致該線程被切換到后臺,它依然持有鎖。這意味著其他線程不能進入臨界區,因為它們無法獲取已被占用的鎖。
- 保證數據一致性:盡管線程可能被切換,但只要它持有鎖,就能保證在它再次獲得CPU時間時,能夠繼續執行臨界區內的代碼,并完成對其共享資源的操作。因此,即使存在上下文切換,也不會破壞數據的一致性。
- 原子性:鎖的機制確保了對臨界區的訪問具有原子性,即要么完整地執行臨界區內的代碼,要么根本不執行。這意味著一旦線程獲取了鎖,即使被切換,它仍然是唯一可以繼續執行臨界區代碼的線程。
② 原子性體現
對于一個沒有持有鎖的線程2來說,它面臨的情況只有兩種:
- 線程1 沒有持有鎖:這意味著當前沒有線程正在訪問臨界區,臨界區處于空閑狀態。此時,這個線程可以嘗試獲取鎖,并開始執行臨界區內的代碼。
- 線程1 釋放鎖:這意味著線程1已經完成了對臨界區的訪問,并釋放了鎖。此時,其他等待的線程可以嘗試獲取鎖,進入臨界區執行。
當一個線程成功獲取鎖后,它就可以獨占臨界區內的資源,這意味著在這段時間內,其他線程不能進入臨界區執行代碼或訪問資源。這種操作被稱為原子性的,因為它要么完全發生(獲取鎖并執行臨界區代碼),要么根本不發生(無法獲取鎖,線程被阻塞)。
- 這種特性確保了共享資源的一致性和完整性。
③ 加鎖是否意味著串行執行?
是的,在使用互斥鎖保護的臨界區內,線程執行是串行的
🍒 具體來說,當一個線程成功獲取到互斥鎖并進入臨界區后,其他試圖獲取該鎖的線程將被阻塞,直到持有鎖的線程執行完畢臨界區代碼并釋放鎖。這種機制確保了在同一時刻,只有一個線程能夠訪問和修改受保護的共享資源(在這里是 tickets 變量)。盡管線程間的調度仍然是不確定的,但在互斥鎖的約束下,對臨界區的訪問是有序的、不可重疊的,從而實現了對共享資源的串行化訪問。
④ 鎖也是共享資源?
正確,鎖本身確實是一種共享資源,因為所有試圖訪問受保護資源的線程都需要與之交互
- 每個線程都必須先嘗試獲取這把鎖,只有成功獲取鎖的線程才能進入臨界區。其他線程在鎖被釋放之前只能等待。這種共享性是線程間協作的基礎,通過統一的鎖機制協調對共享資源的訪問順序。
⑤ 誰來保證鎖的安全?
🎂 確保鎖的安全是非常重要的,因為它直接關系到多線程程序的正確性和數據的一致性。鎖的安全性主要包括兩個方面:一是鎖本身的原子性,二是鎖使用的正確性。
鎖的原子性:鎖的原子性指的是鎖的申請和釋放操作必須是不可分割的,即這兩個操作要么全部完成,要么都不發生
- 申請鎖(加鎖):必須是一個原子操作,確保在申請鎖的過程中不會被其他線程打斷。
- 釋放鎖(解鎖):同樣必須是一個原子操作,確保在釋放鎖的過程中不會被其他線程打斷。
鎖使用的正確性:除了鎖本身的操作必須是原子的之外,還需要保證鎖在整個使用過程中是安全的,這包括但不限于:
- 互斥性:確保在任何時刻只有一個線程持有鎖。
- 死鎖避免:避免多個線程因互相等待對方釋放鎖而導致的死鎖情況。
- 資源訪問的正確性:確保在持有鎖的情況下,線程可以安全地訪問共享資源。
4.3 互斥鎖實現原理
互斥鎖實現原理(本質):以一條匯編的方式,將內存和CPU內寄存區數據進行交互
? 寄存器的本質
🏀 在計算機系統中,寄存器是一組小容量的高速存儲單元,它們位于CPU內部,用于暫存數據和指令。寄存器通常用于快速存儲和訪問臨時數據,如算術運算的中間結果、指針、狀態標志等。寄存器的速度遠快于主內存,因為它們直接集成在處理器內部,減少了數據傳輸的時間延遲。
寄存器作為當前執行流的上下文
? 在多線程或多執行流的視角下,寄存器可以被視為當前執行流的上下文。這里所說的“上下文”,是指執行流在某一時刻的所有必要狀態信息,包括但不限于寄存器的內容、程序計數器(PC)、棧指針(SP)等。
寄存器的空間共享與內容私有
🏸 雖然物理上的寄存器硬件是所有執行流所共享的,但寄存器的內容卻是每個執行流私有的。這意味著,雖然多個線程或執行流可能看起來共享同一組寄存器,但當一個執行流正在執行時,它會擁有這些寄存器內容的獨占使用權。具體而言:
- 寄存器的空間共享:從硬件層面來看,寄存器的物理地址是固定的,所有的執行流都指向這些固定的地址。
- 寄存器的內容私有:當一個多線程程序運行時,操作系統或硬件會為每個線程保存一組寄存器狀態。當線程被切換時,當前線程的寄存器內容會被保存下來,而新選擇的線程的寄存器內容會被加載到寄存器中。這個過程確保了每個線程在執行時看到的寄存器內容是自己獨有的。?
? 結合匯編偽代碼理解互斥鎖
lock和unlock匯編偽代碼
lock:movb $0,%alxchgb al,mutexif(al寄存器的內容>0){return 0;}else掛起等待;goto lock;
unlock:movb $1,mutex喚醒等待Mutex的線程;return 0;
鎖操作詳解
- 初始化寄存器:movb $0, %al 將 0 移動到寄存器 %al 中。
- 交換并獲取鎖:xchgb %al, mutex 將 %al 中的值與內存中的 mutex 交換。在創建鎖之后,mutex 的初始值通常是 1,如果 mutex 原本為 0,則表示鎖被占用。
- 比較并重試:cmpb $0, %al, 比較 %al 是否大于 0。如果是,則表示鎖已成功獲取;如果不是,則跳轉到 lock 標簽處重試。
- 解鎖操作:movb $1, mutex 將 1 寫入 mutex,表示解鎖。然后通過其他機制喚醒等待鎖的線程。
交換的現象:內存與%al 做交換
- lock:這部分是獲取互斥鎖的過程。首先將寄存器 al 設置為0(movb $0, %al),然后使用xchg?指令交換 al 的內容與 mutex 變量的值。如果mutex的初始值大于0,說明已經有其他線程持有該鎖,那么當前線程就返回0并掛起等待;否則,它會繼續執行并獲得鎖。
- unlock:這部分是釋放互斥鎖的過程。同樣地,將寄存器 al 設置為1(movb $1, mutex),然后喚醒等待 Mutex 的線程,并返回0。
🔥 對于 swap 或 exchange 這樣的指令,它們通常用于在內存和寄存器之間交換數據,而且在一些體系結構中,這類指令是可以保證原子性的,即在多線程環境下,不會有其他線程能在指令執行過程中中斷并改變被交換的數據。例如,在x86架構中,可以用 xchg 指令實現寄存器和內存位置的數據原子交換。
- 在執行流視角來看,CPU寄存器就是當前執行流狀態的重要組成部分,它們反映了當前執行點的關鍵信息,如算術邏輯運算的中間結果、函數調用的返回地址、棧指針等。每條執行流有自己的寄存器上下文,保證了各自執行的獨立性和連續性。
- 在匯編層面,swap 或exchange 指令常用于實現內存與CPU內部寄存器間的數據原子性交換。一旦這條匯編指令僅由單條語句構成,我們通常認為該指令在執行期間是不可分割的,即原子操作,不會受到其他執行流的干擾。
交換的本質:共享<->私有:
- 從執行流的角度審視CPU內部的寄存器,它們本質上構成了當前執行流的運行環境或上下文。
- 盡管所有執行流共享CPU內部的寄存器空間資源,但每個獨立的執行流(如線程或進程)對其使用的寄存器內容享有專屬權
- 也就是說,寄存器的具體值是各個執行流私有的狀態信息。
- 換言之,在并發或多線程環境中,盡管物理上的寄存器空間是公共資源,但CPU通過巧妙的上下文切換機制,確保每個執行流在運行時都有自己獨特且獨立的寄存器內容。
- 因此,可以說寄存器的內容反映了執行流在某一特定時間點的執行狀態和局部數據,這種狀態是與其他執行流隔離的。
4.4 結合實例理解互斥鎖原理
📕 在下方的圖示中,可以看到一個CPU和內存之間的交互過程。當CPU嘗試獲取鎖時,它需要檢查內存中的mutex變量的狀態。如果狀態為0,則可以成功獲取鎖;反之,如果狀態非零,則表示另一個線程已經持有了鎖,此時CPU需要等待。
🔥 在這個場景中,有兩個線程A和B試圖同時訪問同一段共享資源(例如一段內存區域或一個變量)。為了防止多個線程同時修改這段共享資源導致的數據不一致或其他問題,我們需要一種機制來保證每次只有一個線程能夠訪問這段資源。
🛹 互斥鎖就是這樣的一個機制。它提供了一種方式來控制對共享資源的訪問,使得在同一時刻只有一個線程能夠擁有鎖并訪問資源。當一個線程想要訪問共享資源時,它必須先嘗試獲取鎖。如果鎖已經被其他線程持有,那么這個線程就會被阻塞并進入等待狀態,直到鎖被釋放為止。
- 線程A嘗試獲取鎖:
- A線程首先嘗試獲取鎖,由于此時鎖還沒有被任何線程持有,所以A線程能夠成功獲取鎖。
- 在獲取鎖之后,A線程就可以安全地訪問共享資源而不用擔心與其他線程發生沖突。
- 線程B嘗試獲取鎖:
- B線程也嘗試獲取鎖,但由于鎖已經被A線程持有,所以B線程無法立即獲取鎖。
- 此時,B線程會被阻塞并進入等待狀態,直到A線程完成對共享資源的操作并釋放鎖。
- 線程A釋放鎖:當A線程完成對共享資源的操作后,它會釋放鎖,這使得其他正在等待鎖的線程有機會重新嘗試獲取鎖。
- 線程B獲取鎖:
- 在A線程釋放鎖之后,B線程可以從等待狀態恢復過來,并嘗試再次獲取鎖。
- 這次,由于沒有其他線程持有鎖,所以B線程能夠成功獲取鎖并開始訪問共享資源。
🧃 通過這種方式,我們可以在多線程環境中實現對共享資源的安全訪問,避免了數據競爭和其他并發問題。每個線程都必須遵循相同的規則來獲取和釋放鎖,以確保所有線程都能正確地協調它們對共享資源的訪問。
5. 共勉🔥
【*★,°*:.☆( ̄▽ ̄)/$:*.°★* 】那么本篇到此就結束啦,如果有不懂 和 發現問題的小伙伴可以在評論區說出來哦,同時我還會繼續更新關于【Linux】的內容,請持續關注我?!!💞