Linux 內核中的各種鎖:信號量 - 互斥鎖 - 讀寫鎖 - 原子鎖 - 自旋鎖 - 內存屏障等

首先得搞清楚,不同鎖的作用對象不同。

下面分別是作用於臨界區CPU內存cache 的各種鎖的歸納:

一、atomic 原子變量 / spinlock 自旋鎖 — —CPU

既然是鎖 CPU,那就都是針對多核處理器或多 CPU 處理器。單核的話,只有發生中斷會使任務被搶佔,那麼可以進入臨界區之前先關中斷,但是對多核 CPU 光關中斷就不夠了,因爲對當前 CPU 關了中斷只能使得當前 CPU 不會運行其它要進入臨界區的程序,但其它 CPU 還是可能執行進入臨界區的程序。

原子變量:

在 x86 多核環境下,多核競爭數據總線的時候,提供 Lock 指令鎖住總線,保證 “讀 - 修改 - 寫” 操作在芯片級的原子性。這個好說,我們一般對某個被多線程會訪問的變量設置爲 atomic 類型的即可,比如 atomic_int x;或 atomic x;

自旋鎖:

當一個線程在獲取鎖的時候,如果鎖已經被其它線程獲取,那麼該線程將循環等待,然後不斷的判斷鎖是否能夠被成功獲取。使用實例如下:

#include <linux/spinlock.h>
// 定義自旋鎖
spinlock_t my_lock;
void my_function(void)
{
    spin_lock(&my_lock);
    // 訪問共享資源的操作
    spin_unlock(&my_lock);
}

互斥鎖中,要是當前線程沒拿到鎖,就會出讓 CPU;而自旋鎖中,要是當前線程沒有拿到鎖,當前線程在 CPU 上忙等待直到鎖可用,這是爲了保證響應速度更快。但是這種線程多了,那意味着多個 CPU 核都在忙等待,使得系統性能下降。

因此一定不能自旋太久,所以用戶態編程裏用自旋鎖保護臨界區的話,這個臨界區一定要儘可能小,鎖的粒度得儘可能小。

爲什麼自旋鎖的響應速度會比互斥鎖更快?

自旋鎖是通過 CPU 提供的 CAS 函數(Compare And Swap),在「用戶態」完成加鎖和解鎖操作,不會主動產生線程上下文切換,所以相比互斥鎖來說,會快一些,開銷也小一些。

而互斥鎖則不是,前面說互斥鎖加鎖失敗,線程會出讓 CPU,這個過程其實是由內核來完成線程切換的,因此加鎖失敗時,1)首先從用戶態切換至內核態,內核會把線程的狀態從「運行」狀態設置爲「睡眠」狀態,然後把 CPU 切換給其他線程運行;2)當互斥鎖可用時,之前「睡眠」狀態的線程會變爲「就緒」狀態(要進入就緒隊列了),之後內核會在合適的時間,把 CPU 切換給該線程運行。

然後返回用戶態。

這個過程中,不僅有用戶態到內核態的切換開銷,還有兩次線程上下文切換的開銷。

線程的上下文切換主要是線程棧、寄存器、線程局部變量等。

而自旋鎖在當前線程獲取鎖失敗時不會進行線程的切換,而是一直循環等待直到獲取鎖成功。因此,自旋鎖不會切換至內核態,也沒有線程切換開銷。

所以如果這個鎖被佔有的時間很短,或者說各個線程對臨界區是快進快出,那麼用自旋鎖是開銷最小的!

自旋鎖的缺點前面也說了,就是如果自旋久了或者自旋的線程數量多了,CPU 的利用率就下降了,因爲上面執行的每個線程都在忙等待— —佔用了 CPU 但什麼事都沒做。

二、信號量 / 互斥鎖 — —臨界區

信號量:

信號量(信號燈)本質是一個計數器,是描述臨界區中可用資源數目的計數器。

信號量爲 3,表示可用資源爲 3。加入初始信號量爲 3,某時刻信號量爲 1,說明可用資源數爲 1,那麼有 2 個進程 / 線程在使用資源或者說有兩個資源被消耗了(具體資源是什麼得看具體情況)。進程對信號量有 PV 操作,P 操作就是進入共享資源區前 - 1,V 操作就是離開共享資源後 + 1(這個時候信號量就表明還可以允許多少個進程進入該臨界區)。

信號量進行多線程通信編程的時候,往往初始化信號量爲 0,然後用兩個函數做線程間同步:

sem_wait():等待信號量,如果信號量的值大於 0, 將信號量的值減 1, 立即返回。如果信號量的值爲 0, 則線程阻塞。

sem_post():釋放資源,信號量 + 1 ,相當於 unlock,這樣執行了 sem_wait() 的線程就不阻塞了。

要注意:信號量本身也是個共享資源,它的 ++ 操作(釋放資源)和 -- 操作(獲取資源)也需要保護。其實就是用的自旋鎖保護的。如果有中斷的話,會把中斷保存到 eflags 寄存器,待操作完成,就去該寄存器上讀取,然後執行中斷。

struct semaphore {
     spinlock_t lock; // 自旋鎖
     unsigned int count;
     struct list_head wait_list;
};

互斥鎖:

信號量的話表示可用資源的數量,是允許多個進程 / 線程在臨界區的。但是互斥鎖不是,它的目的就是隻讓一個線程進入臨界區,其餘線程沒拿到鎖,就只能阻塞等待。線程互斥的進入臨界區,這就是互斥鎖名字由來。

另外提一下 std::timed_mutex 睡眠鎖,它和互斥鎖的區別是:

互斥鎖中,沒拿到鎖的線程就一直阻塞等待,而睡眠鎖則是設置一定的睡眠時間比如 2s,線程睡眠 2s,如果過了之後還沒拿到鎖,那就放棄拿鎖(可以輸出獲取鎖失敗),如果拿到了,那就繼續做事。比如 用成員函數 try_lock_for()

std::timed_mutex g_mutex;
//先睡2s再去搶鎖
if(g_mutex.try_lock_for(std::chrono::seconds(2)))){
  // do something
}
else{
  // 沒搶到
  std::cout<<"獲取鎖失敗";
}

三、讀寫鎖 / 搶佔 — —臨界區

讀寫鎖:

用於讀操作比寫操作更頻繁的場景,讓讀和寫分開加鎖,這樣可以減小鎖的粒度,提高程序的性能。

它允許多個線程同時讀取共享資源,但只允許一個線程寫入共享資源。這可以提高併發性能,因爲讀操作通常比寫操作頻繁得多。讀寫鎖這種就屬於高階鎖了,它的實現就可以用自旋鎖。

搶佔:

搶佔必須涉及進程上下文的切換,而中斷則是涉及中斷上下文的切換。

內核從 2.6 開始就支持內核搶佔,之前的內核不支持搶佔,只要進程在佔用 CPU 且時間片沒用完,除非有中斷,否則它就能一直佔用 CPU;

搶佔的情況:

比如某個優先級高的任務(進程),因爲需要等待資源,就主動讓出 CPU(又或者因爲中斷被打斷了),然後低優先級的任務先佔用 CPU,當資源到了,內核就讓該優先級高的任務搶佔那個正在 CPU 上跑的任務。也就是說,當前的優先級低的進程跑着跑着,時間片沒用完,也沒發生中斷,但是自己被踢掉了。

爲了支持內核搶佔,內核引入了 preempt_count 字段,該計數初始值爲 0,每當使用鎖時 + 1,釋放鎖時 - 1。當 preempt_count 爲 0 時,表示內核可以安全的搶佔,大於 0 時,則禁止內核搶佔

Per-CPU— —作用於 cache

per-cpu 變量用於解決各個 CPU 裏 L2 cache 和內存間的數據不一致性。

四、RCU 機制 / 內存屏障 — —內存

RCU 機制是 read copy update,即讀 複製 更新。

和讀寫鎖一樣,RCU 機制也是允許多個讀者同時讀,但更新數據的時候,需要先複製一份副本,在副本上完成修改,然後再一次性地替換舊數據。

比如鏈表裏修改某個節點的數據,先拷貝該節點出來,修改裏面的值,然後把節點前的指針指向拷貝出的節點

等到舊數據沒有人要讀的時,就把該內存回收。

所以 RCU 機制的核心有兩個:1)複製後更新;2)延遲迴收內存

有 RCU 機制的話,讀寫就不需要做同步,也不會發生讀寫競爭了,因爲讀者是對原來的數據進行讀,而寫者是對拷貝出來的那份內存進行修改,讀寫可以並行。

他們的讀寫是根據內存的指針來進行的,寫者寫完之後,就把舊讀者的指針賦值爲新的數據的指針,指針的賦值操作是原子的,這樣新的讀者將訪問新數據。

舊內存由一個線程專門負責回收。

內存屏障:

內存屏障則是用於控制內存訪問順序,確保指令的執行順序符合預期。

因爲代碼往往不是看我們寫的這種順序被執行的,它有兩個層面的亂序:

1)編譯器層面的。因爲編譯器的優化往往會對代碼的彙編指令進行重排

2)CPU 層面的。多 CPU 間存在內存亂序訪問的情況。

內存屏障就是讓編譯器或 CPU 對內存的訪問有序。

編譯時的亂序訪問:

int x, y, r;
void f()
{
    x = r;
    y = 1;
}

開了優化選項後編譯,得到的彙編可能是 y = 1 先執行,再 x =r 執行。可以用g++ -O2 -S test.cpp生成彙編代碼,查看開了 - O2 優化後的彙編:

我們可以使用內核提供的宏函數barrier()來避免編譯器的這種亂序:

#define barrier() __asm__ __volatile__("" ::: "memory")
int x, y, r;
void f()
{
  x = r;
  __asm__ __volatile__("" ::: "memory");
  y = 1;
}

或者將涉及到的相關變量 x 和 y 用 volatile 關鍵字修飾:

volatile int x, y;

注意,C++ 裏的 volatile 關鍵字只能避免編譯期的指令重排,對於多 CPU 的指令重排不起作用,所以實際上代碼真正運行的時候,可能又是亂序的。而 Java 的 volatile 關鍵字好像具有編譯器、CPU 兩個層面的內存屏障作用。

多 CPU 亂序訪問內存:

在單 CPU 上,不考慮編譯器優化導致亂序的前提下,多線程執行不存在內存亂序訪問的問題。因爲單個 CPU 獲取指令是有序的(隊列 FIFO),返回指令執行的結果至寄存器也是有序的(也是通過隊列)

但是在多 CPU 處理器中,因爲每個 CPU 都存有 cache,當數據 x 第一次被一個 CPU 獲取時,x 顯然不在 該 CPU 的 cache 中(這就是 cache miss)。cache miss 發生那意味着 CPU 需要從內存中獲取數據,然後數據 x 將被加載到 CPU 的 cache 中,這樣後續就能直接從 cache 上快速訪問。

當某個 CPU 進行寫操作時,它必須確保其他的 CPU 已經將數據 x 從它們的 cache 中移除(以便保證一致性),只有在移除操作完成後此 CPU 才能安全的修改數據。

顯然,存在多個 cache 時,我們必須通過 cache 的一致性協議來避免數據不一致的問題,而這個通訊的過程就可能導致亂序訪問的出現。

CPU 級別的內存屏障有三種:

  1. 通用 barrier,保證讀寫操作都有序的,mb() 和 smp_mb() // mb即memory barrier

  2. 寫操作 barrier,僅保證寫操作有序的,wmb() 和 smp_wmb()

  3. 讀操作 barrier,僅保證讀操作有序的,rmb() 和 smp_rmb()

上述這些函數也是有宏定義的比如 mb(),用在上述的編譯期間亂序的例子中就是加個mfence

#define mb() _asm__volatile("mfence":::"memory")
void f()
{
  x = 1;
  __asm__ __volatile__("mfence" ::: "memory");
  r1 = y;
}
// GNU中的內存屏障#define mfence() _asm__volatile_("mfence": : :"memory")

注意,所有的 CPU 級別的 Memory Barrier(除了數據依賴 barrier 之外)都隱含了編譯器 barrier。

而且,實際上很多線程同步機制,都在底層有內存屏障作爲支撐,比如原子鎖和自旋鎖都是依賴 CPU 提供的 CAS 操作實現。CAS 即 Compare and Swap, 它的基本思想是:

在多線程環境下,如果需要修改共享變量的值,先讀取該變量的值,然後修改該變量的值,最後將新值與舊值進行比較,如果相同,則修改成功,否則修改失敗,需要重新執行該操作。

在實現 CAS 操作時,需要使用內存屏障來保證操作的順序和一致性。例如,在 Java 中,使用 Atomic 類的 compareAndSet 方法實現 CAS 操作時,會自動插入內存屏障來保證操作的正確性。

對於應用層的編程而言,C++11 引入了內存模型,它確保了多線程程序中的同步和一致性。內存屏障(CPU 級別)就是內存模型的一部分,用於確保特定的內存操作順序,X86-64 下僅支持一種指令重排:Store-Load ,即讀操作可能會重排到寫操作前面。

內存屏障有兩種類型:store 和 load,使用示例如下:

// store屏障 
std::atomic<int> x; 
x.store(1, std::memory_order_release); // store屏障確保之前的寫操作在之後的寫操作之前完成
// load屏障 
std::atomic<int> y; 
int val = y.load(std::memory_order_acquire); // load屏障確保之前的讀操作在之後的讀操作之前完成

CPU 級別的內存屏障除了保證指令順序外,還要保證數據的可見性,不可見就會導致數據的不一致性。

所以上述代碼中也用到了 acquire 和 release 語義分別對讀和寫設置屏障:

acquire:保證 acquire 後的讀寫操作不會發生在 acquire 動作之前

release:保證 release 前的讀寫操作不會發生在 release 動作之後

除了上面的 atomic 的 load 和 store,C++11 還提供了單獨的內存屏障函數 std::atomic_thread_fence,其用法和上述的類似:

#include <atomic>
std::atomic_thread_fence(std::memory_order_acquire);
std::atomic_thread_fence(std::memory_order_release);

五、內核中使用這些鎖的示例

進程調度:內核鎖用於保護調度器的數據結構,以避免多個 CPU 同時修改它們而導致錯誤。

// 自旋鎖
spin_lock(&rq->lock); 
... 
spin_unlock(&rq->lock);

文件系統:內核鎖用於保護文件系統的元數據,如 inode、dentry 等數據結構,以避免多個進程同時訪問它們而導致錯誤。

spin_lock(&inode->i_lock); 
... 
spin_unlock(&inode->i_lock);

網絡協議棧:內核鎖用於保護網絡協議棧的數據結構,如套接字、路由表等,以避免多個進程同時訪問它們而導致錯誤。

read_lock(&rt_hash_lock); 
...
read_unlock(&rt_hash_lock);

內存管理:內核鎖用於保護內存管理的數據結構,如頁表、內存映射等,以避免多個進程同時訪問它們而導致錯誤

spin_lock(&mm->page_table_lock);
... 
spin_unlock(&mm->page_table_lock);
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/1-YpIr20KhYlpzub7JUU-A