一 什么是自旋鎖
自旋鎖(Spinlock)是一種廣泛運用的底層同步機制。自旋鎖是一個互斥設備,它只有兩個值:“鎖定”和“解鎖”。它通常實現為某個整數值中的某個位。希望獲得某個特定鎖得代碼測試相關的位。如果鎖可用,則“鎖定”被設置,而代碼繼續進入臨界區;相反,如果鎖被其他人獲得,則代碼進入忙循環(而不是休眠,這也是自旋鎖和一般鎖的區別)並重復檢查這個鎖,直到該鎖可用為止,這就是自旋的過程。“測試並設置位”的操作必須是原子的,這樣,即使多個線程在給定時間自旋,也只有一個線程可獲得該鎖。
自旋鎖對於SMP和單處理器可搶占內核都適用。可以想象,當一個處理器處於自旋狀態時,它做不了任何有用的工作,因此自旋鎖對於單處理器不可搶占內核沒有意義,實際上,非搶占式的單處理器系統上自旋鎖被實現為空操作,不做任何事情。
曾經有個經典的例子來比喻自旋鎖:A,B兩個人合租一套房子,共用一個廁所,那么這個廁所就是共享資源,且在任一時刻最多只能有一個人在使用。當廁所閑置時,誰來了都可以使用,當A使用時,就會關上廁所門,而B也要使用,但是急啊,就得在門外焦急地等待,急得團團轉,是為“自旋”,這也是要求鎖的持有時間盡量短的原因!
自旋鎖有以下特點:
___________________
- 用於臨界區互斥
- 在任何時刻最多只能有一個執行單元獲得鎖
- 要求持有鎖的處理器所占用的時間盡可能短
- 等待鎖的線程進入忙循環
補充:
___________________
臨界區和互斥:對於某些全局資源,多個並發執行的線程在訪問這些資源時,操作系統可能會交錯執行多個並發線程的訪問指令,一個錯誤的指令順序可能會導致最終的結果錯誤。多個線程對共享的資源的訪問指令構成了一個臨界區(critical section),這個臨界區不應該和其他線程的交替執行,確保每個線程執行臨界區時能對臨界區里的共享資源互斥的訪問。
二 自旋鎖較互斥鎖之類同步機制的優勢
2.1 休眠與忙循環
___________________
互斥鎖得不到鎖時,線程會進入休眠,這類同步機制都有一個共性就是 一旦資源被占用都會產生任務切換,任務切換涉及很多東西的(保存原來的上下文,按調度算法選擇新的任務,恢復新任務的上下文,還有就是要修改cr3寄存器會導致cache失效)這些都是需要大量時間的,因此用互斥之類來同步一旦涉及到阻塞代價是十分昂貴的。
一個互斥鎖來控制2行代碼的原子操作,這個時候一個CPU正在執行這個代碼,另一個CPU也要進入, 另一個CPU就會產生任務切換。為了短短的兩行代碼 就進行任務切換執行大量的代碼,對系統性能不利,另一個CPU還不如直接有條件的死循環,等待那個CPU把那兩行代碼執行完。
2.2 自旋過程
___________________
當鎖被其他線程占有時,獲取鎖的線程便會進入自旋,不斷檢測自旋鎖的狀態。一旦自旋鎖被釋放,線程便結束自旋,得到自旋鎖的線程便可以執行臨界區的代碼。對於臨界區的代碼必須短小,否則其他線程會一直受到阻塞,這也是要求鎖的持有時間盡量短的原因!
三 windows驅動程序中自旋鎖的使用
3.1 初始化自旋鎖
___________________
在windows下,自旋鎖用一個名為KSPIN_LOCK的結構體進行表示。
VOID KeInitializeSpinLock(
_Out_ PKSPIN_LOCK SpinLock
);
注意:
存儲KSPIN_LOCK變量必須是常駐在內存的,一般可以放在設備對象的設備擴展結構體中,控制對象的控制擴展中,或者調用者申請的非分頁內存池中。
可運行在任意IRQL中。
3.2 申請自旋鎖
___________________
VOID KeAcquireSpinLock(
_In_ PKSPIN_LOCK SpinLock,
_Out_ PKIRQL OldIrql
);
SpinLock:指向經過KeInitializeSpinLock的結構體
OldIrql:用於保存當前的中斷請求級
注意:
當使用全局變量存儲 OldIrql時,不同的鎖最好不要共用一個全局塊,否則很容易引起競爭問題(race condition)。
3.3 釋放自旋鎖
___________________
VOID KeReleaseSpinLock(
_Inout_ PKSPIN_LOCK SpinLock,
_In_ KIRQL NewIrql
);
SpinLock:指向經過KeInitializeSpinLock的結構體
NewIrql :KeAcquireSpinLock保存當前的中斷請求級
注意
運行的IRQL = DISPATCH_LEVEL
四 windows下自旋鎖的實現
4.1 KSPIN_LOCK結構體
___________________
KSPIN_LOCK實際是一個操作系統相關的無符號整數,32位系統上是32位的unsigned long,64位系統則定義為unsigned __int64。
在初始化時,其值被設置為0,為空閑狀態。
4.2 KeInitializeSpinLock
___________________
FORCEINLINE
VOID
NTAPI
KeInitializeSpinLock (
__out PKSPIN_LOCK SpinLock
)
{
*SpinLock = 0; //將SpinLock初始化為0,表示鎖的狀態為空閑狀態
}
4.3 KeAcquireSpinLock
___________________
4.3.1 單處理器
wdm.h中是這樣定義的:
#define KeAcquireSpinLock(SpinLock, OldIrql) \
*(OldIrql) = KeAcquireSpinLockRaiseToDpc(SpinLock)
很明顯,核心的操作對象是SpinLock,同時也與IRQL有關 。
如果當前的IRQL為PASSIVEL_LEVEL,那么首先會提升IRQL到DISPATCH_LEVEL,然后調用KxAcquireSpinLock()。
如果當前的IRQL為DISPATCH_LEVEL,那么就調用KeAcquireSpinLockAtDpcLevel,省去提升IRQL一步。
因為線程調度也是發生在DISPATCH_LEVEL,所以提升IRQL之后當前處理器上就不會發生線程切換。單處理器時,當前只能有一個線程被執行,而這個線程提升IRQL至DISPATCH_LEVEL之后又不會因為調度被切換出去,自然也可以實現我們想要的互斥“效果”,其實只操作IRQL即可,無需SpinLock。實際上單核系統的內核文件ntosknl.exe中導出的有關SpinLock的函數都只有一句話,就是return。
4.3.2 多處理器
而多處理器呢?提升IRQL只會影響到當前處理器,保證當前處理器的當前線程不被切換。
__forceinline
KIRQL
KeAcquireSpinLockRaiseToDpc (
__inout PKSPIN_LOCK SpinLock
)
{
KIRQL OldIrql;
//
// Raise IRQL to DISPATCH_LEVEL and acquire the specified spin lock.
//
OldIrql = KfRaiseIrql(DISPATCH_LEVEL); //提升IRQL
KxAcquireSpinLock(SpinLock); //獲取自旋鎖
return OldIrql;
}
其中用於獲取自旋鎖的KxAcquireSpinLock函數:
__forceinline
VOID
KxAcquireSpinLock (
__inout PKSPIN_LOCK SpinLock
)
{
if (InterlockedBitTestAndSet64((LONG64 *)SpinLock, 0))//64位函數
{
KxWaitForSpinLockAndAcquire(SpinLock); //CPU空轉進行等待
}
}
KxAcquireSpinLock()函數先測試鎖的狀態。若鎖空閑,則SpinLock為0,那么InterlockedBitTestAndSet()將返回0,並使SpinLock置位,不再為0。這樣KxAcquireSpinLock()就成功得到了鎖,並設置鎖為占用狀態(*SpinLock不為0),函數返回。若鎖已被占用呢?InterlockedBitTestAndSet()將返回1,此時將調用KxWaitForSpinLockAndAcquire()等待並獲取這個鎖。這表明,SPIN_LOCK為0則鎖空閑,非0則已被占有。
InterlockedBitTestAndSet64()函數的32位版本如下:
BOOLEAN
FORCEINLINE
InterlockedBitTestAndSet (
IN LONG *Base,
IN LONG Bit
)
{
__asm {
mov eax, Bit
mov ecx, Base
lock bts [ecx], eax
setc al
};
}
關鍵就在bts指令,是一個進行位測試並置位的指令。這里在進行關鍵的操作時有lock前綴,保證了多處理器安全。
4.4 KxReleaseSpinLock
___________________
__forceinline
VOID
KxReleaseSpinLock (
__inout PKSPIN_LOCK SpinLock
)
{
InterlockedAnd64((LONG64 *)SpinLock, 0);//釋放時進行與操作設置其為0
}
4.5 真實系統上的實現
___________________
好了,對於自旋鎖的初始化、獲取、釋放,都有了了解。但是只是談談原理,看看WRK,似乎有種紙上談兵的感覺?那就實戰一下,看看真實系統中是如何實現的。以雙核系統中XP SP2下內核中關於SpinLock的實現細節為例:
用IDA分析雙核系統的內核文件ntkrnlpa.exe,關於自旋鎖操作的兩個基本函數是KiAcquireSpinLock和KiReleaseSpinLock,其它幾個類似。
.text:004689C0 KiAcquireSpinLock proc near ; CODE XREF:
sub_416FEE+2D p
.text:004689C0 ; sub_4206C0+5 j ...
.text:004689C0 lock bts dword ptr [ecx], 0
.text:004689C5 jb short loc_4689C8
.text:004689C7 retn
.text:004689C8 ; ---------------------------------------------------------------------------
.text:004689C8
.text:004689C8 loc_4689C8: ; CODE XREF: KiAcquireSpinLock+5 j
.text:004689C8 ; KiAcquireSpinLock+12 j
.text:004689C8 test dword ptr [ecx], 1
.text:004689CE jz short KiAcquireSpinLock
.text:004689D0 pause
.text:004689D2 jmp short loc_4689C8
.text:004689D2 KiAcquireSpinLock endp
代碼比較簡單,還原成源碼是這樣子的:
void __fastcall KiAcquireSpinLock(int _ECX)
{
while ( 1 )
{
__asm { lock bts dword ptr [ecx], 0 }
if ( !_CF )
break;
while ( *(_DWORD *)_ECX & 1 )
__asm { pause }//應是rep nop,IDA將其翻譯成pause
}
}
fastcall方式調用,參數KSPIN_LOCK在ECX中,可以看到是一個死循環,先測試其是否置位,若否,則CF將置0,並將ECX置位,即獲取鎖的操作成功;若是,即鎖已被占有,則一直對其進行測試並進入空轉狀態,這和前面分析的完全一致,只是代碼似乎更精煉了一點,畢竟是實用的玩意嘛。
再來看看釋放時:
.text:004689E0 public KiReleaseSpinLock
.text:004689E0 KiReleaseSpinLock proc near ; CODE XREF: sub_41702E+E p
.text:004689E0 ; sub_4206D0+5 j ...
.text:004689E0 mov byte ptr [ecx], 0
.text:004689E3 retn
.text:004689E3 KiReleaseSpinLock endp
這個再清楚不過了,直接設置為0就代表了將其釋放,此時那些如虎狼般瘋狂空轉的其它處理器將馬上獲知這一信息,於是,下一個獲取、釋放的過程開始了。這就是最基本的自旋鎖,其它一些自旋鎖形式是對這種基本形式的擴充。比如排隊自旋鎖,是為了解決多處理器競爭時的無序狀態等等,不多說了。
現在對自旋鎖可謂真的是明明白白了,之前我犯的錯誤就是以為用了自旋鎖就能保證多核同步,其實不是的,用自旋鎖來保證多核同步的前提是大家都要用這個鎖。若當前處理器已占有自旋鎖,只有別的處理器也來請求這個鎖時,才會進入空轉,不進行別的操作,這時你的操作將不會受到干擾。
參考鏈接:
【原創】明明白白自旋鎖
Linux 內核的排隊自旋鎖(FIFO Ticket Spinlock)
Linux 內核的同步機制,第 1 部分