Linux 內核同步機制:確保系統穩定與高效

在複雜而龐大的 Linux 系統世界中,內核就如同一位有條不紊的指揮官,協調着各種任務和資源的分配。而其中,內核同步機制則是確保整個系統穩定與高效運行的關鍵要素。想象一下,衆多的進程和線程在 Linux 內核的舞臺上同時登場,它們都渴望訪問共享的資源,如同千軍萬馬奔向同一個目標。如果沒有有效的同步機制,混亂和衝突將不可避免,系統可能陷入崩潰的邊緣。

那麼,Linux 內核同步機制究竟是如何發揮其神奇的魔力呢?它又是通過哪些巧妙的方式來保證不同的任務能夠和諧共處,共同推動系統的高效運轉呢?讓我們一同深入探索 Linux 內核同步機制的奧祕,揭開它神祕的面紗,領略其在系統穩定性和高效性方面所展現出的強大力量。

一、引言

Linux 內核同步機制在多任務、多處理器環境下至關重要,它確保了併發操作的正確性和一致性。本文將深入探討 Linux 內核同步機制的各種類型及其應用場景。

在當今複雜的計算機系統中,多任務和多處理器環境已成爲常態。在這樣的環境下,不同的任務和處理器可能同時訪問共享資源,這就可能導致數據不一致和錯誤的結果。爲了解決這個問題,Linux 內核提供了多種同步機制。這些同步機制可以分爲不同的類型,每種類型都有其特定的應用場景。例如,自旋鎖適用於保護短時間的臨界區,當一個處理器試圖獲取已被其他處理器持有的自旋鎖時,它會一直循環檢查(旋轉)直到鎖變爲可用。而互斥鎖則用於保護代碼段,確保同時只有一個線程可以執行。如果一個線程不能獲得互斥鎖,它會被掛起而不是忙等待。

讀寫鎖則允許多個讀操作同時進行,但寫操作是排他的。這有助於提高併發性能,特別是當讀操作遠多於寫操作時。信號量是一種計數器,用於控制對共享資源的訪問。它可以用作互斥鎖或同步多個線程。

此外,還有完成量、等待隊列、屏障、原子操作、順序鎖、RCU 等同步機制。完成量用於等待某個事件或任務的完成,等待隊列用於阻塞等待某個條件的發生,屏障用於同步一組線程,直到所有線程都到達屏障點,原子操作是一系列不可分割的操作,順序鎖是一種輕量級的鎖機制,適用於讀多寫少的場景,RCU 是一種高效的無鎖同步機制,允許多個線程併發讀取,而寫操作需要複製數據。

選擇正確的同步機制對於提高系統性能和保證數據一致性至關重要。不同的同步機制適用於不同的併發場景和性能需求,因此在設計和實現 Linux 內核中的併發程序時,需要根據具體情況選擇合適的同步機制。

爲了弄清楚什麼事同步機制,必需要弄明確下面三個問題:

1.1 什麼是相互排斥與同步?(通俗理解)

1.2Linux 爲什麼須要同步機制?

在操作系統引入了進程概念,進程成爲調度實體後,系統就具備了併發運行多個進程的能力,但也導致了系統中各個進程之間的資源競爭和共享。另外,因爲中斷、異常機制的引入,以及內核態搶佔都導致了這些內核運行路徑(進程)以交錯的方式運行。

對於這些交錯路徑運行的內核路徑,如不採取必要的同步措施。將會對一些重要數據結構進行交錯訪問和改動。從而導致這些數據結構狀態的不一致,進而導致系統崩潰。

因此。爲了確保系統高效穩定有序地運行,linux 必需要採用同步機制。

1.3Linux 內核提供了哪些同步機制?

在學習 linux 內核同步機制之前。先要了解下面預備知識:(臨界資源與併發源)

在 linux 系統中,我們把對共享的資源進行訪問的代碼片段稱爲臨界區。把導致出現多個進程對同一共享資源進行訪問的原因稱爲併發源。

Linux 系統下併發的主要來源有:

如前所述可知:採用同步機制的目的就是避免多個進程併發併發訪問同一臨界資源。常用的 Linux 內核同步機制有原子操作、Per-CPU 變量、內存屏障、自旋鎖、Mutex 鎖、信號量和 RCU 等,後面幾種鎖實現會依賴於前三種基礎同步機制。在正式開始分析具體的內核同步機制實現之前,需要先澄清一些基本概念。

二、基本概念

2.1 同步

既然是同步機制,那就首先要搞明白什麼是同步。同步是指用於實現控制多個執行路徑按照一定的規則或順序訪問某些系統資源的機制。所謂執行路徑,就是在 CPU 上運行的代碼流。我們知道,CPU 調度的最小單位是線程,可以是用戶態線程,也可以是內核線程,甚至是中斷服務程序。所以,執行路徑在這裏就包括用戶態線程、內核線程和中斷服務程序。執行路徑、執行單元、控制路徑等等,叫法不同,但本質都一樣。那爲什麼需要同步機制呢?請繼續往下看。

2.2 併發與競態

併發是指兩個以上的執行路徑同時被執行,而併發的執行路徑對共享資源(硬件資源和軟件上的全局變量等)的訪問則很容易導致競態。例如,現在系統有一個 LED 燈可以由 APP 控制,APP1 控制燈亮一秒滅一秒,APP2 控制燈亮 500ms 滅 1500ms。

如果 APP1 和 APP2 分別在 CPU1 和 CPU2 上併發運行,LED 燈的行爲會是什麼樣的呢?很有可能 LED 燈的亮滅節奏都不會如這兩個 APP 所願,APP1 在關掉 LED 燈時,很有可能恰逢 APP2 正要打開 LED 燈。很明顯,APP1 和 APP2 對 LED 燈這個資源產生了競爭關係。競態是危險的,如果不加以約束,輕則只是程序運行結果不符合預期,重則系統崩潰。

在操作系統中,更復雜、更混亂的併發大量存在,而同步機制正是爲了解決併發和競態問題。同步機制通過保護臨界區(訪問共享資源的代碼區域)達到對共享資源互斥訪問的目的,所謂互斥訪問,是指一個執行路徑在訪問共享資源時,另一個執行路徑被禁止去訪問。關於併發與競態,有個生活例子很貼切。

假如你和你的同事張小三都要上廁所,但是公司只有一個洗手間而且也只有一個坑。當張小三進入廁所關起門的那一刻起,你就無法進去了,只能在門外侯着。當小三哥出來後你才能進去解決你的問題。這裏,公司廁所就是共享資源,你和張小三同時需要這個共享資源就是併發,你們對廁所的使用需求就構成了競態,而廁所的門就是一種同步機制,他在用你就不能用了。

2.3 中斷與搶佔

中斷本身的概念很簡單,本文不予解釋。當然,這並不是說 Linux 內核的中斷部分也很簡單。事實上,Linux 內核的中斷子系統也相當複雜,因爲中斷對於操作系統來說實在是太重要了。以後有機會,筆者計劃開專題再來介紹。對於同步機制的代碼分析來說,瞭解中斷的概念即可,不需要深入分析內核的具體代碼實現。搶佔屬於進程調度的概念,Linux 內核從 2.6 版本開始支持搶佔調度。

進程調度(管理)是 Linux 內核最核心的子系統之一,異常龐大,本文只簡單介紹基本概念,對於同步機制的代碼分析已然足夠。通俗地說,搶佔是指一個正愉快地運行在 CPU 上的 task(可以是用戶態進程,也可以是內核線程) 被另一個 task(通常是更高優先級)奪去 CPU 執行權的故事。中斷和搶佔之間有着比較曖昧的關係,簡單來說,搶佔依賴中斷。如果當前 CPU 禁止了本地中斷,那麼也意味着禁止了本 CPU 上的搶佔。

但反過來,禁掉搶佔並不影響中斷。Linux 內核中用 preempt_enable() 宏函數來開啓本 CPU 的搶佔,用 preempt_disable() 來禁掉本 CPU 的搶佔。這裏,“本 CPU” 這個描述其實不太準確,更嚴謹的說法是運行在當前 CPU 上的 task。preempt_enable() 和 preempt_disable() 的具體實現展開來介紹的話也可以單獨成文了,筆者沒有深究過,就不班門弄斧了,感興趣的讀者可以去 RTFSC。不管是用戶態搶佔還是內核態搶佔,並不是什麼代碼位置都能發生,而是有搶佔時機的,也就是所謂的搶佔點。搶佔時機如下:

2.4 編譯亂序與編譯屏障

編譯器(compiler)的工作就是優化我們的代碼以提高性能。這包括在不改變程序行爲的情況下重新排列指令。因爲 compiler 不知道什麼樣的代碼需要線程安全(thread-safe),所以 compiler 假設我們的代碼都是單線程執行(single-threaded),並且進行指令重排優化並保證是單線程安全的。因此,當你不需要 compiler 重新排序指令的時候,你需要顯式告訴 compiler,我不需要重排。否則,它可不會聽你的。本篇文章中,我們一起探究 compiler 關於指令重排的優化規則。

注:測試使用 aarch64-linux-gnu-gcc 版本:7.3.0

編譯器指令重排(Compiler Instruction Reordering)

compiler 的主要工作就是將對人們可讀的源碼轉化成機器語言,機器語言就是對 CPU 可讀的代碼。因此,compiler 可以在背後做些不爲人知的事情。我們考慮下面的 C 語言代碼:

int a, b;
 
void foo(void)
{
    a = b + 1;
    b = 0;
}

使用 aarch64-linux-gnu-gcc 在不優化代碼的情況下編譯上述代碼,使用 objdump 工具查看 foo() 反彙編結果:

<foo>:
    ...
    ldr w0, [x0]       //load b to w0
    add w1, w0, #0x1
    ...
    str w1, [x0]       //a = b + 1
    ...
    str wzr, [x0]      //b = 0

我們應該知道 Linux 默認編譯優化選項是 -O2,因此我們採用 -O2 優化選項編譯上述代碼,並反彙編得到如下彙編結果:

<foo>:
    ...
    ldr w2, [x0]       //load b to w2
    str wzr, [x0]      //b = 0
    add w0, w2, #0x1
    str w0, [x1]       //a = b + 1

比較優化和不優化的結果,我們可以發現:在不優化的情況下,a 和 b 的寫入內存順序符合代碼順序(program order);但是 -O2 優化後,a 和 b 的寫入順序和 program order 是相反的。-O2 優化後的代碼轉換成 C 語言可以看作如下形式:

int a, b;
 
void foo(void)
{
    register int reg = b;
 
    b = 0;
    a = reg + 1;
}

這就是 compiler reordering(編譯器重排)。爲什麼可以這麼做呢?對於單線程來說,a 和 b 的寫入順序,compiler 認爲沒有任何問題。並且最終的結果也是正確的(a == 1 && b == 0)。這種 compiler reordering 在大部分情況下是沒有問題的。但是在某些情況下可能會引入問題。例如我們使用一個全局變量 flag 標記共享數據 data 是否就緒。由於 compiler reordering,可能會引入問題。考慮下面的代碼(無鎖編程):

int flag, data;
 
void write_data(int value)
{
    data = value;
    flag = 1;
}

如果 compiler 產生的彙編代碼是 flag 比 data 先寫入內存,那麼,即使是單核系統上,我們也會有問題。在 flag 置 1 之後,data 寫 45 之前,系統發生搶佔。另一個進程發現 flag 已經置 1,認爲 data 的數據已經準備就緒。但是實際上讀取 data 的值並不是 45。爲什麼 compiler 還會這麼操作呢?因爲,compiler 並不知道 data 和 flag 之間有嚴格的依賴關係。這種邏輯關係是我們人爲強加的。我們如何避免這種優化呢?

顯式編譯器屏障(Explicit Compiler Barriers)

爲了解決上述變量之間存在依賴關係導致 compiler 錯誤優化。compiler 爲我們提供了編譯器屏障(compiler barriers),可用來告訴 compiler 不要 reorder。我們繼續使用上面的 foo() 函數作爲演示實驗,在代碼之間插入 compiler barriers。

#define barrier() __asm__ __volatile__("": : :"memory")
 
int a, b;
 
void foo(void)
{
    a = b + 1;
    barrier();
    b = 0;
}

barrier() 就是 compiler 提供的屏障,作用是告訴 compiler 內存中的值已經改變,之前對內存的緩存(緩存到寄存器)都需要拋棄,barrier() 之後的內存操作需要重新從內存 load,而不能使用之前寄存器緩存的值。並且可以防止 compiler 優化 barrier() 前後的內存訪問順序。barrier() 就像是代碼中的一道不可逾越的屏障,barrier() 前的 load/store 操作不能跑到 barrier() 後面;同樣,barrier() 後面的 load/store 操作不能在 barrier() 之前。依然使用 -O2 優化選項編譯上述代碼,反彙編得到如下結果:

<foo>:
    ...
    ldr w2, [x0]       //load b to w2
    add w2, w2, #0x1
    str w2, [x1]       //a = a + 1
    str wzr, [x0]      //b = 0
    ...

我們可以看到插入 compiler barriers 之後,a 和 b 的寫入順序和 program order 一致。因此,當我們的代碼中需要嚴格的內存順序,就需要考慮 compiler barriers。

隱式編譯器屏障(Implied Compiler Barriers)

除了顯示的插入 compiler barriers 之外,還有別的方法阻止 compiler reordering。例如 CPU barriers 指令,同樣會阻止 compiler reordering。後續我們再考慮 CPU barriers。除此以外,當某個函數內部包含 compiler barriers 時,該函數也會充當 compiler barriers 的作用。即使這個函數被 inline,也是這樣。例如上面插入 barrier() 的 foo() 函數,當其他函數調用 foo() 時,foo() 就相當於 compiler barriers。考慮下面的代碼:

int a, b, c;
 
void fun(void)
{
    c = 2;
    barrier();
}
 
void foo(void)
{
    a = b + 1;
    fun(); /* fun() call acts as compiler barriers */
    b = 0;
}

fun() 函數包含 barrier(),因此 foo() 函數中 fun() 調用也表現出 compiler barriers 的作用,同樣可以保證 a 和 b 的寫入順序。如果 fun() 函數不包含 barrier(),結果又會怎麼樣呢?實際上,大多數的函數調用都表現出 compiler barriers 的作用。但是,這不包含 inline 的函數。

因此,fun() 如果被 inline 進 foo(),那麼 fun() 就不具有 compiler barriers 的作用。如果被調用的函數是一個外部函數,其副作用會比 compiler barriers 還要強。因爲 compiler 不知道函數的副作用是什麼。它必須忘記它對內存所作的任何假設,即使這些假設對該函數可能是可見的。我們看一下下面的代碼片段,printf() 一定是一個外部的函數。

int a, b;
 
void foo(void)
{
    a = 5;
    printf("smcdef");
    b = a;
}

同樣使用 -O2 優化選項編譯代碼,objdump 反彙編得到如下結果:

<foo>:
    ...
    mov w2, #0x5              //#5
    str w2, [x19]             //a = 5
    bl 640 <__printf_chk@plt> //printf()
    ldr w1, [x19]             //reload a to w1
    ...
    str w1, [x0]              //b = a

compiler 不能假設 printf() 不會使用或者修改 a 變量。因此在調用 printf() 之前會將 a 寫 5,以保證 printf() 可能會用到新值。在 printf() 調用之後,重新從內存中 load a 的值,然後賦值給變量 b。重新 load a 的原因是 compiler 也不知道 printf() 會不會修改 a 的值。

因此,我們可以看到即使存在 compiler reordering,但是還是有很多限制。當我們需要考慮 compiler barriers 時,一定要顯示的插入 barrier(),而不是依靠函數調用附加的隱式 compiler barriers。因爲,誰也無法保證調用的函數不會被 compiler 優化成 inline 方式。

barrier() 除了防止編譯亂序,還能做什麼

barriers() 作用除了防止 compiler reordering 之外,還有什麼妙用嗎?我們考慮下面的代碼片段:

int run = 1;
 
void foo(void)
{
    while (run)
        ;
}

run 是個全局變量,foo() 在一個進程中執行,一直循環。我們期望的結果是 foo() 一直等到其他進程修改 run 的值爲 0 才退出循環。實際 compiler 編譯的代碼和我們會達到我們預期的結果嗎?我們看一下彙編代碼:

0000000000000748 <foo>:
748: 90000080 adrp x0, 10000
74c: f947e800 ldr x0, [x0, #4048]
750: b9400000 ldr w0, [x0]            //load run to w0
754: d503201f nop
758: 35000000 cbnz w0, 758 <foo+0x10> //if (w0) while (1);
75c: d65f03c0 ret

彙編代碼可以轉換成如下的 C 語言形式:

int run = 1;
 
void foo(void)
{
    register int reg = run;
 
    if (reg)
        while (1)
            ;
}

compiler 首先將 run 加載到一個寄存器 reg 中,然後判斷 reg 是否滿足循環條件,如果滿足就一直循環。但是循環過程中,寄存器 reg 的值並沒有變化。因此,即使其他進程修改 run 的值爲 0,也不能使 foo() 退出循環。很明顯,這不是我們想要的結果。我們繼續看一下加入 barrier() 後的結果:

0000000000000748 <foo>:
748: 90000080 adrp x0, 10000
74c: f947e800 ldr x0, [x0, #4048]
750: b9400001 ldr w1, [x0]            //load run to w0
754: 34000061 cbz w1, 760 <foo+0x18>
758: b9400001 ldr w1, [x0]            //load run to w0
75c: 35ffffe1 cbnz w1, 758 <foo+0x10> //if (w0) goto 758
760: d65f03c0 ret

可以看到加入 barrier() 後的結果真是我們想要的。每一次循環都會從內存中重新 load run 的值。因此,當有其他進程修改 run 的值爲 0 的時候,foo() 可以正常退出循環。爲什麼加入 barrier() 後的彙編代碼就是正確的呢?因爲 barrier() 作用是告訴 compiler 內存中的值已經變化,後面的操作都需要重新從內存 load,而不能使用寄存器緩存的值。因此,這裏的 run 變量會從內存重新 load,然後判斷循環條件。這樣,其他進程修改 run 變量,foo() 就可以看得見了。

在 Linux kernel 中,提供了 cpu_relax() 函數,該函數在 ARM64 平臺定義如下:

static inline void cpu_relax(void)
{
    asm volatile("yield" ::: "memory");
}

我們可以看出,cpu_relax() 是在 barrier() 的基礎上又插入一條彙編指令 yield。在 kernel 中,我們經常會看到一些類似上面舉例的 while 循環,循環條件是個全局變量。爲了避免上述所說問題,我們就會在循環中插入 cpu_relax() 調用。

int run = 1;
 
void foo(void)
{
    while (run)
        cpu_relax();
}

當然也可以使用 Linux 提供的 READ_ONCE()。例如,下面的修改也同樣可以達到我們預期的效果。

int run = 1;
 
void foo(void)
{
    while (READ_ONCE(run)) /* similar to while (*(volatile int *)&run) */
        ;
}

當然你也可以修改 run 的定義爲 volatile int run就會得到如下代碼。同樣可以達到預期目的。

volatile int run = 1;
 
void foo(void)
{
    while (run);
}

2.5 執行亂序與內存屏障

不管是編譯亂序還是執行亂序,都是爲了提升 CPU 的性能。執行亂序是處理器運行時的行爲,和 CPU 內部設計架構有關。而對於從事在 Linux 內核的程序員來說,要真正的理解透執行亂序所帶來的軟件方面的影響,首先需要搞清楚 cache 的概念。

三、主要同步機制介紹

3.1 自旋鎖(spinlock)

自旋鎖是一種基本的同步機制,它使用忙等待的方式來實現互斥訪問。當一個線程嘗試獲取自旋鎖時,如果鎖已被其他線程佔用,該線程會一直循環等待,直到鎖被釋放。

自旋鎖與互斥鎖有點類似,只是自旋鎖不會引起調用者睡眠,如果自旋鎖已經被別的執行單元保持,調用者就一直循環在那裏看是否該自旋鎖的保持者已經釋放了鎖,"自旋" 一詞就是因此而得名。

由於自旋鎖使用者一般保持鎖時間非常短,因此選擇自旋而不是睡眠是非常必要的,自旋鎖的效率遠高於互斥鎖。信號量和讀寫信號量適合於保持時間較長的情況,它們會導致調用者睡眠,因此只能在進程上下文使用(_trylock 的變種能夠在中斷上下文使用),而自旋鎖適合於保持時間非常短的情況,它可以在任何上下文使用。

如果被保護的共享資源只在進程上下文訪問,使用信號量保護該共享資源非常合適,如果對共巷資源的訪問時間非常短,自旋鎖也可以。但是如果被保護的共享資源需要在中斷上下文訪問(包括底半部即中斷處理句柄和頂半部即軟中斷),就必須使用自旋鎖。

自旋鎖保持期間是搶佔失效的,而信號量和讀寫信號量保持期間是可以被搶佔的。自旋鎖只有在內核可搶佔或 SMP 的情況下才真正需要,在單 CPU 且不可搶佔的內核下,自旋鎖的所有操作都是空操作。

跟互斥鎖一樣,一個執行單元要想訪問被自旋鎖保護的共享資源,必須先得到鎖,在訪問完共享資源後,必須釋放鎖。如果在獲取自旋鎖時,沒有任何執行單元保持該鎖,那麼將立即得到鎖;如果在獲取自旋鎖時鎖已經有保持者,那麼獲取鎖操作將自旋在那裏,直到該自旋鎖的保持者釋放了鎖。

無論是互斥鎖,還是自旋鎖,在任何時刻,最多隻能有一個保持者,也就說,在任何時刻最多隻能有一個執行單元獲得鎖。自旋鎖的 API 有:

獲得自旋鎖和釋放自旋鎖有好幾個版本,因此讓讀者知道在什麼樣的情況下使用什麼版本的獲得和釋放鎖的宏是非常必要的。

如果被保護的共享資源只在進程上下文訪問和軟中斷上下文訪問,那麼當在進程上下文訪問共享資源時,可能被軟中斷打斷,從而可能進入軟中斷上下文來對被保護的共享資源訪問,因此對於這種情況,對共享資源的訪問必須使用 spin_lock_bh 和 spin_unlock_bh 來保護。

當然使用 spin_lock_irq 和 spin_unlock_irq 以及 spin_lock_irqsave 和 spin_unlock_irqrestore 也可以,它們失效了本地硬中斷,失效硬中斷隱式地也失效了軟中斷。但是使用 spin_lock_bh 和 spin_unlock_bh 是最恰當的,它比其他兩個快。

如果被保護的共享資源只在進程上下文和 tasklet 或 timer 上下文訪問,那麼應該使用與上面情況相同的獲得和釋放鎖的宏,因爲 tasklet 和 timer 是用軟中斷實現的。

如果被保護的共享資源只在一個 tasklet 或 timer 上下文訪問,那麼不需要任何自旋鎖保護,因爲同一個 tasklet 或 timer 只能在一個 CPU 上運行,即使是在 SMP 環境下也是如此。實際上 tasklet 在調用 tasklet_schedule 標記其需要被調度時已經把該 tasklet 綁定到當前 CPU,因此同一個 tasklet 決不可能同時在其他 CPU 上運行。

timer 也是在其被使用 add_timer 添加到 timer 隊列中時已經被幫定到當前 CPU,所以同一個 timer 絕不可能運行在其他 CPU 上。當然同一個 tasklet 有兩個實例同時運行在同一個 CPU 就更不可能了。

如果被保護的共享資源只在兩個或多個 tasklet 或 timer 上下文訪問,那麼對共享資源的訪問僅需要用 spin_lock 和 spin_unlock 來保護,不必使用_bh 版本,因爲當 tasklet 或 timer 運行時,不可能有其他 tasklet 或 timer 在當前 CPU 上運行。

如果被保護的共享資源只在一個軟中斷(tasklet 和 timer 除外)上下文訪問,那麼這個共享資源需要用 spin_lock 和 spin_unlock 來保護,因爲同樣的軟中斷可以同時在不同的 CPU 上運行。

如果被保護的共享資源在兩個或多個軟中斷上下文訪問,那麼這個共享資源當然更需要用 spin_lock 和 spin_unlock 來保護,不同的軟中斷能夠同時在不同的 CPU 上運行。

如果被保護的共享資源在軟中斷(包括 tasklet 和 timer)或進程上下文和硬中斷上下文訪問,那麼在軟中斷或進程上下文訪問期間,可能被硬中斷打斷,從而進入硬中斷上下文對共享資源進行訪問,因此,在進程或軟中斷上下文需要使用 spin_lock_irq 和 spin_unlock_irq 來保護對共享資源的訪問。

而在中斷處理句柄中使用什麼版本,需依情況而定,如果只有一箇中斷處理句柄訪問該共享資源,那麼在中斷處理句柄中僅需要 spin_lock 和 spin_unlock 來保護對共享資源的訪問就可以了。

因爲在執行中斷處理句柄期間,不可能被同一 CPU 上的軟中斷或進程打斷。但是如果有不同的中斷處理句柄訪問該共享資源,那麼需要在中斷處理句柄中使用 spin_lock_irq 和 spin_unlock_irq 來保護對共享資源的訪問。

在使用 spin_lock_irq 和 spin_unlock_irq 的情況下,完全可以用 spin_lock_irqsave 和 spin_unlock_irqrestore 取代,那具體應該使用哪一個也需要依情況而定,如果可以確信在對共享資源訪問前中斷是使能的,那麼使用 spin_lock_irq 更好一些。

因爲它比 spin_lock_irqsave 要快一些,但是如果你不能確定是否中斷使能,那麼使用 spin_lock_irqsave 和 spin_unlock_irqrestore 更好,因爲它將恢復訪問共享資源前的中斷標誌而不是直接使能中斷。

當然,有些情況下需要在訪問共享資源時必須中斷失效,而訪問完後必須中斷使能,這樣的情形使用 spin_lock_irq 和 spin_unlock_irq 最好。

需要特別提醒讀者,spin_lock 用於阻止在不同 CPU 上的執行單元對共享資源的同時訪問以及不同進程上下文互相搶佔導致的對共享資源的非同步訪問,而中斷失效和軟中斷失效卻是爲了阻止在同一 CPU 上軟中斷或中斷對共享資源的非同步訪問。

3.2 信號量(semaphore)

信號量是一種睡眠鎖。如果任務想要獲取一個不可用的信號量時,信號量會將任務推進一個隊列,然後讓這個任務睡眠。當該信號量可用後,處於等待隊列的任務將被喚醒,並獲得該信號量。Linux 內核的信號量在概念和原理上與用戶態的 System V 的 IPC 機制信號量是一樣的,但是它絕不可能在內核之外使用,因此它與 System V 的 IPC 機制信號量毫不相干。

信號量在創建時需要設置一個初始值,表示同時可以有幾個任務可以訪問該信號量保護的共享資源,初始值爲 1 就變成互斥鎖(Mutex),即同時只能有一個任務可以訪問信號量保護的共享資源。一個任務要想訪問共享資源,首先必須得到信號量,獲取信號量的操作將把信號量的值減 1,若當前信號量的值爲負數,表明無法獲得信號量,該任務必須掛起在該信號量的等待隊列等待該信號量可用;若當前信號量的值爲非負數,表示可以獲得信號量,因而可以立刻訪問被該信號量保護的共享資源。

當任務訪問完被信號量保護的共享資源後,必須釋放信號量,釋放信號量通過把信號量的值加 1 實現,如果信號量的值爲非正數,表明有任務等待當前信號量,因此它也喚醒所有等待該信號量的任務。信號量的 API 有:

信號量在絕大部分情況下作爲互斥鎖使用,下面以 console 驅動系統爲例說明信號量的使用。

在內核源碼樹的 kernel/printk.c 中,使用宏 DECLARE_MUTEX 聲明瞭一個互斥鎖 console_sem,它用於保護 console 驅動列表 console_drivers 以及同步對整個 console 驅動系統的訪問。

其中定義了函數 acquire_console_sem 來獲得互斥鎖 console_sem,定義了 release_console_sem 來釋放互斥鎖 console_sem,定義了函數 try_acquire_console_sem 來盡力得到互斥鎖 console_sem。這三個函數實際上是分別對函數 down,up 和 down_trylock 的簡單包裝。

需要訪問 console_drivers 驅動列表時就需要使用 acquire_console_sem 來保護 console_drivers 列表,當訪問完該列表後,就調用 release_console_sem 釋放信號量 console_sem。

函數 console_unblank,console_device,console_stop,console_start,register_console 和 unregister_console 都需要訪問 console_drivers,因此它們都使用函數對 acquire_console_sem 和 release_console_sem 來對 console_drivers 進行保護。

3.3 讀寫信號量(rw_semaphore)

讀寫信號量是對普通信號量的擴展,區分了讀操作和寫操作。多個讀操作可以同時獲取到讀鎖,但是一旦有任務獲取了寫鎖,則其他任務不允許再獲取讀鎖和寫鎖。

讀寫信號量在以讀爲主的情況下非常有用,例如在一個數據庫系統中,多個客戶端可以同時讀取數據,但是在寫入數據時,需要獨佔訪問。這樣可以提高系統的併發性能,同時保證數據的一致性。

寫信號量對訪問者進行了細分,或者爲讀者,或者爲寫者,讀者在保持讀寫信號量期間只能對該讀寫信號量保護的共享資源進行讀訪問,如果一個任務除了需要讀,可能還需要寫,那麼它必須被歸類爲寫者,它在對共享資源訪問之前必須先獲得寫者身份,寫者在發現自己不需要寫訪問的情況下可以降級爲讀者。讀寫信號量同時擁有的讀者數不受限制,也就說可以有任意多個讀者同時擁有一個讀寫信號量。

如果一個讀寫信號量當前沒有被寫者擁有並且也沒有寫者等待讀者釋放信號量,那麼任何讀者都可以成功獲得該讀寫信號量;否則,讀者必須被掛起直到寫者釋放該信號量。如果一個讀寫信號量當前沒有被讀者或寫者擁有並且也沒有寫者等待該信號量,那麼一個寫者可以成功獲得該讀寫信號量,否則寫者將被掛起,直到沒有任何訪問者。因此,寫者是排他性的,獨佔性的。

讀寫信號量有兩種實現,一種是通用的,不依賴於硬件架構,因此,增加新的架構不需要重新實現它,但缺點是性能低,獲得和釋放讀寫信號量的開銷大;另一種是架構相關的,因此性能高,獲取和釋放讀寫信號量的開銷小,但增加新的架構需要重新實現。在內核配置時,可以通過選項去控制使用哪一種實現。讀寫信號量的相關 API 有:

在 Linux 中,每一個進程都用一個類型爲 task_t 或 struct task_struct 的結構來描述,該結構的類型爲 struct mm_struct 的字段 mm 描述了進程的內存映像,特別是 mm_struct 結構的 mmap 字段維護了整個進程的內存塊列表,該列表將在進程生存期間被大量地遍利或修改。結構的 mmap 字段維護了整個進程的內存塊列表,該列表將在進程生存期間被大量地遍利或修改。

因此 mm_struct 結構就有一個字段 mmap_sem 來對 mmap 的訪問進行保護,mmap_sem 就是一個讀寫信號量,在 proc 文件系統裏有很多進程內存使用情況的接口,通過它們能夠查看某一進程的內存使用情況,命令 free、ps 和 top 都是通過 proc 來得到內存使用信息的,proc 接口就使用 down_read 和 up_read 來讀取進程的 mmap 信息。

當進程動態地分配或釋放內存時,需要修改 mmap 來反映分配或釋放後的內存映像,因此動態內存分配或釋放操作需要以寫者身份獲得讀寫信號量 mmap_sem 來對 mmap 進行更新。系統調用 brk 和 munmap 就使用了 down_write 和 up_write 來保護對 mmap 的訪問。

3.4 互斥鎖

互斥鎖是一種較爲常用的同步機制,其原理是在多個進程或線程訪問同一個資源之前先嚐試獲取互斥鎖,如果該鎖已經被佔用,則進程或線程會阻塞等待。

互斥鎖的底層原理涉及多個方面,包括硬件支持、原子操作、內核調度以及鎖的實現方式。例如,在 Linux 操作系統中,互斥鎖的底層實現依賴於硬件提供的原子操作,通過原子操作來確保操作的不可中斷性。當一個線程嘗試獲取互斥鎖但鎖已被其他線程持有時,線程會進入休眠狀態,並釋放 CPU 資源。內核將在鎖可用時選擇一個線程喚醒並分配 CPU 時間,以允許其繼續執行。

3.5 原子操作

所謂原子操作,就是該操作絕不會在執行完畢前被任何其他任務或事件打斷,也就說,它的最小的執行單位,不可能有比它更小的執行單位,因此這裏的原子實際是使用了物理學裏的物質微粒的概念。

原子操作需要硬件的支持,因此是架構相關的,其 API 和原子類型的定義都定義在內核源碼樹的 include/asm/atomic.h 文件中,它們都使用匯編語言實現,因爲 C 語言並不能實現這樣的操作。

原子操作主要用於實現資源計數,很多引用計數 (refcnt) 就是通過原子操作實現的。原子類型定義如下:

typedef struct { 
volatile int counter; 
} atomic_t;

volatile 修飾字段告訴 gcc 不要對該類型的數據做優化處理,對它的訪問都是對內存的訪問,而不是對寄存器的訪問。原子操作 API 包括:

原子操作通常用於實現資源的引用計數,在 TCP/IP 協議棧的 IP 碎片處理中,就使用了引用計數,碎片隊列結構 struct ipq 描述了一個 IP 碎片,字段 refcnt 就是引用計數器,它的類型爲 atomic_t,當創建 IP 碎片時(在函數 ip_frag_create 中),使用 atomic_set 函數把它設置爲 1,當引用該 IP 碎片時,就使用函數 atomic_inc 把引用計數加 1。

當不需要引用該 IP 碎片時,就使用函數 ipq_put 來釋放該 IP 碎片,ipq_put 使用函數 atomic_dec_and_test 把引用計數減 1 並判斷引用計數是否爲 0,如果是就釋放 IP 碎片。函數 ipq_kill 把 IP 碎片從 ipq 隊列中刪除,並把該刪除的 IP 碎片的引用計數減 1(通過使用函數 atomic_dec 實現)。

3.6 等待隊列

以隊列爲基礎數據結構,與進程調度機制緊密結合,能夠用於實現核心的異步事件通知機制。

等待隊列在 Linux 內核中廣泛應用,例如在設備驅動程序中,當設備不可用時,進程可以將自己加入等待隊列,等待設備變爲可用。當設備變爲可用時,驅動程序可以喚醒等待隊列中的進程,通知它們設備已經可以使用。

3.7 大內核鎖

在早期的 Linux 內核中,BKL 是一個全局鎖,用於保護整個內核。如今已被更細粒度的鎖機制所取代。

大內核鎖在早期的 Linux 內核中起到了重要的作用,但是隨着內核的發展,它的存在成爲了性能瓶頸。因爲它是一個全局鎖,所以在多處理器環境下,會導致大量的線程等待,降低系統的併發性能。因此,Linux 內核逐漸採用了更細粒度的鎖機制,以提高系統的性能。

3.8 讀寫鎖

類似於讀寫信號量,允許任意數量的讀取者共享資源,但只允許一個寫入者獨佔資源。

讀寫鎖在一些需要頻繁讀取但較少寫入的場景中非常有用,例如在一個配置文件讀取的程序中,多個線程可能同時需要讀取配置文件,但是在寫入配置文件時,需要獨佔訪問。這樣可以提高系統的併發性能,同時保證數據的一致性。

3.9 大讀者鎖

優化了讀取密集的場景,允許多個讀取者同時訪問,但只有一個寫入者能獲得鎖。

大讀者鎖在讀取密集的場景下表現出色,例如在一個 Web 服務器中,大量的客戶端同時請求讀取網頁內容,但是在更新網頁內容時,需要獨佔訪問。這樣可以提高系統的併發性能,同時保證數據的一致性。

3.10RCU(Read-Copy Update)

一種高效的無鎖同步機制,通過延遲對象的刪除來實現併發讀取。

RCU 在一些對性能要求極高的場景中非常有用,例如在 Linux 內核中,RCU 被廣泛應用於網絡協議棧、文件系統等模塊中。它允許多個線程同時讀取共享數據,而在寫入數據時,通過複製數據的方式來實現,避免了使用傳統的鎖機制帶來的性能開銷。

3.11 順序鎖

輕量級的同步機制,用於保護數據結構免受破壞性的競爭條件影響。

順序鎖在一些需要輕量級同步機制的場景中非常有用,例如在一些小型的嵌入式系統中,資源有限,需要一種簡單高效的同步機制來保護數據結構。順序鎖通過維護一個順序計數器來實現,讀取數據時,先讀取順序計數器的值,然後讀取數據,最後再次讀取順序計數器的值,如果兩次讀取的值相同,則說明在讀取過程中沒有被寫入操作修改,可以安全地使用讀取到的數據。

3.12 完成量(completion)

一個線程告訴另一個線程工作已完成,類似 Linux 應用的信號量,有靜態宏創建和動態創建兩種方式。

完成量在多線程編程中非常有用,例如在一個多線程的任務處理程序中,一個線程負責生成任務,另一個線程負責處理任務。當生成任務的線程完成任務的生成後,可以使用完成量來通知處理任務的線程開始處理任務。完成量可以通過靜態宏創建或動態創建兩種方式來創建,具體使用哪種方式取決於應用程序的需求。

四、同步機制的選擇

不同的同步機制適用於不同的併發場景和性能需求。例如,自旋鎖適用於短時間持有鎖的場景;信號量適用於鎖會被長時間持有的情況;讀寫鎖在讀取操作遠多於寫入操作的場景中特別有效。

自旋鎖在需要快速獲取鎖且持有時間很短的情況下表現出色。比如在中斷處理程序中,爲了快速響應關鍵事件,自旋鎖可以確保在短時間內獲取到所需資源,避免因線程睡眠和喚醒帶來的開銷。但如果臨界區執行時間較長,自旋鎖會導致其他線程長時間忙等待,浪費 CPU 資源,此時就不適合使用自旋鎖。

信號量則適用於鎖會被長時間持有的情況。當一個任務想要獲得已被佔用的信號量時,信號量會將任務推進一個等待隊列,然後讓這個任務睡眠。當該信號量可用後,處於等待隊列的任務將被喚醒,並獲得該信號量。例如在一些耗時較長的任務中,如坐火車從南京到新疆需要 2 天時間,這種情況下使用信號量讓任務進入睡眠狀態是比較合適的,避免了一直佔用 CPU 資源進行忙等待。

讀寫鎖在讀取操作遠多於寫入操作的場景中非常有用。讀寫鎖允許任意數量的讀取者共享資源,但只允許一個寫入者獨佔資源。比如在一個數據庫系統中,多個客戶端可以同時讀取數據,但是在寫入數據時,需要獨佔訪問。這樣可以提高系統的併發性能,同時保證數據的一致性。

不同的同步機制各有其特點和適用場景,在實際應用中,需要根據具體的併發情況和性能要求來選擇合適的同步機制,以提高系統的性能和穩定性。

五、全文總結

Linux 內核的同步機制在確保系統的正確性和性能方面起着至關重要的作用。不同的同步機制具有各自的特點和適用場景,開發人員需要根據實際情況進行選擇。

如自旋鎖適用於保護短時間的臨界區,在中斷處理等場景中能快速響應,但不可重入且在長時間持有鎖時會浪費 CPU 資源。信號量則適用於鎖會被長時間持有的情況,能讓任務進入睡眠狀態,避免佔用 CPU 資源。讀寫信號量、讀寫鎖和大讀者鎖在以讀爲主的場景中能提高併發性能,同時保證數據的一致性。互斥鎖通過底層的硬件支持和內核調度實現互斥訪問。原子操作用於保護共享數據,在資源計數等方面廣泛應用。等待隊列與進程調度機制緊密結合,可實現異步事件通知。大內核鎖雖在早期有重要作用,但已被更細粒度的鎖機制取代。RCU 是高效的無鎖同步機制,適用於對性能要求極高的場景。順序鎖是輕量級的同步機制,用於保護數據結構。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/pZC-EFxrAPopme5AWPpbhg