線程同步之詳解自旋鎖

一 什麼是自旋鎖

自旋鎖(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 部分

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章