Linux 自旋鎖:探祕內核同步利器
在 Linux 操作系統那複雜而精妙的內核世界裏,自旋鎖宛如一顆獨特而關鍵的 “螺絲釘”,雖看似微小卻有着不可忽視的力量。它緊密地與多任務處理、併發控制以及資源共享等核心機制相互交織,深刻地影響着系統的性能、穩定性與可靠性。
當我們開啓探索 Linux 自旋鎖之旅時,就彷彿踏入了一個神祕而充滿挑戰的技術領域,在這裏,我們將逐步揭開自旋鎖的神祕面紗,洞察它在 Linux 內核運作中所扮演的微妙角色,以及它是如何在多線程、多進程的繁忙交互中巧妙地維持秩序,保障系統有條不紊地運行。讓我們一同深入其中,領略 Linux 自旋鎖的深邃魅力與強大功能吧。
01
自旋鎖簡介
自旋鎖是爲了在多處理系統(SMP)設計,實現在多處理器情況下保護臨界區。在 Linux 內核中,自旋鎖通常用於包含內核數據結構的操作,保證操作的原子性。
自旋鎖最初是爲了在多處理系統(SMP)設計,實現在多處理器情況下保護臨界區。自旋鎖的實現是爲了保護一段短小的臨界區代碼,保證這個臨界區的操作是原子的。在 Linux 內核中,自旋鎖通常用於包含內核數據結構的操作 (如 wait_queue 等),用於保證操作內核中的數據結構的原子性。如果內核控制路徑發現自旋鎖可用,則 “鎖定” 被設置,而代碼繼續進入臨界區。相反,如果內核控制路徑發現鎖運行在另一個 CPU 的內核控制路徑 “鎖定”,就原地 “旋轉” 並重複檢查這個鎖,直到這個鎖可用爲止。
自旋鎖與 UP、SMP 的關係:根據自旋鎖的邏輯,自旋鎖的臨界區是不能休眠的。在 UP 下,只有一個 CPU,如果我們執行到了臨界區,此時自旋鎖是不可能處於加鎖狀態的。因爲我們正在佔用 CPU,又沒有其它的 CPU,其它的臨界區要麼沒有到來、要麼已經執行過去了。所以我們是一定能獲得自旋鎖的,所以自旋鎖對 UP 來說是沒有意義的。但是爲了在 UP 和 SMP 下代碼的一致性,UP 下也有自旋鎖,但是自旋鎖的定義就變成了空結構體,自旋鎖的加鎖操作就退化成了禁用搶佔,自旋鎖的解鎖操作也就退化成了開啓搶佔。所以說自旋鎖只適用於 SMP,但是在 UP 下也提供了兼容操作。
自旋鎖一開始的實現是很簡單的,後來隨着衆核時代的到來,自旋鎖的公平性成了很大的問題,於是內核實現了票號自旋鎖 (ticket spinlock) 來保證加鎖的公平性。後來又發現票號自旋鎖有很大的性能問題,於是又開始着力解決自旋鎖的性能問題。先是開發出了 MCS 自旋鎖,確實解決了性能問題,但是它改變了自旋鎖的接口,所以沒辦法替代自旋鎖。然後又有人對 MCS 自旋鎖進行改造從而開發出了隊列自旋鎖(queue spinlock)。隊列自旋鎖既解決了自旋鎖的性能問題,又保持了自旋鎖的原有接口,非常完美。現在內核使用的自旋鎖是隊列自旋鎖。下面我們用一張圖來總結一下自旋鎖的發展史(x86 平臺)。
02
自旋鎖的特性
⑴忙等待特性
當一個線程嘗試獲取自旋鎖,而該鎖已經被其他線程佔用時,這個線程不會進入阻塞狀態(如掛起並讓出 CPU)。相反,它會在一個循環中不斷地檢查(“自旋”)鎖是否被釋放,這個過程是忙等待(busy - waiting)的過程。例如,在簡單的代碼實現中可能會有如下形式:
while (test_and_set(&lock));
其中 test_and_set 是一個原子操作,用於檢查鎖是否被佔用並嘗試獲取鎖,如果鎖被佔用則返回真,循環就會一直執行,直到鎖被釋放。這種忙等待的方式可以避免線程進入睡眠和喚醒的開銷,在鎖被佔用時間很短的情況下能快速獲取鎖。
⑵原子性操作保證
自旋鎖的獲取和釋放操作必須是原子操作。原子操作是不可被中斷的操作序列,在多處理器系統中,通過特殊的指令(如在 x86 架構中的 LOCK 指令前綴)來確保操作的原子性。以獲取自旋鎖爲例,當一個線程執行獲取鎖的原子操作時,其他線程對同一把鎖的獲取操作必須等待這個原子操作完成。這保證了在同一時刻只有一個線程能夠成功獲取自旋鎖,從而確保了共享資源訪問的互斥性。
⑶非搶佔特性(在特定實現下)
在某些自旋鎖的實現中,當一個線程獲取自旋鎖後,它不會被強制剝奪(preemption)自旋鎖,直到它主動釋放鎖。這與一些其他的鎖機制(如某些可搶佔式的互斥鎖)不同,這種特性有助於簡化自旋鎖的實現邏輯,但是也可能導致優先級反轉(priority inversion)等問題。例如,如果一個高優先級線程等待一個被低優先級線程持有的自旋鎖,而低優先級線程又無法被搶佔,高優先級線程就會一直處於等待狀態,直到低優先級線程釋放鎖。
⑷輕量級同步機制
自旋鎖相對於其他更復雜的同步機制(如信號量、條件變量等)來說,它的實現比較簡單,並且沒有複雜的等待隊列管理等操作。它通常只需要使用少量的機器指令來實現獲取和釋放操作,所以它在一些簡單的共享資源保護場景下,特別是在共享資源的臨界區代碼執行時間很短(通常小於兩次線程上下文切換的時間)的情況下,性能比較好。例如,在對一個簡單的計數器進行原子更新的場景中,使用自旋鎖可以有效地保護計數器的更新操作,避免多個線程同時更新導致的數據不一致。
⑸有限的適用性
由於自旋鎖在等待鎖的過程中會一直佔用 CPU 資源,所以如果鎖被佔用的時間較長,就會導致等待的線程長時間地佔用 CPU 進行空轉,浪費 CPU 資源。因此,自旋鎖適用於保護臨界區代碼執行時間非常短的共享資源,如單個機器字長的數據(如整數變量)的原子更新等場景。如果臨界區執行時間較長,就應該考慮使用其他更適合的同步機制,如互斥鎖結合線程阻塞和喚醒機制。
03
自旋鎖相關 API
3.1 初始化自旋鎖
在 Linux 內核中,初始化自旋鎖可以使用 spin_lock_init 函數。例如:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/spinlock.h>
spinlock_t my_lock;
static int __init my_init(void) {
printk(KERN_INFO "Initializing module.\n");
spin_lock_init(&my_lock);
return 0;
}
static void __exit my_exit(void) {
printk(KERN_INFO "Exiting module.\n");
}
module_init(my_init);
module_exit(my_exit);
spin_lock_init 函數主要用於設置鎖的狀態爲未鎖定,並可能初始化一些與鎖相關的其他信息。
3.2 加鎖操作
**①spin_lock:**使用 spin_lock 函數可以獲取指定的自旋鎖,即加鎖。例如:
spinlock_t lock;
void functionA() {
spin_lock(&lock);
// 臨界區代碼
spin_unlock(&lock);
}
**②spin_lock_irqsave:**這個函數在獲取鎖之前保存中斷狀態並禁止本地中斷。例如:
spinlock_t lock;
unsigned long flags;
void functionA() {
spin_lock_irqsave(&lock, flags);
// 臨界區代碼
spin_unlock_irqrestore(&lock, flags);
}
**③spin_lock_irq:**禁止本地中斷並獲取自旋鎖,如果確定處理器上沒有禁止中斷,可以使用這個函數代替 spin_lock_irqsave,無需跟蹤中斷狀態。
**④spin_lock_bh:**在獲取鎖之前禁止軟件中斷,但硬件中斷保持打開。
3.3 嘗試加鎖操作
spin_trylock 和 spin_trylock_bh 函數用於嘗試獲取指定的自旋鎖,如果沒有獲取到就返回 0,獲取成功返回非零值。例如:
spinlock_t lock;
int result = spin_trylock(&lock);
if (result) {
// 獲取到鎖,執行臨界區代碼
spin_unlock(&lock);
} else {
// 未獲取到鎖,執行其他操作
}
3.4 解鎖操作
-
spin_unlock:釋放自旋鎖。
-
spin_unlock_irqrestore:將中斷狀態恢復到以前的狀態,並且激活本地中斷,釋放自旋鎖。傳遞給這個函數的 flags 參數必須是傳遞給 spin_lock_irqsave 的同一個變量,並且必須在同一個函數中調用 spin_lock_irqsave 和 spin_unlock_irqrestore,否則可能會破壞某些體系。
-
spin_unlock_irq:激活本地中斷,並釋放自旋鎖。
-
spin_unlock_bh:恢復軟件中斷狀態並釋放自旋鎖。
04
自旋鎖的使用場景
⑴多處理器系統中的短時間資源訪問保護
在多處理器系統中,當多個線程需要訪問共享資源,並且對共享資源的訪問時間很短(例如,對一個共享的計數器進行簡單的加 1 操作)時,自旋鎖是一個很好的選擇。
例如,在一個高性能計算的場景中,多個處理器核心可能會同時對一個全局的任務計數器進行更新。假設這個計數器用於記錄已經完成的計算任務數量。每個核心在完成一個小任務後,會通過自旋鎖來保護對計數器的更新操作。由於更新操作很簡單,只是一個原子的加 1 操作,使用自旋鎖可以快速地獲取鎖、更新計數器並釋放鎖,避免了線程阻塞和喚醒的開銷。
⑵中斷處理與線程同步
當一個系統既要處理中斷,又要保證線程對共享資源的安全訪問時,自旋鎖可以用於同步。在這種情況下,中斷處理程序和普通線程可能會競爭訪問相同的資源。
例如,在一個網絡設備驅動程序中,網絡數據包的接收可能會觸發中斷。中斷處理程序會將接收到的數據包放入一個共享的緩衝區,而用戶線程可能會從這個緩衝區中讀取數據包進行處理。爲了防止中斷處理程序和用戶線程同時訪問緩衝區導致數據混亂,自旋鎖可以用於保護緩衝區的訪問。因爲中斷處理通常需要快速完成,使用自旋鎖可以在短時間內獲取和釋放鎖,保證數據的完整性。
⑶實現簡單的互斥訪問機制
對於一些簡單的多線程程序,程序員希望以一種簡單的方式來實現互斥訪問共享資源。自旋鎖的實現相對簡單,不需要複雜的線程等待隊列等管理機制。
比如,在一個簡單的多線程文件讀取程序中,多個線程可能需要訪問一個配置文件來獲取讀取文件的起始位置等信息。使用自旋鎖可以很方便地確保每次只有一個線程訪問配置文件,避免文件讀取位置等信息被錯誤地修改。
⑷避免複雜的線程調度開銷
在某些對性能要求極高的場景下,線程的阻塞和喚醒會帶來較大的開銷,包括保存和恢復線程上下文等操作。自旋鎖通過忙等待的方式,可以避免這些開銷。
例如,在一個實時性要求很高的控制系統中,多個傳感器數據採集線程和控制算法執行線程可能會共享一些實時性要求極高的中間數據。如果使用阻塞式的鎖,頻繁的線程調度可能會導致數據更新不及時。而自旋鎖可以讓線程在等待鎖的過程中快速地獲取鎖並更新數據,減少因線程調度帶來的延遲。
05
自旋鎖的實現原理
⑴原子操作基礎
自旋鎖的實現依賴於原子操作。原子操作是指在執行過程中不會被中斷的操作,它可以保證在多處理器環境下操作的完整性和一致性。在現代處理器中,有專門的指令來支持原子操作。例如,在 x86 架構中,帶有 LOCK 前綴的指令(如 LOCK XCHG)可以確保操作的原子性。
原子操作主要用於實現自旋鎖的兩個關鍵部分:獲取鎖和釋放鎖。以一個簡單的基於整數的自旋鎖爲例,通常用一個整數變量來表示鎖的狀態,比如 0 表示鎖未被佔用,1 表示鎖被佔用。獲取鎖的過程就是通過原子操作將這個變量從 0 變爲 1,如果發現變量已經是 1,則表示鎖被佔用,需要進行自旋等待。
⑵獲取鎖(lock_acquire
)實現原理
最常見的獲取鎖的實現方式是使用 test - and - set(測試並設置)原子操作。這個操作會先讀取變量的值,然後設置變量的值爲 1,並且返回讀取的值。
假設我們有一個自旋鎖變量 spinlock,代碼實現可能如下:
int test_and_set(int *lock) {
int old_value = *lock;
*lock = 1;
return old_value;
}
void lock_acquire(int *spinlock) {
while (test_and_set(spinlock));
}
當一個線程調用 lock_acquire 函數時,它會進入一個循環。在每次循環中,test - and - set 操作會檢查鎖是否被佔用。如果鎖未被佔用(返回 0),那麼當前線程成功獲取鎖,循環結束;如果鎖被佔用(返回 1),線程會繼續循環,不斷地檢查鎖是否被釋放,這就是所謂的 “自旋” 過程。
⑶釋放鎖(lock_release
)實現原理
釋放鎖的過程相對簡單。通常是將表示自旋鎖的變量設置爲 0,以表示鎖已經被釋放。不過,這個操作同樣需要是原子操作,以確保在多處理器環境下的正確性。
代碼實現可能如下:
void lock_release(int *spinlock) {
*spinlock = 0;
}
當一個線程完成對共享資源的訪問後,它會調用 lock_release 函數,將自旋鎖的狀態重置爲 0,這樣其他正在自旋等待的線程就可以獲取鎖了。
⑷內存屏障(Memory Barrier)的考慮
在自旋鎖的實現中,尤其是在多處理器環境下,還需要考慮內存屏障的問題。內存屏障是一種硬件或軟件機制,用於確保指令執行的順序和內存訪問的順序。
例如,在獲取鎖之後,需要確保在訪問共享資源之前,之前的所有內存操作都已經完成,並且在釋放鎖之前,對共享資源的訪問操作都已經完成並更新到內存中。這是因爲處理器可能會對指令進行重排序,而內存屏障可以防止這種重排序對自旋鎖的正確性產生影響。在一些高級編程語言或處理器特定的代碼中,可能會有專門的指令(如 mfence、sfence 和 lfence 在 x86 架構中)來實現內存屏障的功能。
06
源碼實現分析
以 x86 架構爲例,當申請自旋鎖時,arch_spin_trylock 函數會判斷自旋鎖是否被佔用,如果沒被佔用,嘗試原子性地更新 lock 中的 head_tail 的值,將 tail + 1,返回是否加鎖成功。如果加鎖不成功,會進入 do_raw_spin_lock 函數,在循環中不斷讀取新的 head 值,直到 head 和 tail 相等,表示鎖可用。在釋放鎖時,會將 tail 加 1,並把之前的值記錄下來,完成加鎖操作。以下是對這段關於 x86 架構下自旋鎖源碼實現的詳細分析:
⑴申請自旋鎖(arch_spin_trylock 函數)
初始判斷:當調用 arch_spin_trylock 函數申請自旋鎖時,首先會進行一個判斷操作,即檢查自旋鎖是否已經被其他線程或進程佔用。這一步是非常關鍵的,因爲它確定了後續操作的走向。如果自旋鎖當前未被佔用,那麼就有機會嘗試獲取該鎖。
原子性更新嘗試:若自旋鎖未被佔用,接下來會嘗試原子性地更新 lock 中的 head_tail 的值。這裏的 head_tail 應該是一個用於記錄自旋鎖狀態相關信息的數據結構中的成員,具體可能與排隊等待獲取鎖的線程信息等有關(雖然從給定描述中不太能明確其確切含義,但大致可推測是和鎖的獲取順序、狀態等相關)。
將 tail 的值加 1,這個操作是原子性的,以確保在多處理器環境下的正確性。原子性操作能保證在執行這個更新操作時,不會被其他處理器的操作所幹擾,從而準確地反映鎖的獲取狀態。
然後根據這個原子性更新的結果返回是否加鎖成功。如果更新成功,意味着當前線程成功獲取了自旋鎖;如果更新失敗,說明在嘗試更新的瞬間,可能有其他線程也在競爭獲取該鎖,並且已經成功獲取了,所以當前線程加鎖不成功。
⑵加鎖不成功的處理(do_raw_spin_lock 函數)
循環等待機制:當 arch_spin_trylock 函數加鎖不成功時,會進入 do_raw_spin_lock 函數。在這個函數中,設置了一個循環等待的機制。
在循環中,不斷地讀取新的 head 值。這裏的 head 同樣是和 head_tail 相關的數據結構中的成員,推測其與鎖的排隊等待狀態或者已經獲取鎖的線程信息等有關。
持續讀取 head 值的目的是爲了判斷鎖是否可用。通過不斷對比 head 和 tail 的值,當兩者相等時,表示鎖可用。這是因爲可能存在一種基於 head 和 tail 差值來判斷是否有線程正在佔用鎖或者排隊等待獲取鎖的邏輯。例如,當 head 和 tail 相等時,可能意味着當前沒有線程在佔用鎖,或者之前排隊等待的線程都已經完成了對鎖的使用並釋放了鎖,所以此時當前線程就有機會再次嘗試獲取鎖。
⑶釋放自旋鎖
更新 tail 值並記錄:在釋放自旋鎖時,首先會將 tail 的值加 1。這個操作同樣應該是原子性的,以保證在多處理器環境下的正確性。
並且會把之前 tail 的值記錄下來。雖然不太明確記錄下來具體用於何種目的,但可能與後續的一些統計分析(比如查看鎖的使用頻率、排隊情況等)或者是爲了確保釋放鎖的操作能夠完整且準確地被其他等待線程感知到有關。
通過這樣的操作,完成了自旋鎖的釋放過程,使得其他正在等待獲取自旋鎖的線程能夠根據新的 head 和 tail 的值來判斷鎖是否可用,進而有機會獲取到自旋鎖。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/kRjPA5Gez8rCkX9MUw5oxA