Linux內核設計與實現 讀書筆記(10)內核同步方法

主要內容:

  • 原子操作
  • 自旋鎖
  • 讀寫自旋鎖
  • 信號量
  • 讀寫信號量
  • 互斥體
  • 完成變量
  • 大內核鎖
  • 順序鎖
  • 禁止搶佔
  • 順序和屏障
  • 總結

 

1. 原子操作

原子操作是由編譯器來保證的,保證一個線程對數據的操作不會被其他線程打斷。

原子操作有2類:

  1. 原子整數操作,有32位和64位。頭文件分別爲<asm/atomic.h>和<asm/atomic64.h>
  2. 原子位操作。頭文件 <asm/bitops.h>

 

原子操作的api很簡單,參見相應的頭文件即可。

原子操作頭文件與具體的體系結構有關,比如x86架構的相關頭文件在 arch/x86/include/asm/*.h

 

2. 自旋鎖

原子操作只能用於臨界區只有一個變量的情況,實際應用中,臨界區的情況要複雜的多。

對於複雜的臨界區,linux內核中也提供了多種同步方法,自旋鎖就是其中一種。

 

自旋鎖的特點就是當一個線程獲取了鎖之後,其他試圖獲取這個鎖的線程一直在循環等待獲取這個鎖,直至鎖重新可用。

由於線程實在一直循環的獲取這個鎖,所以會造成CPU處理時間的浪費,因此最好將自旋鎖用於能很快處理完的臨界區。

 

自旋鎖的實現與體系結構有關,所以相應的頭文件 <asm/spinlock.h> 位於相關體系結構的代碼中。

 

自旋鎖使用時有2點需要注意:

  1. 自旋鎖是不可遞歸的,遞歸的請求同一個自旋鎖會自己鎖死自己。
  2. 線程獲取自旋鎖之前,要禁止當前處理器上的中斷。(防止獲取鎖的線程和中斷形成競爭條件) 
      比如:當前線程獲取自旋鎖後,在臨界區中被中斷處理程序打斷,中斷處理程序正好也要獲取這個鎖, 
      於是中斷處理程序會等待當前線程釋放鎖,而當前線程也在等待中斷執行完後再執行臨界區和釋放鎖的代碼。

 

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

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

 

自旋鎖方法列表如下:

方法

描述

spin_lock() 獲取指定的自旋鎖
spin_lock_irq() 禁止本地中斷並獲取指定的鎖
spin_lock_irqsave() 保存本地中斷的當前狀態,禁止本地中斷,並獲取指定的鎖
spin_unlock() 釋放指定的鎖
spin_unlock_irq() 釋放指定的鎖,並激活本地中斷
spin_unlock_irqstore() 釋放指定的鎖,並讓本地中斷恢復到以前狀態
spin_lock_init() 動態初始化指定的spinlock_t
spin_trylock() 試圖獲取指定的鎖,如果未獲取,則返回0
spin_is_locked() 如果指定的鎖當前正在被獲取,則返回非0,否則返回0

 

3. 讀寫自旋鎖

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

 

:讀寫鎖要分別使用,不能混合使用,否則會造成死鎖。

正常的使用方法:

複製代碼
DEFINE_RWLOCK(mr_rwlock);

read_lock(&mr_rwlock);
/* 臨界區(只讀).... */
read_unlock(&mr_rwlock);

write_lock(&mr_lock);
/* 臨界區(讀寫)... */
write_unlock(&mr_lock);
複製代碼

混合使用時:

複製代碼
/* 獲取一個讀鎖 */
read_lock(&mr_lock);
/* 在獲取寫鎖的時候,由於讀寫鎖之間是互斥的,
 * 所以寫鎖會一直自旋等待讀鎖的釋放,
 * 而此時讀鎖也在等待寫鎖獲取完成後繼續下面的代碼。
 * 因此造成了讀寫鎖的互相等待,形成了死鎖。
 */
write_lock(&mr_lock);
複製代碼

 

讀寫鎖相關文件參照 各個體系結構中的 <asm/rwlock.h>

讀寫鎖的相關函數如下:

方法

描述

read_lock() 獲取指定的讀鎖
read_lock_irq() 禁止本地中斷並獲得指定讀鎖
read_lock_irqsave() 存儲本地中斷的當前狀態,禁止本地中斷並獲得指定讀鎖
read_unlock() 釋放指定的讀鎖
read_unlock_irq() 釋放指定的讀鎖並激活本地中斷
read_unlock_irqrestore() 釋放指定的讀鎖並將本地中斷恢復到指定前的狀態
write_lock() 獲得指定的寫鎖
write_lock_irq() 禁止本地中斷並獲得指定寫鎖
write_lock_irqsave() 存儲本地中斷的當前狀態,禁止本地中斷並獲得指定寫鎖
write_unlock() 釋放指定的寫鎖
write_unlock_irq() 釋放指定的寫鎖並激活本地中斷
write_unlock_irqrestore() 釋放指定的寫鎖並將本地中斷恢復到指定前的狀態
write_trylock() 試圖獲得指定的寫鎖;如果寫鎖不可用,返回非0值
rwlock_init() 初始化指定的rwlock_t

 

4. 信號量

信號量也是一種鎖,和自旋鎖不同的是,線程獲取不到信號量的時候,不會像自旋鎖一樣循環的去試圖獲取鎖,

而是進入睡眠,直至有信號量釋放出來時,纔會喚醒睡眠的線程,進入臨界區執行。

 

由於使用信號量時,線程會睡眠,所以等待的過程不會佔用CPU時間。所以信號量適用於等待時間較長的臨界區。

信號量消耗的CPU時間的地方在於使線程睡眠和喚醒線程,

如果 (使線程睡眠 + 喚醒線程)的CPU時間 > 線程自旋等待的CPU時間,那麼可以考慮使用自旋鎖。

 

信號量有二值信號量和計數信號量2種,其中二值信號量比較常用。

二值信號量表示信號量只有2個值,即0和1。信號量爲1時,表示臨界區可用,信號量爲0時,表示臨界區不可訪問。

二值信號量表面看和自旋鎖很相似,區別在於爭用自旋鎖的線程會一直循環嘗試獲取自旋鎖,

而爭用信號量的線程在信號量爲0時,會進入睡眠,信號量可用時再被喚醒。

 

計數信號量有個計數值,比如計數值爲5,表示同時可以有5個線程訪問臨界區。

 

信號量相關函數參照: <linux/semaphore.h> 實現方法參照:kernel/semaphore.c

使用信號量的方法如下:

複製代碼
/* 定義並聲明一個信號量,名字爲mr_sem,用於信號量計數 */
static DECLARE_MUTEX(mr_sem);

/* 試圖獲取信號量...., 信號未獲取成功時,進入睡眠
 * 此時,線程狀態爲 TASK_INTERRUPTIBLE
 */
down_interruptible(&mr_sem);
/* 這裏也可以用:
 * down(&mr_sem);
 * 這個方法把線程狀態置爲 TASK_UNINTERRUPTIBLE 後睡眠
 */

/* 臨界區 ... */

/* 釋放給定的信號量 */
up(&mr_sem);
複製代碼

 

一般用的比較多的是down_interruptible()方法,因爲以 TASK_UNINTERRUPTIBLE 方式睡眠無法被信號喚醒。

對於 TASK_INTERRUPTIBLE 和 TASK_UNINTERRUPTIBLE 補充說明一下:

  • TASK_INTERRUPTIBLE - 可打斷睡眠,可以接受信號並被喚醒,也可以在等待條件全部達成後被顯式喚醒(比如wake_up()函數)。
  • TASK_UNINTERRUPTIBLE - 不可打斷睡眠,只能在等待條件全部達成後被顯式喚醒(比如wake_up()函數)。

 

信號量方法如下:

方法

描述

sema_init(struct semaphore *, int) 以指定的計數值初始化動態創建的信號量
init_MUTEX(struct semaphore *) 以計數值1初始化動態創建的信號量
init_MUTEX_LOCKED(struct semaphore *) 以計數值0初始化動態創建的信號量(初始爲加鎖狀態)
down_interruptible(struct semaphore *) 以試圖獲得指定的信號量,如果信號量已被爭用,則進入可中斷睡眠狀態
down(struct semaphore *) 以試圖獲得指定的信號量,如果信號量已被爭用,則進入不可中斷睡眠狀態
down_trylock(struct semaphore *) 以試圖獲得指定的信號量,如果信號量已被爭用,則立即返回非0值
up(struct semaphore *) 以釋放指定的信號量,如果睡眠隊列不空,則喚醒其中一個任務

 

信號量結構體具體如下:

/* Please don't access any members of this structure directly */
struct semaphore {
    spinlock_t        lock;
    unsigned int        count;
    struct list_head    wait_list;
};

可以發現信號量結構體中有個自旋鎖,這個自旋鎖的作用是保證信號量的down和up等操作不會被中斷處理程序打斷。

 

5. 讀寫信號量

讀寫信號量和信號量之間的關係 與 讀寫自旋鎖和普通自旋鎖之間的關係 差不多。

讀寫信號量都是二值信號量,即計數值最大爲1,增加讀者時,計數器不變,增加寫者,計數器才減一。

也就是說讀寫信號量保護的臨界區,最多隻有一個寫者,但可以有多個讀者。

 

讀寫信號量的相關內容參見:<asm/rwsem.h> 具體實現與硬件體系結構有關。

 

6. 互斥體

互斥體也是一種可以睡眠的鎖,相當於二值信號量,只是提供的API更加簡單,使用的場景也更嚴格一些,如下所示:

  1. mutex的計數值只能爲1,也就是最多隻允許一個線程訪問臨界區
  2. 在同一個上下文中上鎖和解鎖
  3. 不能遞歸的上鎖和解鎖
  4. 持有個mutex時,進程不能退出
  5. mutex不能在中斷或者下半部中使用,也就是mutex只能在進程上下文中使用
  6. mutex只能通過官方API來管理,不能自己寫代碼操作它

 

在面對互斥體和信號量的選擇時,只要滿足互斥體的使用場景就儘量優先使用互斥體。

在面對互斥體和自旋鎖的選擇時,參見下表:

需求

建議的加鎖方法

低開銷加鎖 優先使用自旋鎖
短期鎖定 優先使用自旋鎖
長期加鎖 優先使用互斥體
中斷上下文中加鎖 使用自旋鎖
持有鎖需要睡眠 使用互斥體

 

互斥體頭文件:<linux/mutex.h>

常用的互斥體方法如下:

方法

描述

mutex_lock(struct mutex *) 爲指定的mutex上鎖,如果鎖不可用則睡眠
mutex_unlock(struct mutex *) 爲指定的mutex解鎖
mutex_trylock(struct mutex *) 試圖獲取指定的mutex,如果成功則返回1;否則鎖被獲取,返回0
mutex_is_locked(struct mutex *) 如果鎖已被爭用,則返回1;否則返回0

 

7. 完成變量

完成變量的機制類似於信號量,

比如一個線程A進入臨界區之後,另一個線程B會在完成變量上等待,線程A完成了任務出了臨界區之後,使用完成變量來喚醒線程B。

 

完成變量的頭文件:<linux/completion.h>

完成變量的API也很簡單:

方法

描述

init_completion(struct completion *) 初始化指定的動態創建的完成變量
wait_for_completion(struct completion *) 等待指定的完成變量接受信號
complete(struct completion *) 發信號喚醒任何等待任務

使用完成變量的例子可以參考:kernel/sched.c 和 kernel/fork.c

一般在2個任務需要簡單同步的情況下,可以考慮使用完成變量。

 

8. 大內核鎖

大內核鎖已經不再使用,只存在與一些遺留的代碼中。

 

9. 順序鎖

順序鎖爲讀寫共享數據提供了一種簡單的實現機制。

之前提到的讀寫自旋鎖和讀寫信號量,在讀鎖被獲取之後,寫鎖是不能再被獲取的,

也就是說,必須等所有的讀鎖釋放後,才能對臨界區進行寫入操作。

 

順序鎖則與之不同,讀鎖被獲取的情況下,寫鎖仍然可以被獲取。

使用順序鎖的讀操作在讀之前和讀之後都會檢查順序鎖的序列值,如果前後值不符,則說明在讀的過程中有寫的操作發生,

那麼讀操作會重新執行一次,直至讀前後的序列值是一樣的。

do
{
    /* 讀之前獲取 順序鎖foo 的序列值 */
    seq = read_seqbegin(&foo);
...
} while(read_seqretry(&foo, seq)); /* 順序鎖foo此時的序列值!=seq 時返回true,反之返回false */

順序鎖優先保證寫鎖的可用,所以適用於那些讀者很多,寫者很少,且寫優於讀的場景。

順序鎖的使用例子可以參考:kernel/timer.c和kernel/time/tick-common.c文件

 

10. 禁止搶佔

其實使用自旋鎖已經可以防止內核搶佔了,但是有時候僅僅需要禁止內核搶佔,不需要像自旋鎖那樣連中斷都屏蔽掉。

這時候就需要使用禁止內核搶佔的方法了:

方法

描述

preempt_disable() 增加搶佔計數值,從而禁止內核搶佔
preempt_enable() 減少搶佔計算,並當該值降爲0時檢查和執行被掛起的需調度的任務
preempt_enable_no_resched() 激活內核搶佔但不再檢查任何被掛起的需調度的任務
preempt_count() 返回搶佔計數

這裏的preempt_disable()和preempt_enable()是可以嵌套調用的,disable和enable的次數最終應該是一樣的。

禁止搶佔的頭文件參見:<linux/preempt.h>

 

11. 順序和屏障

對於一段代碼,編譯器或者處理器在編譯和執行時可能會對執行順序進行一些優化,從而使得代碼的執行順序和我們寫的代碼有些區別。

一般情況下,這沒有什麼問題,但是在併發條件下,可能會出現取得的值與預期不一致的情況

 

比如下面的代碼:

複製代碼
/* 
 * 線程A和線程B共享的變量 a和b
 * 初始值 a=1, b=2
 */
int a = 1, b = 2;

/*
 * 假設線程A 中對 a和b的操作
 */
void Thread_A()
{
    a = 5;
    b = 4;
}

/*
 * 假設線程B 中對 a和b的操作
 */
void Thread_B()
{
    if (b == 4)
        printf("a = %d\n", a);
}
複製代碼

由於編譯器或者處理器的優化,線程A中的賦值順序可能是b先賦值後,a才被賦值。

所以如果線程A中 b=4; 執行完,a=5; 還沒有執行的時候,線程B開始執行,那麼線程B打印的是a的初始值1。

這就與我們預期的不一致了,我們預期的是a在b之前賦值,所以線程B要麼不打印內容,如果打印的話,a的值應該是5。

 

在某些併發情況下,爲了保證代碼的執行順序,引入了一系列屏障方法來阻止編譯器和處理器的優化。

方法

描述

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() 阻止編譯器跨越屏障對載入或存儲操作進行優化

 

爲了使得上面的小例子能正確執行,用上表中的函數修改線程A的函數即可:

複製代碼
/*
 * 假設線程A 中對 a和b的操作
 */
void Thread_A()
{
    a = 5;
    mb(); 
    /* 
     * mb()保證在對b進行載入和存儲值(值就是4)的操作之前
     * mb()代碼之前的所有載入和存儲值的操作全部完成(即 a = 5;已經完成)
     * 只要保證a的賦值在b的賦值之前進行,那麼線程B的執行結果就和預期一樣了
     */
    b = 4;
}
複製代碼

 

12. 總結

本節討論了大約11種內核同步方法,除了大內核鎖已經不再推薦使用之外,其他各種鎖都有其適用的場景。

瞭解了各種同步方法的適用場景,才能正確的使用它們,使我們的代碼在安全的保障下達到最優的性能。

 

同步的目的就是爲了保障數據的安全,其實就是保障各個線程之間共享資源的安全,下面根據共享資源的情況來討論一下10種同步方法的選擇。

10種同步方法在圖中分別用藍色框標出。

locks

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章