Linux 內核同步機制:解鎖併發編程的奧祕
在當今的數字時代,多核處理器早已成爲計算機系統的標配,從我們日常辦公的電腦,到數據中心裏龐大的服務器集羣,它們無處不在。這一硬件層面的發展,使得計算機系統能夠同時處理多個任務,極大地提升了計算效率。就如同繁忙的交通樞紐,多車道並行,車輛往來穿梭,看似混亂卻又有序運
然而,在 Linux 內核這個 “交通指揮中心” 裏,當多個進程或線程如同川流不息的車輛,試圖同時訪問共享資源時,問題就出現了。想象一下,兩條道路上的車輛都想同時通過一個狹窄的路口,如果沒有合理的交通規則,必然會導致擁堵甚至碰撞。在 Linux 內核中,這些共享資源就如同這個狹窄路口,而進程和線程的併發訪問如果缺乏有效的管理,就會引發數據不一致、程序崩潰等嚴重問題。這不僅會影響系統的穩定性,還可能導致關鍵業務的中斷,造成不可估量的損失。
那麼,Linux 內核是如何在複雜的併發環境中,確保共享資源的安全訪問,維持系統的高效穩定運行的呢?答案就在於其精心設計的同步機制。它就像一套精密的交通指揮系統,通過各種規則和信號,引導着進程和線程這些 “車輛” 有序地通過共享資源這個 “路口”。接下來,就讓我們一同深入 Linux 內核同步機制的世界,探尋其中的奧祕,解鎖併發編程的關鍵技巧,爲構建更穩定、高效的系統奠定堅實基礎。
常用的 Linux 內核同步機制有原子操作、Per-CPU 變量、內存屏障、自旋鎖、Mutex 鎖、信號量和 RCU 等,後面幾種鎖實現會依賴於前三種基礎同步機制。在正式開始分析具體的內核同步機制實現之前,需要先澄清一些基本概念。
一、基本概念
1.1 同步機制
既然是同步機制,那就首先要搞明白什麼是同步。同步是指用於實現控制多個執行路徑按照一定的規則或順序訪問某些系統資源的機制。所謂執行路徑,就是在 CPU 上運行的代碼流。我們知道,CPU 調度的最小單位是線程,可以是用戶態線程,也可以是內核線程,甚至是中斷服務程序。所以,執行路徑在這裏就包括用戶態線程、內核線程和中斷服務程序。執行路徑、執行單元、控制路徑等等,叫法不同,但本質都一樣。那爲什麼需要同步機制呢?請繼續往下看。
1.2 併發與競態
併發是指兩個以上的執行路徑同時被執行,而併發的執行路徑對共享資源(硬件資源和軟件上的全局變量等)的訪問則很容易導致競態。例如,現在系統有一個 LED 燈可以由 APP 控制,APP1 控制燈亮一秒滅一秒,APP2 控制燈亮 500ms 滅 1500ms。如果 APP1 和 APP2 分別在 CPU1 和 CPU2 上併發運行,LED 燈的行爲會是什麼樣的呢?很有可能 LED 燈的亮滅節奏都不會如這兩個 APP 所願,APP1 在關掉 LED 燈時,很有可能恰逢 APP2 正要打開 LED 燈。很明顯,APP1 和 APP2 對 LED 燈這個資源產生了競爭關係。競態是危險的,如果不加以約束,輕則只是程序運行結果不符合預期,重則系統崩潰。
在操作系統中,更復雜、更混亂的併發大量存在,而同步機制正是爲了解決併發和競態問題。同步機制通過保護臨界區(訪問共享資源的代碼區域)達到對共享資源互斥訪問的目的,所謂互斥訪問,是指一個執行路徑在訪問共享資源時,另一個執行路徑被禁止去訪問。關於併發與競態,有個生活例子很貼切。假如你和你的同事張小三都要上廁所,但是公司只有一個洗手間而且也只有一個坑。當張小三進入廁所關起門的那一刻起,你就無法進去了,只能在門外侯着。
當小三哥出來後你才能進去解決你的問題。這裏,公司廁所就是共享資源,你和張小三同時需要這個共享資源就是併發,你們對廁所的使用需求就構成了競態,而廁所的門就是一種同步機制,他在用你就不能用了
總結如下圖:
1.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。不管是用戶態搶佔還是內核態搶佔,並不是什麼代碼位置都能發生,而是有搶佔時機的,也就是所謂的搶佔點。搶佔時機如下:
用戶態搶佔
-
1、從系統調用返回用戶空間時;
-
2、從中斷(異常)處理程序返回用戶空間時。
內核態搶佔:
-
1、當一箇中斷處理程序退出,返回到內核態時;
-
2、task 顯式調用 schedule();
-
3、task 發生阻塞(此時由調度器完成調度)。
1.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);
}
二、同步機制的起源
在深入探討 Linux 內核同步機制之前,我們先來理解一下併發(Concurrency)與競態(Race Condition)的概念,因爲它們是同步機制存在的根本原因。
2.2 併發的多種形式
併發,簡單來說,就是指多個執行單元同時、並行地被執行 。在 Linux 系統中,併發主要有以下幾種場景:
SMP 多 CPU:對稱多處理器(SMP)是一種緊耦合、共享存儲的系統模型,多個 CPU 使用共同的系統總線,可以訪問共同的外設和存儲器 。在這種情況下,兩個 CPU 之間的進程、中斷都有併發的可能性。例如,CPU0 上的進程 A 和 CPU1 上的進程 B 可能同時訪問共享內存中的同一數據。
單 CPU 內進程與搶佔進程:在單個 CPU 中,雖然同一時刻只能有一個進程在運行,但進程的執行可能會被打斷。比如,一個進程在執行過程中,可能會因爲時間片耗盡,或者被另一個高優先級的進程搶佔。當高優先級進程與被打斷的進程共同訪問共享資源時,就可能產生競態。比如進程 A 正在訪問一個全局變量,還沒來得及修改完,就被進程 B 搶佔,進程 B 也對這個全局變量進行訪問和修改,就可能導致數據混亂。
中斷與進程:中斷可以打斷正在執行的進程 。如果中斷服務程序也訪問進程正在訪問的共享資源,就很容易產生競態。比如,進程正在向串口發送數據,這時一箇中斷髮生,中斷服務程序也嘗試向串口發送數據,就會導致串口數據發送錯誤。
2.2 競態帶來的問題
當多個併發執行單元訪問共享資源時,競態就可能出現。競態會導致程序出現不可預測的行爲,比如數據不一致、程序崩潰等 。我們來看一個簡單的例子,假設有兩個進程 P1 和 P2,它們都要對一個共享變量 count 進行加 1 操作。代碼可能如下:
// 共享變量
int count = 0;
// 進程P1的操作
void process1() {
int temp = count; // 讀取count的值
temp = temp + 1; // 對temp加1
count = temp; // 將temp的值寫回count
}
// 進程P2的操作
void process2() {
int temp = count; // 讀取count的值
temp = temp + 1; // 對temp加1
count = temp; // 將temp的值寫回count
}
如果這兩個進程併發執行,正常情況下,count 最終的值應該是 2。但由於競態的存在,可能會出現以下情況:
-
進程 P1 讀取 count 的值,此時 temp 爲 0。
-
進程 P2 讀取 count 的值,此時 temp 也爲 0,因爲 P1 還沒有將修改後的值寫回 count。
-
進程 P1 對 temp 加 1,然後將 temp 的值寫回 count,此時 count 爲 1。
-
進程 P2 對 temp 加 1(此時 temp 還是 0),然後將 temp 的值寫回 count,此時 count 還是 1,而不是 2。
這就是競態導致的數據錯誤。在實際的 Linux 內核中,共享資源可能是硬件設備、全局變量、文件系統等,競態帶來的問題會更加複雜和嚴重,可能導致系統不穩定、數據丟失等問題 。因此,爲了保證系統的正確性和穩定性,Linux 內核需要一套有效的同步機制來解決競態問題。
三、常見同步機制解析
爲了解決併發與競態問題,Linux 內核提供了多種同步機制 ,每種機制都有其獨特的工作原理和適用場景。下面我們來詳細瞭解一下這些同步機制。
3.1 自旋鎖(Spinlocks)
自旋鎖是一種比較簡單的同步機制 。當一個線程嘗試獲取自旋鎖時,如果鎖已經被其他線程持有,那麼該線程不會進入阻塞狀態,而是在原地不斷地循環檢查鎖是否可用,這個過程就叫做 “自旋” 。就好像你去餐廳喫飯,發現你喜歡的那桌還被別人佔着,你又特別想坐那桌,於是你就站在旁邊一直盯着,等那桌人喫完離開,你馬上就能坐過去,這個一直盯着等待的過程就類似自旋。
自旋鎖適用於鎖持有時間非常短的場景 ,因爲它避免了線程上下文切換的開銷。在多處理器系統中,當一個線程在自旋等待鎖時,其他處理器核心可以繼續執行其他任務,不會因爲線程阻塞而導致 CPU 資源浪費 。比如在一些對共享硬件資源的短時間訪問場景中,自旋鎖就非常適用。假設多個線程需要訪問共享的網卡設備寄存器,對寄存器的操作通常非常快,使用自旋鎖可以讓線程快速獲取鎖並完成操作,避免了線程上下文切換帶來的開銷。
自旋鎖也有其侷限性。如果鎖持有時間較長,線程會一直自旋,不斷消耗 CPU 資源,導致系統性能下降 。所以在使用自旋鎖時,需要根據實際情況謹慎選擇。
自旋鎖的 API 有:
-
spin_lock_init(x) 該宏用於初始化自旋鎖 x。自旋鎖在真正使用前必須先初始化。該宏用於動態初始化。
-
DEFINE_SPINLOCK(x) 該宏聲明一個自旋鎖 x 並初始化它。該宏在 2.6.11 中第一次被定義,在先前的內核中並沒有該宏。
-
SPIN_LOCK_UNLOCKED 該宏用於靜態初始化一個自旋鎖。
-
DEFINE_SPINLOCK(x) 等同於 spinlock_t x = SPIN_LOCK_UNLOCKEDspin_is_locked(x) 該宏用於判斷自旋鎖 x 是否已經被某執行單元保持(即被鎖),如果是,返回真,否則返回假。
-
spin_unlock_wait(x) 該宏用於等待自旋鎖 x 變得沒有被任何執行單元保持,如果沒有任何執行單元保持該自旋鎖,該宏立即返回,否則將循環在那裏,直到該自旋鎖被保持者釋放。
-
spin_trylock(lock) 該宏盡力獲得自旋鎖 lock,如果能立即獲得鎖,它獲得鎖並返回真,否則不能立即獲得鎖,立即返回假。它不會自旋等待 lock 被釋放。
-
spin_lock(lock) 該宏用於獲得自旋鎖 lock,如果能夠立即獲得鎖,它就馬上返回,否則,它將自旋在那裏,直到該自旋鎖的保持者釋放,這時,它獲得鎖並返回。總之,只有它獲得鎖才返回。
-
spin_lock_irqsave(lock, flags) 該宏獲得自旋鎖的同時把標誌寄存器的值保存到變量 flags 中並失效本地中斷。
-
spin_lock_irq(lock) 該宏類似於 spin_lock_irqsave,只是該宏不保存標誌寄存器的值。
-
spin_lock_bh(lock) 該宏在得到自旋鎖的同時失效本地軟中斷。
-
spin_unlock(lock) 該宏釋放自旋鎖 lock,它與 spin_trylock 或 spin_lock 配對使用。如果 spin_trylock 返回假,表明沒有獲得自旋鎖,因此不必使用 spin_unlock 釋放。
-
spin_unlock_irqrestore(lock, flags) 該宏釋放自旋鎖 lock 的同時,也恢復標誌寄存器的值爲變量 flags 保存的值。它與 spin_lock_irqsave 配對使用。
-
spin_unlock_irq(lock) 該宏釋放自旋鎖 lock 的同時,也使能本地中斷。它與 spin_lock_irq 配對應用。
-
spin_unlock(lock) 該宏釋放自旋鎖 lock,它與 spin_trylock 或 spin_lock 配對使用。如果 spin_trylock 返回假,表明沒有獲得自旋鎖,因此不必使用 spin_unlock 釋放。
-
spin_unlock_irqrestore(lock, flags) 該宏釋放自旋鎖 lock 的同時,也恢復標誌寄存器的值爲變量 flags 保存的值。它與 spin_lock_irqsave 配對使用。
-
spin_unlock_irq(lock) 該宏釋放自旋鎖 lock 的同時,也使能本地中斷。它與 spin_lock_irq 配對應用。
-
spin_unlock_bh(lock) 該宏釋放自旋鎖 lock 的同時,也使能本地的軟中斷。它與 spin_lock_bh 配對使用。
-
spin_trylock_irqsave(lock, flags) 該宏如果獲得自旋鎖 lock,它也將保存標誌寄存器的值到變量 flags 中,並且失效本地中斷,如果沒有獲得鎖,它什麼也不做。因此如果能夠立即獲得鎖,它等同於 spin_lock_irqsave,如果不能獲得鎖,它等同於 spin_trylock。如果該宏獲得自旋鎖 lock,那需要使用 spin_unlock_irqrestore 來釋放。
-
spin_unlock_bh(lock) 該宏釋放自旋鎖 lock 的同時,也使能本地的軟中斷。它與 spin_lock_bh 配對使用。
-
spin_trylock_irqsave(lock, flags) 該宏如果獲得自旋鎖 lock,它也將保存標誌寄存器的值到變量 flags 中,並且失效本地中斷,如果沒有獲得鎖,它什麼也不做。因此如果能夠立即獲得鎖,它等同於 spin_lock_irqsave,如果不能獲得鎖,它等同於 spin_trylock。如果該宏獲得自旋鎖 lock,那需要使用 spin_unlock_irqrestore 來釋放。
-
spin_can_lock(lock) 該宏用於判斷自旋鎖 lock 是否能夠被鎖,它實際是 spin_is_locked 取反。如果 lock 沒有被鎖,它返回真,否則,返回假。該宏在 2.6.11 中第一次被定義,在先前的內核中並沒有該宏。
獲得自旋鎖和釋放自旋鎖有好幾個版本,因此讓讀者知道在什麼樣的情況下使用什麼版本的獲得和釋放鎖的宏是非常必要的。
如果被保護的共享資源只在進程上下文訪問和軟中斷上下文訪問,那麼當在進程上下文訪問共享資源時,可能被軟中斷打斷,從而可能進入軟中斷上下文來對被保護的共享資源訪問,因此對於這種情況,對共享資源的訪問必須使用 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 互斥鎖(Mutexes)
互斥鎖,也叫互斥量 ,是一種用於實現線程間互斥訪問的同步機制 。它的工作原理是,當一個線程獲取到互斥鎖後,其他線程如果也嘗試獲取該鎖,就會被阻塞,直到持有鎖的線程釋放鎖 。這就好比一個公共衛生間,一次只能允許一個人使用,當有人進入衛生間並鎖上門後,其他人就只能在外面排隊等待,直到裏面的人出來打開門,外面的人才有機會進去使用。
與自旋鎖不同,互斥鎖適用於那些可能會阻塞很長時間的場景 。當線程獲取不到鎖時,它會被操作系統掛起,讓出 CPU 資源,不會像自旋鎖那樣一直佔用 CPU 進行無效的等待 。在涉及大量計算或者 IO 操作的代碼段中,使用互斥鎖可以避免 CPU 資源的浪費。比如在數據庫操作中,一個線程需要長時間佔用數據庫連接執行復雜的查詢或者事務操作,這時使用互斥鎖來保護數據庫連接資源,其他線程在獲取不到鎖時會被阻塞,直到當前線程完成數據庫操作並釋放鎖,這樣可以有效地管理資源,提高系統的整體性能。
3.3 讀寫鎖(Read-Write Locks)
讀寫鎖是一種特殊的同步機制,它允許多個線程同時進行讀操作,但只允許一個線程進行寫操作 。當有線程正在進行寫操作時,其他線程無論是讀操作還是寫操作都將被阻塞,直到寫操作完成並釋放鎖 。這就像圖書館的一本熱門書籍,很多人可以同時閱讀這本書,但如果有人要對這本書進行修改(比如添加批註或者修正錯誤),就必須先獨佔這本書,其他人在修改期間不能閱讀也不能修改,直到修改完成。
讀寫鎖的優勢在於它能顯著提高併發性能,特別是在讀取頻繁而寫入較少的場景中 。在一個在線商城系統中,商品信息的展示(讀操作)非常頻繁,而商品信息的更新(寫操作)相對較少。使用讀寫鎖,多個用戶可以同時讀取商品信息,而當商家需要更新商品信息時,只需要獲取寫鎖,保證寫操作的原子性和數據一致性,這樣可以大大提高系統的併發處理能力,提升用戶體驗。
讀寫信號量的相關 API 有:
-
DECLARE_RWSEM(name) 該宏聲明一個讀寫信號量 name 並對其進行初始化。
-
void init_rwsem(struct rw_semaphore *sem); 該函數對讀寫信號量 sem 進行初始化。
-
void down_read(struct rw_semaphore *sem); 讀者調用該函數來得到讀寫信號量 sem。該函數會導致調用者睡眠,因此只能在進程上下文使用。
-
int down_read_trylock(struct rw_semaphore *sem); 該函數類似於 down_read,只是它不會導致調用者睡眠。它盡力得到讀寫信號量 sem,如果能夠立即得到,它就得到該讀寫信號量,並且返回 1,否則表示不能立刻得到該信號量,返回 0。因此,它也可以在中斷上下文使用。
-
void down_write(struct rw_semaphore *sem); 寫者使用該函數來得到讀寫信號量 sem,它也會導致調用者睡眠,因此只能在進程上下文使用。
-
int down_write_trylock(struct rw_semaphore *sem); 該函數類似於 down_write,只是它不會導致調用者睡眠。該函數盡力得到讀寫信號量,如果能夠立刻獲得,就獲得該讀寫信號量並且返回 1,否則表示無法立刻獲得,返回 0。它可以在中斷上下文使用。
-
void up_read(struct rw_semaphore *sem); 讀者使用該函數釋放讀寫信號量 sem。它與 down_read 或 down_read_trylock 配對使用。如果 down_read_trylock 返回 0,不需要調用 up_read 來釋放讀寫信號量,因爲根本就沒有獲得信號量。
-
void up_write(struct rw_semaphore *sem); 寫者調用該函數釋放信號量 sem。它與 down_write 或 down_write_trylock 配對使用。如果 down_write_trylock 返回 0,不需要調用 up_write,因爲返回 0 表示沒有獲得該讀寫信號量。
-
void downgrade_write(struct rw_semaphore *sem); 該函數用於把寫者降級爲讀者,這有時是必要的。因爲寫者是排他性的,因此在寫者保持讀寫信號量期間,任何讀者或寫者都將無法訪問該讀寫信號量保護的共享資源,對於那些當前條件下不需要寫訪問的寫者,降級爲讀者將,使得等待訪問的讀者能夠立刻訪問,從而增加了併發性,提高了效率。對於那些當前條件下不需要寫訪問的寫者,降級爲讀者將,使得等待訪問的讀者能夠立刻訪問,從而增加了併發性,提高了效率。讀寫信號量適於在讀多寫少的情況下使用,在 linux 內核中對進程的內存映像描述結構的訪問就使用了讀寫信號量進行保護。
在 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 信號量(Semaphores)
信號量是一個整數值,它可以用來控制對共享資源的訪問 。信號量主要有兩個作用:一是實現互斥,二是控制併發訪問的數量 。信號量內部維護一個計數器,當線程請求訪問共享資源時,會嘗試獲取信號量,如果計數器大於 0,則線程可以獲取信號量並繼續執行,同時計數器減一;如果計數器爲 0,則線程會被阻塞,直到有其他線程釋放信號量,使得計數器增加 。這就像一個停車場,停車場有一定數量的停車位(信號量的初始值),每輛車進入停車場(線程請求資源)時,會佔用一個停車位,停車位數量減一,如果停車位滿了(計數器爲 0),新的車輛就只能在外面等待,直到有車輛離開停車場(線程釋放資源),停車位數量增加,等待的車輛纔有機會進入。
在限制線程訪問文件資源數量的場景中,信號量就非常有用 。假設一個系統中,同時只允許 5 個線程對某個文件進行讀寫操作,我們可以創建一個初始值爲 5 的信號量 。每個線程在訪問文件前,先獲取信號量,如果獲取成功則可以訪問文件,同時信號量的計數器減一;當線程完成文件訪問後,釋放信號量,計數器加一。這樣就可以有效地控制同時訪問文件的線程數量,避免資源的過度競爭和衝突 。
信號量的 API 有:
-
DECLARE_MUTEX(name) 該宏聲明一個信號量 name 並初始化它的值爲 0,即聲明一個互斥鎖。
-
DECLARE_MUTEX_LOCKED(name) 該宏聲明一個互斥鎖 name,但把它的初始值設置爲 0,即鎖在創建時就處在已鎖狀態。因此對於這種鎖,一般是先釋放後獲得。
-
void sema_init (struct semaphore *sem, int val); 該函用於數初始化設置信號量的初值,它設置信號量 sem 的值爲 val。
-
void init_MUTEX (struct semaphore *sem); 該函數用於初始化一個互斥鎖,即它把信號量 sem 的值設置爲 1。
-
void init_MUTEX_LOCKED (struct semaphore *sem); 該函數也用於初始化一個互斥鎖,但它把信號量 sem 的值設置爲 0,即一開始就處在已鎖狀態。
-
void down(struct semaphore * sem); 該函數用於獲得信號量 sem,它會導致睡眠,因此不能在中斷上下文(包括 IRQ 上下文和 softirq 上下文)使用該函數。該函數將把 sem 的值減 1,如果信號量 sem 的值非負,就直接返回,否則調用者將被掛起,直到別的任務釋放該信號量才能繼續運行。
-
int down_interruptible(struct semaphore * sem); 該函數功能與 down 類似,不同之處爲,down 不會被信號(signal)打斷,但 down_interruptible 能被信號打斷,因此該函數有返回值來區分是正常返回還是被信號中斷,如果返回 0,表示獲得信號量正常返回,如果被信號打斷,返回 - EINTR。
-
int down_trylock(struct semaphore * sem); 該函數試着獲得信號量 sem,如果能夠立刻獲得,它就獲得該信號量並返回 0,否則,表示不能獲得信號量 sem,返回值爲非 0 值。因此,它不會導致調用者睡眠,可以在中斷上下文使用。
-
void up(struct semaphore * sem); 該函數釋放信號量 sem,即把 sem 的值加 1,如果 sem 的值爲非正數,表明有任務等待該信號量,因此喚醒這些等待者。
信號量在絕大部分情況下作爲互斥鎖使用,下面以 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.5 原子操作(Atomic Operations)
原子操作是指那些不可被中斷的操作 ,即它們的執行是一個完整的、不可分割的單元,不會被其他任務或事件打斷 。在多線程編程中,原子操作可以保證對共享資源的訪問是線程安全的,避免了競態條件的發生 。例如,在實現資源計數和引用計數方面,原子操作就發揮着重要作用 。
假設有一個共享資源,多個線程可能會對其引用計數進行增加或減少操作,如果這些操作不是原子的,就可能會出現競態條件,導致引用計數錯誤。而使用原子操作,就可以確保每次對引用計數的修改都是原子的,不會受到其他線程的干擾,從而保證了資源計數的準確性和一致性 。在 C 語言中,可以使用 atomic 庫來實現原子操作 ,比如 atomic_fetch_add 函數可以原子地對一個變量進行加法操作 。原子類型定義如下:
typedef struct {
volatile int counter;
} atomic_t;
volatile 修飾字段告訴 gcc 不要對該類型的數據做優化處理,對它的訪問都是對內存的訪問,而不是對寄存器的訪問。原子操作 API 包括:
-
tomic_read(atomic_t * v); 該函數對原子類型的變量進行原子讀操作,它返回原子類型的變量 v 的值。
-
atomic_set(atomic_t * v, int i); 該函數設置原子類型的變量 v 的值爲 i。
-
void atomic_add(int i, atomic_t *v); 該函數給原子類型的變量 v 增加值 i。
-
atomic_sub(int i, atomic_t *v); 該函數從原子類型的變量 v 中減去 i。
-
int atomic_sub_and_test(int i, atomic_t *v); 該函數從原子類型的變量 v 中減去 i,並判斷結果是否爲 0,如果爲 0,返回真,否則返回假。
-
void atomic_inc(atomic_t *v); 該函數對原子類型變量 v 原子地增加 1。
-
void atomic_dec(atomic_t *v); 該函數對原子類型的變量 v 原子地減 1。
-
int atomic_dec_and_test(atomic_t *v); 該函數對原子類型的變量 v 原子地減 1,並判斷結果是否爲 0,如果爲 0,返回真,否則返回假。
-
int atomic_inc_and_test(atomic_t *v); 該函數對原子類型的變量 v 原子地增加 1,並判斷結果是否爲 0,如果爲 0,返回真,否則返回假。
-
int atomic_add_negative(int i, atomic_t *v); 該函數對原子類型的變量 v 原子地增加 I,並判斷結果是否爲負數,如果是,返回真,否則返回假。
-
int atomic_add_return(int i, atomic_t *v); 該函數對原子類型的變量 v 原子地增加 i,並且返回指向 v 的指針。
-
int atomic_sub_return(int i, atomic_t *v); 該函數從原子類型的變量 v 中減去 i,並且返回指向 v 的指針。
-
int atomic_inc_return(atomic_t * v); 該函數對原子類型的變量 v 原子地增加 1 並且返回指向 v 的指針。
-
int atomic_dec_return(atomic_t * v); 該函數對原子類型的變量 v 原子地減 1 並且返回指向 v 的指針。
原子操作通常用於實現資源的引用計數,在 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 實現)。
四、同步機制的選擇與應用場景
在 Linux 內核的實際應用中,選擇合適的同步機制至關重要,這就如同在不同的路況下選擇合適的交通工具一樣 。不同的同步機制適用於不同的場景,我們需要根據具體的需求和條件來做出決策。
自旋鎖由於其自旋等待的特性,適合用於臨界區執行時間非常短且競爭不激烈的場景 。在多核處理器系統中,當線程對共享資源的訪問時間極短,如對一些硬件寄存器的快速讀寫操作,使用自旋鎖可以避免線程上下文切換的開銷,提高系統的響應速度 。因爲線程在自旋等待時,雖然會佔用 CPU 資源,但由於臨界區執行時間短,很快就能獲取鎖並完成操作,相比於線程上下文切換的開銷,這種自旋等待的成本是可以接受的。如果臨界區執行時間較長,線程長時間自旋會浪費大量的 CPU 資源,導致系統性能下降,所以自旋鎖不適合長時間持有鎖的場景 。
互斥鎖則適用於臨界區可能會阻塞很長時間的場景 。當涉及到大量的計算、IO 操作或者需要等待外部資源時,使用互斥鎖可以讓線程在獲取不到鎖時進入阻塞狀態,讓出 CPU 資源給其他線程,避免 CPU 資源的浪費 。在一個網絡服務器中,當線程需要從網絡中讀取大量數據或者向數據庫寫入數據時,這些操作通常會花費較長的時間,此時使用互斥鎖來保護相關的資源,能夠有效地管理線程的執行順序,保證系統的穩定性 。因爲在這種情況下,線程上下文切換的開銷相對較小,而讓線程阻塞等待可以避免 CPU 資源被無效佔用,提高系統的整體效率 。
讀寫鎖適用於讀取頻繁而寫入較少的場景 。在一個實時監控系統中,大量的線程可能需要頻繁讀取監控數據,但只有少數線程會偶爾更新這些數據 。使用讀寫鎖,多個讀線程可以同時獲取讀鎖,併發地讀取數據,而寫線程在需要更新數據時,獲取寫鎖,獨佔資源進行寫入操作,這樣可以大大提高系統的併發性能 。因爲讀操作不會修改數據,所以多個讀線程同時進行讀操作不會產生數據衝突,而寫操作則需要保證原子性和數據一致性,讀寫鎖正好滿足了這種需求 。
信號量則常用於控制對共享資源的訪問數量 。在一個文件服務器中,爲了避免過多的線程同時訪問同一個文件導致文件系統負載過高,我們可以使用信號量來限制同時訪問文件的線程數量 。通過設置信號量的初始值爲允許同時訪問的最大線程數,每個線程在訪問文件前先獲取信號量,訪問完成後釋放信號量,這樣就可以有效地控制對文件資源的訪問,保證系統的穩定性 。因爲信號量的計數器可以精確地控制併發訪問的數量,避免資源的過度競爭和衝突 。
五、實際案例分析
5.1TCP 連接管理
在 Linux 內核的網絡協議棧中,同步機制起着關鍵的作用 。以 TCP 協議的連接管理爲例,當多個線程同時處理 TCP 連接的建立、斷開和數據傳輸時,就需要使用同步機制來保證數據的一致性和操作的正確性 。在處理 TCP 連接請求時,可能會有多個線程同時接收到連接請求,這時候就需要使用自旋鎖來快速地對共享的連接隊列進行操作,確保每個連接請求都能被正確處理,避免出現重複處理或者數據混亂的情況 。
由於連接請求的處理通常非常快,使用自旋鎖可以避免線程上下文切換的開銷,提高系統的性能 。而在進行 TCP 數據傳輸時,由於數據傳輸可能會受到網絡延遲等因素的影響,需要較長的時間,這時候就會使用互斥鎖來保護數據緩衝區等共享資源,確保數據的正確讀寫 。因爲在數據傳輸過程中,線程可能需要等待網絡響應,使用互斥鎖可以讓線程在等待時進入阻塞狀態,讓出 CPU 資源,提高系統的整體效率 。
我們將創建一個簡單的 TCP 連接請求處理程序,使用自旋鎖保護共享的連接隊列,代碼實現示例:
#include <linux/spinlock.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#define MAX_CONNECTIONS 10
struct connection {
int conn_id;
};
struct connection connection_queue[MAX_CONNECTIONS];
int queue_count = 0;
spinlock_t conn_lock;
void handle_connection_request(int conn_id) {
spin_lock(&conn_lock);
if (queue_count < MAX_CONNECTIONS) {
connection_queue[queue_count].conn_id = conn_id;
queue_count++;
printk(KERN_INFO "Handled connection request: %d\n", conn_id);
} else {
printk(KERN_WARNING "Connection queue is full!\n");
}
spin_unlock(&conn_lock);
}
static int __init my_module_init(void) {
spin_lock_init(&conn_lock);
return 0;
}
static void __exit my_module_exit(void) {
// Cleanup code here
}
module_init(my_module_init);
module_exit(my_module_exit);
MODULE_LICENSE("GPL");
-
使用 spinlock_t 類型的自旋鎖來保護對共享資源(連接隊列)的訪問。
-
handle_connection_request 函數模擬處理 TCP 連接請求。它在修改共享隊列之前獲取自旋鎖,並在完成後釋放。
5.2 文件讀寫操作
在文件系統中,同步機制也不可或缺 。以文件的讀寫操作爲例,當多個進程同時對一個文件進行讀寫時,就需要使用合適的同步機制 。對於文件的讀取操作,由於讀取操作不會修改文件內容,多個進程可以同時進行讀取,這時候可以使用讀寫鎖的讀鎖來提高併發性能 。而當有進程需要對文件進行寫入操作時,爲了保證數據的一致性,就需要獲取讀寫鎖的寫鎖,獨佔文件進行寫入 。在文件系統的元數據管理中,如文件的創建、刪除和目錄的遍歷等操作,由於這些操作涉及到對文件系統關鍵數據結構的修改,需要保證原子性和一致性,通常會使用互斥鎖來保護相關的操作 。因爲這些操作可能會涉及到複雜的文件系統操作和磁盤 IO,使用互斥鎖可以有效地管理線程的執行順序,避免出現數據不一致的情況 。
接下來是一個簡化版的文件讀寫操作示例,使用互斥鎖和讀寫鎖來確保線程安全,代碼實現示例:
#include <linux/fs.h>
#include <linux/mutex.h>
#include <linux/rwsem.h>
#include <linux/uaccess.h>
struct rw_semaphore file_rwsem;
char file_buffer[1024];
void read_file(char *buffer, size_t size) {
down_read(&file_rwsem); // 獲取讀鎖
memcpy(buffer, file_buffer, size);
up_read(&file_rwsem); // 釋放讀鎖
}
void write_file(const char *buffer, size_t size) {
down_write(&file_rwsem); // 獲取寫鎖
memcpy(file_buffer, buffer, size);
up_write(&file_rwsem); // 釋放寫鎖
}
static int __init my_file_module_init(void) {
init_rwsem(&file_rwsem);
return 0;
}
static void __exit my_file_module_exit(void) {
// Cleanup code here
}
module_init(my_file_module_init);
module_exit(my_file_module_exit);
MODULE_LICENSE("GPL");
-
使用 rw_semaphore 類型的讀寫鎖來控制對文件緩衝區的併發訪問。
-
在讀取時,通過調用 down_read 獲取讀鎖,以允許多個線程同時讀取而不阻塞;在寫入時,通過調用 down_write 獲取獨佔寫鎖,以保證數據一致性。
通過這以上兩個簡單示例,可以看到在 Linux 內核中如何應用不同的同步機制來管理資源競爭,以提高性能和數據一致性。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/osNFWmhcY5oq_lyt-4-p9Q