執行緒同步之詳解自旋鎖
一 什麼是自旋鎖
自旋鎖(Spinlock)是一種廣泛運用的底層同步機制。自旋鎖是一個互斥裝置,它只有兩個值:“鎖定”和“解鎖”。它通常實現為某個整數值中的某個位。希望獲得某個特定鎖得程式碼測試相關的位。如果鎖可用,則“鎖定”被設定,而程式碼繼續進入臨界區;相反,如果鎖被其他人獲得,則程式碼進入忙迴圈(而不是休眠,這也是自旋鎖和一般鎖的區別)並重複檢查這個鎖,直到該鎖可用為止,這就是自旋的過程。“測試並設定位”的操作必須是原子的,這樣,即使多個執行緒在給定時間自旋,也只有一個執行緒可獲得該鎖。
自旋鎖對於SMP和單處理器可搶佔核心都適用。可以想象,當一個處理器處於自旋狀態時,它做不了任何有用的工作,因此自旋鎖對於單處理器不可搶佔核心沒有意義,實際上,非搶佔式的單處理器系統上自旋鎖被實現為空操作,不做任何事情。
曾經有個經典的例子來比喻自旋鎖:A,B兩個人合租一套房子,共用一個廁所,那麼這個廁所就是共享資源,且在任一時刻最多隻能有一個人在使用。當廁所閒置時,誰來了都可以使用,當A使用時,就會關上廁所門,而B也要使用,但是急啊,就得在門外焦急地等待,急得團團轉,是為“自旋”,這也是要求鎖的持有時間儘量短的原因!
自旋鎖有以下特點:
___________________
- 用於臨界區互斥
- 在任何時刻最多隻能有一個執行單元獲得鎖
- 要求持有鎖的處理器所佔用的時間儘可能短
- 等待鎖的執行緒進入忙迴圈
補充:
___________________
臨界區和互斥:對於某些全域性資源,多個併發執行的執行緒在訪問這些資源時,作業系統可能會交錯執行多個併發執行緒的訪問指令,一個錯誤的指令順序可能會導致最終的結果錯誤。多個執行緒對共享的資源的訪問指令構成了一個臨界區(critical section),這個臨界區不應該和其他執行緒的交替執行,確保每個執行緒執行臨界區時能對臨界區裡的共享資源互斥的訪問。
二 自旋鎖較互斥鎖之類同步機制的優勢
2.1 休眠與忙迴圈
___________________
互斥鎖得不到鎖時,執行緒會進入休眠,這類同步機制都有一個共性就是 一旦資源被佔用都會產生任務切換,任務切換涉及很多東西的(儲存原來的上下文,按排程演算法選擇新的任務,恢復新任務的上下文,還有就是要修改cr3暫存器會導致cache失效)這些都是需要大量時間的,因此用互斥之類來同步一旦涉及到阻塞代價是十分昂貴的。
一個互斥鎖來控制2行程式碼的原子操作,這個時候一個CPU正在執行這個程式碼,另一個CPU也要進入, 另一個CPU就會產生任務切換。為了短短的兩行程式碼 就進行任務切換執行大量的程式碼,對系統性能不利,另一個CPU還不如直接有條件的死迴圈,等待那個CPU把那兩行程式碼執行完。
2.2 自旋過程
___________________
當鎖被其他執行緒佔有時,獲取鎖的執行緒便會進入自旋,不斷檢測自旋鎖的狀態。一旦自旋鎖被釋放,執行緒便結束自旋,得到自旋鎖的執行緒便可以執行臨界區的程式碼。對於臨界區的程式碼必須短小,否則其他執行緒會一直受到阻塞,這也是要求鎖的持有時間儘量短的原因!
回到頂部(go to top)三 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 核心的同步機