文章目錄
- 一、有關概念
- 原子性
- 錯誤認知澄清
- 加鎖
- 二、鎖的相關函數
- 全局鎖
- 局部鎖
- 初始化
- 銷毀
- 加鎖
- 解鎖
- 三、鎖相關
- 如何看待鎖
- 一個線程在執行臨界區的代碼時,可以被切換嗎?
- 鎖是本身也是臨界資源,它如何做到保護自己?(鎖的實現)
- 軟件層面的互斥鎖的實現
- 硬件層面的互斥鎖的實現
- 鎖是不允許拷貝構造或者賦值拷貝的
- 鎖的饑餓問題
一、有關概念
共享資源
:多執行流運行時都能使用的資源臨界資源
:多線程執行流被保護的共享的資源就叫做臨界資源- 臨界區:每個線程內部,訪問臨界資源的代碼,就叫做臨界區
互斥
:任何時刻,互斥保證有且只有一個執行流進入臨界區,訪問臨界資源,通常對臨界資源起保護作用原子性
:不會被任何調度機制打斷的操作,該操作只有兩態,要么完成,要么未完成- 保護的方式常見:
互斥與同步
- 多個執行流,訪問臨界資源的時候,具有一定的順序性,叫做
同步
- 在進程中涉及到互斥資源的程序段叫臨界區。你寫的代碼=訪問臨界資源的代碼(臨界區)+不訪問臨界資源的代碼(非臨界區)
- 所謂的對共享資源進行保護,本質是對訪問共享資源的代碼進行保護
原子性
原子性是指一個操作在執行過程中不會被其他線程或者中斷所干擾
即這個操作要么完全執行,要么完全不執行,不會出現只執行了一部分的情況。
注意:
在計算機系統中,原子性指令的設計目標就是確保其執行過程不可分割,即使在多核并行環境下,同一原子指令也不可能被兩個CPU核心真正“同時”執行
原因:
-
總線鎖定
原理:當CPU核心執行原子指令時,會通過總線信號鎖定內存區域,阻止其他核心訪問對應變量物理內存地址,防止變量被修改被讀取
代價:鎖定總線會導致其他核心的訪存操作被阻塞,影響整體性能 -
緩存鎖定
原理:利用緩存一致性協議,在緩存行級別鎖定內存區域,無需全局總線鎖定。
優勢:更高效,僅阻塞對特定緩存行的訪問 -
硬件指令原子性
某些指令(如x86的LOCK前綴指令)直接在硬件層面保證原子性,例如:
LOCK ADD [mem], 1 ; 原子遞增內存值
錯誤認知澄清
誤區:原子操作等同于“互斥”?
錯誤觀點:原子操作讓其他線程完全無法訪問變量
現實:原子操作僅保證特定操作的原子性,其他線程仍可自由訪問變量(例如,通過非原子方式讀取,或執行其他原子操作)
例
std::atomic<int> x(0);
int y = 0;線程A(原子寫)
x.store(42, std::memory_order_relaxed);線程B(非原子讀!)
int local_x = x.load(std::memory_order_relaxed); 正確:原子讀
int local_y = y; 錯誤:非原子讀,可能讀到未同步的值
原子操作和互斥鎖雖然都能實現線程安全,但它們的核心機制和適用場景不同:
- 原子操作:針對單個變量的特定操作,通過硬件指令實現高效無鎖同步
- 互斥鎖:保護代碼塊內的任意操作(無論涉及多少變量),通過阻塞實現強一致性
所以:
原子性和互斥鎖都能保證對共享資源的進行某一操作時,多執行流必須串行執行,但是互斥鎖保護的范圍比原子性更大
多執行流時,共享資源如果不加保護會怎么樣?
多執行流時,共享資源不互斥(沒有原子性)可能會怎樣?
很可能產生數據不一致問題
例
下面是4個線程同時進行搶票的操作,票數就是全局變量ticket
#include <iostream>
#include <unistd.h>
#include <pthread.h>int ticket = 100;void* Route(void* args)
{char* buf = (char*)args;while(true){if(ticket > 0){sleep(1);std::cout << buf << "sell ticket: " << ticket << std::endl;ticket--;}else{break;}}return nullptr;
}int main()
{pthread_t t1, t2, t3, t4;pthread_create(&t1, nullptr, Route, (void*)"thread 1");pthread_create(&t2, nullptr, Route, (void*)"thread 2");pthread_create(&t3, nullptr, Route, (void*)"thread 3");pthread_create(&t4, nullptr, Route, (void*)"thread 4");pthread_join(t1, nullptr);pthread_join(t2, nullptr);pthread_join(t3, nullptr);pthread_join(t4, nullptr);return 0;
}
為什么最后搶票會搶出負數?
if(ticket>0)不是原子的
因為它會變成3條匯編指令,一條匯編指令雖然是原子的,但是3條匯編指令和在一起的操作就不是原子的了
所以在CPU在執行這3條匯編指令期間,都有可能進行線程切換。
比如:
ticket=1了,線程a把1讀取到寄存器之后,線程a就切換了,還沒去–ticket
線程b也來讀取了,也把ticket=1讀到寄存器里了
這個時候,線程a和線程b就都會判斷,ticket>0,就都進去搶了
而且
ticket–也不是原子的
線程/進程什么時候會發生切換?
- 線程時間片到了
- 來了一個(多個)優先級更高的進程/線程,此時CPU上的線程時間片沒有耗盡也可能會被切換
- CPU上的線程執行阻塞了(比如執行了sleep暫停代碼,scanf等待鍵盤等)線程進入等待隊列,代碼不執行了
CPU就不會讓這個線程占著茅坑不拉屎,就會直接切換到其他線程
因為ticketnum–編譯之后,會變成3條匯編指令
- 讀取ticket到CPU的寄存器
- CPU執行–計算
- 把計算之后的ticket結果寫回內存
所以ticket–不是原子的
所以上面的代碼,在ticket=1時:
線程1執行if判斷時,可以通過,然后執行sleep時,就會阻塞,就切換到線程2了
線程2執行if判斷時,ticket還是1,所以線程2也能通過,然后執行sleep,阻塞,就切換到線程3
線程3…
所以最后if的{}里面同時進入了4個線程
4個線程依次從阻塞狀態恢復,依次對ticket進行–
ticket就減到了-2
還是上面的4個線程搶票問題
因為ticket–編譯之后,會變成3條匯編指令
- 讀取ticket到CPU的寄存器
- CPU執行–計算
- 把計算之后的ticket結果寫回內存
所以ticket–不是原子的
假設線程1要執行ticket–了,此時ticket的值為10000
CPU執行第一個匯編指令,把10000寫進CPU寄存器
CPU執行第二個匯編指令,把10000減到了9999
CPU剛準備執行第3個匯編指令時,線程1的時間片到了
那么CPU就會把CPU中線程1相關的寄存器中的數據保存,即保存上下文數據(PC指針和9999等)
然后線程2被切換上來了,正好線程2也要執行ticket–
而線程2運氣比較好,它一直循環執行了9999次ticket–
于是線程2從10000開始減[ 因為線程1的9999沒有寫回內存,而線程的上下文是線程私有的 ]把ticket減到了1
線程2準備再次執行ticket–時,也和線程1一樣,剛執行到第二條匯編代碼,把ticket減到0,時間片就到了
線程2就被切換成了線程1
線程1恢復上下文之后,根據PC指針中的下一條匯編代碼繼續執行
就把自己計算的結果:9999寫回了內存中的ticket中
然后從循環從9999開始減…
所以線程2就白干了
加鎖
如何給共享資源增加互斥性質?
多執行流時保護共享資源的本質其實是:
保護臨界區的代碼,因為共享資源是通過臨界區的代碼訪問的
那么給共享資源增加互斥性質,本質就是給臨界區代碼添加互斥性質
讓任意時刻最多同時有一個執行流執行該臨界區的代碼
如何給臨界區添加互斥性質?
加鎖
Linux上提供的這把鎖叫互斥量。
加了鎖之后:
每個線程(執行流)執行這個互斥性質的臨界區的代碼之前,都必須先申請鎖,只有申請鎖成功的那個線程才能執行臨界區的代碼
二、鎖的相關函數
鎖pthread_mutex_t
類型的結構體
分為
全局鎖
- 全局鎖可以使用
pthread_mutex_init
或者宏
PTHREAD_MUTEX_INITIALIZER初始化 - 全局鎖銷不銷毀無所謂,因為生命周期本來就和進程一樣長
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
局部鎖
- 只能使用
pthread_mutex_init
初始化 - 并且需要使用
pthread_mutex_destroy
銷毀局部鎖 - 鎖是局部的,所以要讓所有線程都看到的話,就需要把鎖的地址/引用傳給所有線程
初始化
pthread_mutex_init
作用:初始化對應的鎖
#include <pthread.h>int pthread_mutex_init(pthread_mutex_t * mutex,const pthread_mutexattr_t * attr);
- pthread_mutex_t* mutex:要初始化的鎖的地址
- const pthread_mutexattr_t* attr:用戶指定的鎖的屬性,一般不管,設置為nullptr
- 返回值
0 成功,互斥鎖(mutex)初始化完成。
非 0 失敗,返回的錯誤代碼
銷毀
pthread_mutex_destroy
作用:銷毀對應的鎖
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- pthread_mutex_t*mutex:要銷毀的鎖的地址
- 返回值
0 成功
非 0 失敗
加鎖
pthread_mutex_lock
作用:對一個臨界區上鎖(申請一個訪問對應臨界區的"入場券")
- 申請成功:就獲得對應的入場券
- 申請失敗:就說明其他線程已經把入場券搶完了,此時線程的PCB就進入對應的等待隊列
阻塞
int pthread_mutex_lock(pthread_mutex_t *mutex);
- pthread_mutex_t*mutex:鎖對象的地址
- 返回值
0 成功
非 0 失敗
pthread_mutex_trylock
作用:對一個臨界區上鎖(申請一個訪問對應臨界區的"入場券"):
- 申請成功:就獲得對應的入場券
- 申請失敗:就說明其他線程已經把入場券搶完了,此時線程
不阻塞
,直接返回一個錯誤碼
int pthread_mutex_trylock(pthread_mutex_t *mutex);
- pthread_mutex_t*mutex:鎖對象的地址
- 返回值
0 成功
非 0 失敗
解鎖
pthread_mutex_unlock
作用:
解除對應的鎖(把一個訪問對應臨界區的"入場券"還回去,讓其他線程可以去搶"入場券")
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- pthread_mutex_t*mutex:鎖對象的地址
- 返回值
0 成功
非 0 失敗
三、鎖相關
如何看待鎖
鎖的本質就是一個二元信號量
而二元信號量本質是一個值只可能為1或0的計數器
這個計數器作為鎖時:記的是訪問對應臨界區的"入場券"數量
即
- 沒有線程申請訪問對應臨界區時,count為1
- 有一個線程成功申請到了使用對應臨界區的資格時,count就變成0
- 解鎖的話,count就從0變成1
所以鎖本質是一個預定機制
一個線程在執行臨界區的代碼時,可以被切換嗎?
可以被切換
而且這個線程切換了之后,其他線程依然不能進入臨界區
因為這個線程還沒有調用解鎖的接口,所以這個線程把鎖“拿走了”
所以一個線程執行臨界區代碼這個操作,對于其他線程來說就是具有原子性的!
因為對于其他線程而言:
這個臨界區的代碼要嗎沒有被這個線程執行,要嗎就是這個線程執行完了
執行過程中不可能被任何其他線程干擾
鎖是本身也是臨界資源,它如何做到保護自己?(鎖的實現)
每個線程(執行流)執行某個互斥性質的臨界區的代碼之前,都必須先申請鎖,只有申請成功的那個線程才能執行臨界區的代碼
鎖需要被所有線程共享訪問,因此它本身是一種共享資源。
由于鎖的實現必須保證自身操作的原子性(如通過硬件指令避免競爭),所以鎖也是一種臨界資源——它需要被自身的機制保護。
軟件層面的互斥鎖的實現
軟件層面鎖是如何自保的?
加鎖和解鎖的操作是原子的
鎖的本質是一個二元信號量,即一個只有0和1的計數器
我們知道++和–操作都不是原子的,所以鎖不能通過++或者–來修改自己的值
為了實現互斥鎖,體系結構[X86,X64等]提供了兩個新的匯編指令,swap和xchange
它們的作用都是:交換一個寄存器和一個物理內存中的變量的值
因為swap和xchange都只是一條匯編指令,所以他們兩個操作都是原子的
函數pthread_mutex_lock和unlock實現的偽代碼如下圖:
即
調用pthread_mutex_init或者使用宏初始化鎖之后,物理內存中鎖mutex里面的值為1
-
①movb $0,%al:就是把0放進一個寄存器中
-
②xchge %al,mutex:就是交換寄存器和mutex中的值
-
③
- 1.如果寄存器交換得到的值>0,這個線程就申請鎖成功,獲得進入臨界區的資格
- 2.如果寄存器交換得到的值<0,這個線程就會被阻塞,等到獲取到鎖的線程解鎖之后,才會繼續運行
-
④最后執行goot lock,即回到pthread_mutex_lock函數的開頭重新執行一遍,看能不能搶到鎖
線程進入pthread_mutex_lock函數之后依然可以進行切換,并且不會影響鎖的獲取
為什么?
假設有兩個線程
線程1先調用pthread_mutex_lock,當線程1執行完第②條匯編指令[xchge %al,mutex],把寄存器中的0與mutex中的1進行了交換
然后就被切換走了
切換之前,CPU會保護線程1的上下文數據,所以線程1就把mutex中的1放進上下文里帶走了
線程2切換上來之后,也執行了lock方法想要獲取鎖
線程2執行匯編指令①:把0放進寄存器中,把線程1留下的1覆蓋
執行匯編指令②,交換寄存器與mutex的值
但是此時線程2只能從mutex里面拿到被線程1換進去的0
拿不到1了,所以線程2獲取鎖失敗,被阻塞
所以
- 線程1如果在執行匯編指令②之前被切換,本來就不影響鎖的競爭
- 線程1如果在執行完匯編指令②并且成功獲取了鎖,之后被切換,即使線程1被切換了它也會把1(鎖)帶走
所以其實整個pthread_mutex_lock中,匯編指令②xchge %al,mutex就是申請鎖
pthread_mutex_unlock中movb $1,mutex就是解鎖
所以
線程們競爭的資源是什么?
是mutex這個變量空間嗎?不是!
因為所有線程都可以與變量空間中的值進行交換
線程們競爭的是1,是mutex初始時(或者解鎖操作執行后)mutex里面那唯一的一個1
mutex里面的值可能>1或者<0嗎?
不可能!!!
因為鎖只能使用pthread_mutex_init或者宏初始化,不支持其他任何初始化方法
解鎖時也只會把1放進mutex中
硬件層面的互斥鎖的實現
即
在某個線程要執行臨界區代碼之前,先關閉操作系統對與時鐘中斷和外部中斷的響應
這個線程執行完臨界區代碼之后,再打開
即:這個線程執行臨界區代碼時,操作系統不會進行切換
這樣就可以防止并發切換導致的線程安全問題
不過:一般用的是軟件實現鎖
鎖是不允許拷貝構造或者賦值拷貝的
因為如果要使用鎖對一個臨界資源進行保護的話
那么就應該保證所有想訪問這個臨界資源線程看到的都是同一把鎖
不然就不能起到保護的作用了
所以為了防止用戶無意識地進行鎖的拷貝構造/賦值導致出現線程安全問題
就直接禁止鎖進行拷貝構造和賦值拷貝了
鎖的饑餓問題
如果一個共享資源只加了鎖,就有可能出現鎖的饑餓問題
例:
一個死循環–計數器的代碼
while(1)
{
//加鎖
p–
//解鎖
}
一個線程a搶到鎖之后,其他線程想要鎖的進程就只能阻塞等待線程a解鎖
線程a使用完臨界區之后,解鎖之后,又進入下一次循環,又去搶鎖了
因為其他想搶鎖的線程還阻塞著,喚醒需要時間
但是線程a本來就醒著,所以線程a就比別的線程快,馬上又把鎖搶到了
其他想要鎖的線程只能再次進入阻塞狀態
就有可能一直是線程a拿著鎖,訪問臨界區
怎么解決這個問題?
就要用到同步
了