核心中各種同步機制(自旋鎖大核心鎖順序鎖等)

FreeeLinux發表於2017-01-09

原子操作


原子操作是由編譯器來保證的,保證一個執行緒對資料的操作不會被其他執行緒打斷。

自旋鎖


原子操作只能用於臨界區只有一個變數的情況,實際應用中,臨界區的情況要複雜的多。對於複雜的臨界區,Linux 核心提供了多種方法,自旋鎖就是其一。

自旋鎖的特點就是當一個執行緒獲取了鎖之後,其他試圖獲取這個鎖的執行緒一直在迴圈等待獲取這個鎖,直至鎖重新可用。由於執行緒一直在迴圈獲取這個鎖,所以會造成 CPU 處理時間的浪費,因此最好將自旋鎖用於很快能處理完的臨界區。

自旋鎖使用時兩點注意:

  1. 自旋鎖是不可遞迴的,以為自選鎖內部關了搶佔,遞迴的話最深層級的函式呼叫嘗試獲取自旋鎖但是由於第一層級函式呼叫還沒釋放,所以會一直死自旋下去。
  2. 執行緒獲取自旋鎖之前,要禁止當前處理器上的中斷。(事實上,spin_lock() 函式內部會自己做這個)。

小知識:為什麼自旋鎖呼叫底層不僅關中斷而且關搶佔?

關中斷是因為中斷處理程式有可能重入已獲得自旋鎖的程式碼段,造成遞迴死鎖。

但是關了中斷,時鐘中斷也關了,那麼時間片無法計算了,不會排程了,為什麼還要關搶佔?

關搶佔是因為雖然時鐘中斷被關掉,但是 Linux 有兩種排程策略,SCHED_FIFO 和 SCHED_RR,FCHED_FIFO 是簡單的佇列排程,並沒有時間片的限制,先來先執行,除非是阻塞或者主動讓出(yield),否則一直佔用 CPU,即使關中斷也不能阻止優先順序高的程式被排程執行。


中斷處理下半部操作中使用尤其需要小心:

  1. 下半部處理和程式共享上下文資料時,由於下半部的處理可以搶佔程式上下文的程式碼,所以程式上下文在對共享資料加鎖前要禁止下半部的執行,解鎖時再允許下半部執行。
  2. 中斷處理程式(上半部)和下半部處理共享資料時,由於中斷處理(上半部)可以搶佔下半步的執行,所以下半部在對共享資料加鎖前要禁止中斷處理(上半部),解鎖時再允許中斷的執行。
  3. 同一種 tasklet 不能同時執行,所以同類 tasklet 中的共享資料就不需要保護。
  4. 不同類 tasklet 中共享資料時,其中一個 tasklet 獲得鎖後,不用禁止其他 tasklet 的執行。
  5. 同型別或者非同型別的軟中斷在共享資料時,也不用禁止下半部,因為在同一個處理器上不會有軟中斷相互搶佔的情況。

讀寫自旋鎖

  1. 讀寫自旋鎖除了和普通自選鎖一樣有自旋特性外,還有以下特點,讀鎖之間是共享的,即一個執行緒持有了讀鎖之後,其他執行緒也可以以讀的方式持有這個鎖。
  2. 寫鎖之間是互斥的,即一個縣城持有了寫鎖之後,其他執行緒不能以讀或者寫的方式持有這個鎖。
  3. 讀寫鎖之間是互斥的,即一個縣城持有了讀鎖之後,其他執行緒不能以寫的方式持有這個鎖。

用法:

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 使用的場景比二值訊號量嚴格,如下:

  1. mutex 計數值只能為 1,也就是說最多允許一個執行緒訪問臨界區。
  2. 必須在同一個上下文問加鎖和解鎖。
  3. 不能遞迴的上鎖和解鎖。
  4. 持有 mutex 時,程式不能退出。
  5. mutex 不能在中斷或者下半部使用,也就是 mutex 只能在程式上下文中使用。
  6. 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 中同步方法,應該如何選擇?如圖:
pic


參考:《Linux核心設計與實現》讀書筆記(十)- 核心同步方法

相關文章