1. 程式人生 > >執行緒同步之詳解自旋鎖

執行緒同步之詳解自旋鎖

一 什麼是自旋鎖

自旋鎖(Spinlock)是一種廣泛運用的底層同步機制。自旋鎖是一個互斥裝置,它只有兩個值:“鎖定”和“解鎖”。它通常實現為某個整數值中的某個位。希望獲得某個特定鎖得程式碼測試相關的位。如果鎖可用,則“鎖定”被設定,而程式碼繼續進入臨界區;相反,如果鎖被其他人獲得,則程式碼進入忙迴圈(而不是休眠,這也是自旋鎖和一般鎖的區別)並重複檢查這個鎖,直到該鎖可用為止,這就是自旋的過程。“測試並設定位”的操作必須是原子的,這樣,即使多個執行緒在給定時間自旋,也只有一個執行緒可獲得該鎖。

自旋鎖對於SMP和單處理器可搶佔核心都適用。可以想象,當一個處理器處於自旋狀態時,它做不了任何有用的工作,因此自旋鎖對於單處理器不可搶佔核心沒有意義,實際上,非搶佔式的單處理器系統上自旋鎖被實現為空操作,不做任何事情。

曾經有個經典的例子來比喻自旋鎖:A,B兩個人合租一套房子,共用一個廁所,那麼這個廁所就是共享資源,且在任一時刻最多隻能有一個人在使用。當廁所閒置時,誰來了都可以使用,當A使用時,就會關上廁所門,而B也要使用,但是急啊,就得在門外焦急地等待,急得團團轉,是為“自旋”,這也是要求鎖的持有時間儘量短的原因!

自旋鎖有以下特點:
___________________

  • 用於臨界區互斥
  • 在任何時刻最多隻能有一個執行單元獲得鎖
  • 要求持有鎖的處理器所佔用的時間儘可能短
  • 等待鎖的執行緒進入忙迴圈

補充:
___________________

臨界區和互斥:對於某些全域性資源,多個併發執行的執行緒在訪問這些資源時,作業系統可能會交錯執行多個併發執行緒的訪問指令,一個錯誤的指令順序可能會導致最終的結果錯誤。多個執行緒對共享的資源的訪問指令構成了一個臨界區(critical section),這個臨界區不應該和其他執行緒的交替執行,確保每個執行緒執行臨界區時能對臨界區裡的共享資源互斥的訪問。

回到頂部(go to top)

二 自旋鎖較互斥鎖之類同步機制的優勢

 

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

回到頂部(go to top)

四 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 核心的同步機