原子操作
原子操作是由編譯器來保證的,保證一個線程對數據的操作不會被其他線程打斷。
自旋鎖
原子操作只能用於臨界區只有一個變量的情況,實際應用中,臨界區的情況要復雜的多。對於復雜的臨界區,Linux 內核提供了多種方法,自旋鎖就是其一。
自旋鎖的特點就是當一個線程獲取了鎖之后,其他試圖獲取這個鎖的線程一直在循環等待獲取這個鎖,直至鎖重新可用。由於線程一直在循環獲取這個鎖,所以會造成 CPU 處理時間的浪費,因此最好將自旋鎖用於很快能處理完的臨界區。
自旋鎖使用時兩點注意:
- 自旋鎖是不可遞歸的,以為自選鎖內部關了搶占,遞歸的話最深層級的函數調用嘗試獲取自旋鎖但是由於第一層級函數調用還沒釋放,所以會一直死自旋下去。
- 線程獲取自旋鎖之前,要禁止當前處理器上的中斷。(事實上,spin_lock() 函數內部會自己做這個)。
小知識:為什么自旋鎖調用底層不僅關中斷而且關搶占?
關中斷是因為中斷處理程序有可能重入已獲得自旋鎖的代碼段,造成遞歸死鎖。
但是關了中斷,時鍾中斷也關了,那么時間片無法計算了,不會調度了,為什么還要關搶占?
關搶占是因為雖然時鍾中斷被關掉,但是 Linux 有兩種調度策略,SCHED_FIFO 和 SCHED_RR,FCHED_FIFO 是簡單的隊列調度,並沒有時間片的限制,先來先運行,除非是阻塞或者主動讓出(yield),否則一直占用 CPU,即使關中斷也不能阻止優先級高的進程被調度運行。
中斷處理下半部操作中使用尤其需要小心:
- 下半部處理和進程共享上下文數據時,由於下半部的處理可以搶占進程上下文的代碼,所以進程上下文在對共享數據加鎖前要禁止下半部的執行,解鎖時再允許下半部執行。
- 中斷處理程序(上半部)和下半部處理共享數據時,由於中斷處理(上半部)可以搶占下半步的執行,所以下半部在對共享數據加鎖前要禁止中斷處理(上半部),解鎖時再允許中斷的執行。
- 同一種 tasklet 不能同時運行,所以同類 tasklet 中的共享數據就不需要保護。
- 不同類 tasklet 中共享數據時,其中一個 tasklet 獲得鎖后,不用禁止其他 tasklet 的執行。
- 同類型或者非同類型的軟中斷在共享數據時,也不用禁止下半部,因為在同一個處理器上不會有軟中斷相互搶占的情況。
讀寫自旋鎖
- 讀寫自旋鎖除了和普通自選鎖一樣有自旋特性外,還有以下特點,讀鎖之間是共享的,即一個線程持有了讀鎖之后,其他線程也可以以讀的方式持有這個鎖。
- 寫鎖之間是互斥的,即一個縣城持有了寫鎖之后,其他線程不能以讀或者寫的方式持有這個鎖。
- 讀寫鎖之間是互斥的,即一個縣城持有了讀鎖之后,其他線程不能以寫的方式持有這個鎖。
用法:
DEFINE_RWLOCK(mr_rwlock); read_lock(&mr_rwlock); /*critical region, only for read*/ read_unlock(&mr_rwlock); write_lock(&mr_lock); /*critical region, only for write*/ write_unlock(&mr_lock);
信號量
信號量也是一種鎖,和自旋鎖不同的是,線程獲取不到信號量的時候,不會像自旋鎖一樣循環區試圖獲取鎖,而是進入睡眠,直至有信號量釋放出來時,才會喚醒睡眠的線程,進入臨界區執行。
由於使用信號量時,線程會睡眠,所以等待的過程不會占用 CPU 時間。所以信號量適用於等待時間較長的臨界區。
信號量消耗 CPU 時間的地方在於使線程睡眠和喚醒線程。
如果(使線程睡眠 + 喚醒線程)的 CPU 時間 > 線程自旋等待 CPU 時間,那么可以考慮使用自旋鎖。
信號量睡眠一般會進入 TASK_INTERRUPTIBLE 狀態,因為另一個無法被信號喚醒。
小知識:二值信號量和 mutex 的區別?
區別在於 mutex 只能被統一線程加鎖解鎖,二值信號量可以被不同線程加鎖解鎖。
讀寫信號量
讀寫信號量和信號量的關系與讀寫自旋鎖和自旋鎖的關系差不多。
互斥量
問題:互斥體也是一種可以用於睡眠的鎖,嗯?為什么?為什么 spin_lock 不可以它可以?
在 mutex 鎖定的臨界區中調用 sleep(),那么底層會調用 schedule() 函數去進行進程調度,假設調度的新進程再次執行這段代碼,由於 mutex 被之前的進程持有,該進程無法獲得該鎖,所以在 mutex_lock() 中又會調用 sleep() 去調用 schedule(),會切換到先前的進程,這樣先前的進程遲早會 unlock(),不會死鎖。所以mutex 中可以睡眠。而 spin_lock 就不一樣了,調度的進程嘗試獲取 spin_lock,失敗后會一直自旋,占據 CPU 不放,根本不會切換回去,所以死鎖!所以在 spin_lock 的 lock() 函數中會關中斷和搶占。
mutex 使用的場景比二值信號量嚴格,如下:
- mutex 計數值只能為 1,也就是說最多允許一個線程訪問臨界區。
- 必須在同一個上下文問加鎖和解鎖。
- 不能遞歸的上鎖和解鎖。
- 持有 mutex 時,進程不能退出。
- mutex 不能在中斷或者下半部使用,也就是 mutex 只能在進程上下文中使用。
- mutex 只能通過官方 API 管理,不能自己寫代碼操作它 :)
知識點:中斷上下文中為什么不能使用 mutex ?
因為 mutex 可能會引發睡眠或者進程調度,而進程調度是針對進程而言的,進程有 task_struct 結構體,中斷上下文確不是一個進程,它沒有 task_struct 結構體, 是不可調度的。沒有 task_struct 的原因是中斷調用頻繁,並且處理程序很快,如果為中斷維護一個 task_struct,那么對系統的吞吐量有所影響。同理,具有睡眠功能的如信號量也不能再中斷上下文中使用。
mutex 和 spin_lock 如何選擇:
需求 | 建議加鎖方法 |
---|---|
低開銷加鎖 | 優先使用spin_lock |
短期鎖定 | 優先使用spin_lock |
長期加鎖 | 優先使用mutex |
中斷上下文中加鎖 | 使用spin_lock |
持有者需要睡眠 | 使用mutex |
mutex 比 spin_lock 開銷多在進程上下文切換。中斷上下文見上面小知識。
完成變量
完成變量名為 completion,就不具體介紹了,我倒是沒見用過。
完成變量類似於信號量,當線程完成任務出了臨界區之后,使用完成變量喚醒等待線程(更像 condition)。
大內核鎖
一個粗粒度鎖,Linux 過度到細粒度鎖之前版本使用,現在幾乎退役?
順序鎖
順序鎖在我的理解是一個部分優化的讀寫鎖。它的特點是,讀鎖被獲取的情況下,寫鎖仍然可以被獲取。
使用順序鎖的讀操作在讀之前和讀之后都會檢查順序鎖的序列值。如果前后值不服,這說明在讀的過程中有寫的操作發生。那么該操作會重新執行一次,直至讀前后的序列值是一樣的。
do{ /*讀之前獲取序列值*/ seq = read_seqbegin(&foo); //do somethin }while(read_seqretry(&foo, seq); /*順序鎖foo此時的序列值不同則重來
禁止搶占
自旋鎖同時關閉中斷和搶占,但有時后只需要關閉搶占,我們來看一下它的方法:
方法 | 描述 |
---|---|
preempt_disable() | 增加搶占計數值,從而禁止內核搶占 |
preempt_enable() | 減少搶占計算,並當該值將為0時檢查和執行被掛起的需要調度的任務 |
preempt_enable_no_resched() | 激活內核搶占但不再檢查任何被掛起的需調度的任務 |
preempt_count() | 返回搶占計數 |
順序和屏障
防止編譯器優化我們的代碼,讓我們代碼的執行順序與我們所寫的不同,就需要順序和屏障。
函數如下:
方法 | 描述 |
---|---|
rmb | 阻止跨越屏障的載入動作發生重排序 |
read_barrier_depends() | 阻止跨越屏障的具有數據依賴關系的載入動作重排序 |
wmb() | 阻止跨越屏障的存儲動作發生重排序 |
mb() | 阻止跨越屏障的載入和存儲動作重新排序 |
smp_rmb() | 在SMP上提供rmb()功能,在UP上提供barrier()功能 |
smp_read_barrier_depends() | 在SMP上提供read_barrier_depends()功能,在UP上提供barrier()功能 |
smp_wmb() | 在SMP上提供wmb()功能,在UP上提供barrier()功能 |
smp_mb | 在SMP上提供mb()功能,在UP上提供barrier()功能 |
barrier | 阻止編譯器跨越屏障對載入或存儲操作進行優化 |
舉例如下:
void thread_worker() { a = 3; mb(); b = 4; }
上述用法就會保證 a 的賦值永遠在 b 賦值之前,而不會被編譯器優化弄反。在某些情況下,弄反了可能帶來難以估量的后果。
如何選擇
對於以上 10 中同步方法,應該如何選擇?如圖: