自旋鎖和排隊自旋鎖
引言
自旋鎖(Spinlock)是一種 Linux 核心中廣泛運用的底層同步機制。自旋鎖是一種工作於多處理器環境的特殊的鎖,在單處理環境中自旋鎖的操作被替換為空操作。當某個處理器上的核心執行執行緒申請自旋鎖時,如果鎖可用,則獲得鎖,然後執行臨界區操作,最後釋放鎖;如果鎖已被佔用,執行緒並不會轉入睡眠狀態,而是忙等待該鎖,一旦鎖被釋放,則第一個感知此資訊的執行緒將獲得鎖。
長期以來,人們總是關注於自旋鎖的安全和高效,而忽視了自旋鎖的“公平”性。傳統的自旋鎖本質上用一個整數來表示,值為1代表鎖未被佔用。這種無序競爭的本質特點導致執行執行緒無法保證何時能取到鎖,某些執行緒可能需要等待很長時間。隨著計算機處理器個數的不斷增長,這種“不公平”問題將會日益嚴重。
排隊自旋鎖(FIFO Ticket Spinlock)是 Linux 核心 2.6.25 版本引入的一種新型自旋鎖,它通過儲存執行執行緒申請鎖的順序資訊解決了傳統自旋鎖的“不公平”問題。排隊自旋鎖的程式碼由 Linux 核心開發者 Nick Piggin 實現,目前只針對 x86 體系結構(包括 IA32 和 x86_64),相信很快就會被移植到其它平臺。
傳統自旋鎖的實現與不足
Linux 核心自旋鎖的底層資料結構 raw_spinlock_t 定義如下:
清單 1. raw_spinlock_t 資料結構
typedef struct {
unsigned int slock;
} raw_spinlock_t;
slock 雖然被定義為無符號整數,但是實際上被當作有符號整數使用。slock 值為 1 代表鎖未被佔用,值為 0 或負數代表鎖被佔用。初始化時 slock 被置為 1。
執行緒通過巨集 spin_lock 申請自旋鎖。如果不考慮核心搶佔,則 spin_lock 呼叫 __raw_spin_lock 函式,程式碼如下所示:
清單 2. __raw_spin_lock 函式
static inline void __raw_spin_lock(raw_spinlock_t *lock) { asm volatile("\n1:\t" LOCK_PREFIX " ; decb %0\n\t" "jns 3f\n" "2:\t" "rep;nop\n\t" "cmpb $0,%0\n\t" "jle 2b\n\t" "jmp 1b\n" "3:\n\t" : "+m" (lock->slock) : : "memory"); } |
- LOCK_PREFIX 的定義如下:
清單 3. LOCK_PREFIX巨集
#ifdef CONFIG_SMP #define LOCK_PREFIX \ ".section .smp_locks,\"a\"\n" \ _ASM_ALIGN "\n" \ _ASM_PTR "661f\n" /* address */ \ ".previous\n" \ "661:\n\tlock; "
#else /* ! CONFIG_SMP */ #define LOCK_PREFIX "" #endif |
在多處理器環境中 LOCK_PREFIX 實際被定義為 “lock”字首。
x86 處理器使用“lock”字首的方式提供了在指令執行期間對匯流排加鎖的手段。晶片上有一條引線 LOCK,如果在一條彙編指令(ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, XCHG)前加上“lock” 字首,經過彙編後的機器程式碼就使得處理器執行該指令時把引線 LOCK 的電位拉低,從而把匯流排鎖住,這樣其它處理器或使用DMA的外設暫時無法通過同一匯流排訪問記憶體。
從 P6 處理器開始,如果指令訪問的記憶體區域已經存在於處理器的內部快取中,則“lock” 字首並不將引線 LOCK 的電位拉低,而是鎖住本處理器的內部快取,然後依靠快取一致性協議保證操作的原子性。
- decb 彙編指令將 slock 的值減 1。由於“減 1”是“讀-改-寫”操作,不是原子操作,可能會被同時申請鎖的其它處理器上的執行緒干擾,所以必須加上“lock”字首。
- jns 彙編指令檢查 EFLAGS 暫存器的 SF(符號)位,如果為 0,說明 slock 原來的值為 1,則執行緒獲得鎖,然後跳到標籤 3 的位置結束本次函式呼叫。如果 SF 位為 1,說明 slock 原來的值為 0 或負數,鎖已被佔用。那麼執行緒轉到標籤 2 處不斷測試 slock 與 0 的大小關係,假如 slock 小於或等於 0,跳轉到標籤 2 的位置繼續忙等待;假如 slock 大於 0,說明鎖已被釋放,則跳轉到標籤 1 的位置重新申請鎖。
執行緒通過巨集 spin_unlock 釋放自旋鎖,該巨集呼叫 __raw_spin_unlock 函式:
清單 4. __raw_spin_unlock函式
static inline void __raw_spin_unlock(raw_spinlock_t *lock) { asm volatile("movb $1,%0" : "+m" (lock->slock) :: "memory"); } |
可見 __raw_spin_unlock 函式僅僅執行一條彙編指令:將 slock 置為 1。
儘管擁有使用簡單方便、效能好的優點,自旋鎖也存在自身的不足:
- 由於傳統自旋鎖無序競爭的本質特點,核心執行執行緒無法保證何時可以取到鎖,某些執行執行緒可能需要等待很長時間,導致“不公平”問題的產生。這有兩方面的原因:
- 隨著處理器個數的不斷增加,自旋鎖的競爭也在加劇,自然導致更長的等待時間。
- 釋放自旋鎖時的重置操作將無效化所有其它正在忙等待的處理器的快取,那麼在處理器拓撲結構中臨近自旋鎖擁有者的處理器可能會更快地重新整理快取,因而增大獲得自旋鎖的機率。
- 由於每個申請自旋鎖的處理器均在全域性變數 slock 上忙等待,系統匯流排將因為處理器間的快取同步而導致繁重的流量,從而降低了系統整體的效能。
排隊自旋鎖的設計原理
傳統自旋鎖的“不公平”問題在鎖競爭激烈的伺服器系統中尤為嚴重,因此 Linux 核心開發者 Nick Piggin 在 Linux 核心 2.6.25 版本中引入了排隊自旋鎖:通過儲存執行執行緒申請鎖的順序資訊來解決“不公平”問題。
排隊自旋鎖仍然使用原有的 raw_spinlock_t 資料結構,但是賦予 slock 域新的含義。為了儲存順序資訊,slock 域被分成兩部分,分別儲存鎖持有者和未來鎖申請者的票據序號(Ticket Number),如下圖所示:
圖 1. Next 和 Owner 域
如果處理器個數不超過 256,則 Owner 域為 slock 的 0-7 位,Next 域為 slock 的 8-15 位,slock 的高 16 位不使用;如果處理器個數超過 256,則 Owner 和 Next 域均為 16 位,其中 Owner 域為 slock 的低 16 位。可見排隊自旋鎖最多支援 216=65536 個處理器。
只有 Next 域與 Owner 域相等時,才表明鎖處於未使用狀態(此時也無人申請該鎖)。排隊自旋鎖初始化時 slock 被置為 0,即 Owner 和 Next 置為 0。核心執行執行緒申請自旋鎖時,原子地將 Next 域加 1,並將原值返回作為自己的票據序號。如果返回的票據序號等於申請時的 Owner 值,說明自旋鎖處於未使用狀態,則直接獲得鎖;否則,該執行緒忙等待檢查 Owner 域是否等於自己持有的票據序號,一旦相等,則表明鎖輪到自己獲取。執行緒釋放鎖時,原子地將 Owner 域加 1 即可,下一個執行緒將會發現這一變化,從忙等待狀態中退出。執行緒將嚴格地按照申請順序依次獲取排隊自旋鎖,從而完全解決了“不公平”問題。
排隊自旋鎖的實現
排隊自旋鎖沒有改變原有自旋鎖的呼叫介面,該 API 是以 C 語言巨集的形式提供給開發人員。下表列出 6 個主要的 API 和相對應的底層實現函式:
表 1. 排隊自旋鎖 API
巨集 |
底層實現函式 |
描述 |
spin_lock_init |
無 |
將鎖置為初始未使用狀態(值為 0) |
spin_lock |
__raw_spin_lock |
忙等待直到 Owner 域等於本地票據序號 |
spin_unlock |
__raw_spin_unlock |
Owner 域加 1,將鎖傳給後續等待執行緒 |
spin_unlock_wait |
__raw_spin_unlock_wait |
不申請鎖,忙等待直到鎖處於未使用狀態 |
spin_is_locked |
__raw_spin_is_locked |
測試鎖是否處於使用狀態 |
spin_trylock |
__raw_spin_trylock |
如果鎖處於未使用狀態,獲得鎖;否則直接返回 |
下面介紹其中 3 個底層函式的實現細節,假定處理器個數不超過 256。
- __raw_spin_is_locked
清單 5. __raw_spin_is_locked 函式
static inline int __raw_spin_is_locked(raw_spinlock_t *lock) { int tmp = *(volatile signed int *)(&(lock)->slock); return (((tmp >> 8) & 0xff) != (tmp & 0xff)); } |
- 此函式判斷 Next 和 Owner 域是否相等,如果相等,說明自旋鎖處於未使用狀態,返回 0;否則返回1。
- tmp 這種複雜的賦值操作是為了直接從記憶體中取值,避免處理器快取的影響。
- __raw_spin_lock
清單 6. __raw_spin_lock 函式
static inline void __raw_spin_lock(raw_spinlock_t *lock) { short inc = 0x0100;
__asm__ __volatile__ ( LOCK_PREFIX "xaddw %w0, %1\n" "1:\t" "cmpb %h0, %b0\n\t" "je 2f\n\t" "rep ; nop\n\t" "movb %1, %b0\n\t" /* don't need lfence here, because loads are in-order */ "jmp 1b\n" "2:" :"+Q" (inc), "+m" (lock->slock) : :"memory", "cc"); } |
- LOCK_PREFIX 巨集在前文中已經介紹過,就是“lock”字首。
- xaddw 彙編指令將 slock 和 inc 的值交換,然後把這兩個值相加後的和存到 slock 中。也就是說,該指令執行完畢後,inc 存有原來的 slock 值作為票據序號,而 slock 的 Next 域被加 1。
- comb 比較 inc 變數的高位和低位位元組是否相等,如果相等,表明鎖處於未使用狀態,直接跳轉到標籤 2 的位置退出函式。
- 如果鎖處於使用狀態,則不停地將當前的 slock 的 Owner 域複製到 inc 的低位元組處(movb 指令),然後重複 c 步驟。不過此時 inc 變數的高位和低位位元組相等表明輪到自己獲取了自旋鎖。
- __raw_spin_unlock
清單 7. __raw_spin_unlock 函式
static inline void __raw_spin_unlock(raw_spinlock_t *lock) { __asm__ __volatile__( UNLOCK_LOCK_PREFIX "incb %0" :"+m" (lock->slock) : :"memory", "cc"); } |
- 在 IA32 體系結構下,如果使用 PPro SMP 系統或者啟用了 X86_OOSTORE,則 UNLOCK_LOCK_PREFIX 被定義為“lock”字首;否則被定義為空。
- incb 指令將 slock 最低位位元組也就是 Owner 域加 1。
Windows 作業系統的排隊自旋鎖(Queued Spinlock)介紹
排隊自旋鎖並不是一個新想法,某些作業系統早已採用了類似概念,只是實現方式有所差別。例如在 Windows 作業系統中排隊自旋鎖被稱為 Queued Spinlock。
Queued Spinlock 的工作方式如下:每個處理器上的執行執行緒都有一個本地的標誌,通過該標誌,所有使用該鎖的處理器(鎖擁有者和等待者)被組織成一個單向佇列。當一個處理器想要獲得一個已被其它處理器持有的 Queued Spinlock 時,它把自己的標誌放在該 Queued Spinlock 的單向佇列的末尾。如果當前鎖持有者釋放了自旋鎖,則它將該鎖移交到佇列中位於自己之後的第一個處理器。同時,如果一個處理器正在忙等待 Queued Spinlock,它並不是檢查該鎖自身的狀態,而是檢查針對自己的標誌;在佇列中位於該處理器之前的處理器釋放自旋鎖時會設定這一標誌,以表明輪到這個正在等待的處理器了。
與 Linux 的排隊自旋鎖相比,Queued Spinlock 的設計更為複雜,但是 Queued Spinlock 擁有自己的優勢:
- 忙等待 Queued Spinlock 的每個處理器在針對該處理器的標誌上旋轉,而不是在全域性的自旋鎖上測試旋轉,因此處理器之間的同步比 Linux 的排隊自旋鎖少得多。
- Queued Spinlock 擁有真實的佇列結構,因此便於擴充更高階的功能。
擴充套件排隊自旋鎖的一點想法
排隊自旋鎖設計簡單、實現容易且效能優秀,因此肯定會受到開發人員的歡迎。本節討論一下排隊自旋鎖未來可能有用的一些擴充套件功能:
- 超時(Timeout)
儘管排隊自旋鎖保證了核心執行執行緒嚴格按照申請順序獲取鎖,但是由於鎖的競爭劇烈(例如處理器個數達到64或更多),執行緒仍然可能會等待過長的時間。當該執行緒獲得鎖時,環境也許已發生變化而導致無法完成任務。因此申請執行緒可以預先指定一個等待閾值,一旦超過該閾值且尚未獲得鎖,則自動從等待隊伍中退出,並返回代表超時的錯誤值。
- 優先順序(Priority)
當前的實現中,所有的執行緒一律平等,嚴格按照申請順序等待。某些執行關鍵操作的執行緒也許需要特殊對待,即賦予更高的優先順序。一旦它們申請自旋鎖,就把他們插入到等待佇列的前部優先執行。
參考資料
- 大家可以從 kernel.org 下載 Linux Kernel 2.6.25 Source Code
- 有關 spinlocks, LWN.net 上的 "Ticket spinlocks" 是個非常棒的描述。
- Intel® 64 and IA-32 Architectures Software Developer's Manuals 描述了 Intel 64 和 IA-32 處理器的架構和程式設計環境。
- 《Linux 核心原始碼情景分析》,毛德操,胡希明。浙江大學出版社出版,2002。