同步篇——臨界區與自旋鎖

寂靜的羽夏發表於2022-02-08

寫在前面

  此係列是本人一個字一個字碼出來的,包括示例和實驗截圖。由於系統核心的複雜性,故可能有錯誤或者不全面的地方,如有錯誤,歡迎批評指正,本教程將會長期更新。 如有好的建議,歡迎反饋。碼字不易,如果本篇文章有幫助你的,如有閒錢,可以打賞支援我的創作。如想轉載,請把我的轉載資訊附在文章後面,並宣告我的個人資訊和本人部落格地址即可,但必須事先通知我

你如果是從中間插過來看的,請仔細閱讀 羽夏看Win系統核心——簡述 ,方便學習本教程。

  看此教程之前,問幾個問題,==基礎知識儲備好了嗎?保護模式篇學會了嗎?練習做完了嗎?沒有的話就不要繼續了。==


? 華麗的分割線 ?


併發與同步

  併發是指多個執行緒在同時執行。單核是分時執行,不是真正的同時,而多核是在某一個時刻,會同時有多個執行緒再執行。同步則是保證在併發執行的環境中各個執行緒可以有序的執行。但是這個定義是不太準確,我們給幾個示例請判斷如下程式碼是否是併發,先看如下程式碼:

void proc1()
{
    int x = 5;
    printf("%d",x);
}

void proc2()
{
    int x = 6;
    printf("%d",x);
}

  請問上面這兩個函式同時執行,存在不存在併發問題呢?其實並不存在,因為區域性變數是在棧中分配的,你用你的,我用我的,互不影響。如果是下面的程式碼:

int x = 6;

void proc1()
{
    printf("%d",x);
}

void proc2()
{
    printf("%x",x);
}

  這個也是不存在併發問題的,因為這兩個函式雖然都是用到的是同一個變數,但是,它們並沒有修改此變數,這兩個函式怎麼執行都不會互相影響,故也不存在併發問題。但是,程式碼這樣一改就不行了:

int x = 6;

void proc1()
{
    x--;
}

void proc2()
{
    ++x;
}

  因為這兩個函式執行都會修改全域性變數,它們的執行會影響結果,故存在併發問題。如果其他操作用到這個值,將會影響判斷。

臨界區

  在學習臨界區之前,先看看如下程式碼:

int x = 6;

void proc1()
{
    x--;
}

  請問這一行x--程式碼,是不是執行緒安全的?
  答案是不是,儘管我們的C程式碼是隻有一句,但是翻譯成彙編,它就不是一句了。我們假設全域性變數x經過編譯器編譯後的地址為0x12345678,那麼彙編就翻譯成如下幾句彙編:

mov eax,[0x12345678]
add eax,1
mov [0x12345678],eax

  如果任何一處彙編執行的時候被時鐘中斷進行切換執行緒,大量的執行緒執行此函式的結果是不一樣的。比如執行緒1執行到add eax,1完成被切換走了,執行緒2執行完此流程,那麼,最終這兩個執行緒執行的結果x的值為2,這就是典型的執行緒安全問題。如果我改成用INC DWORD PTR DS:[0x12345678]這個彙編來實現此函式功能,這程式碼安全嗎?
  對於單核,這個是沒問題的。但是對於多核這是有問題的。就和同時執行兩個執行緒來修改同一個變數的原因是一樣的,CPU實現肯定是讀取地址獲取數值,然後使用加法器進行加一,然後放回去。但是如何實現多核下的執行緒安全呢?
  如下彙編就解決了這個問題:

LOCK INC DWORD PTR DS:[0x12345678]

  對,前面加一個LOCK,這個也是一條彙編指令。它是一個鎖,鎖的是你要執行指令的地址,而不是彙編執行的執行。也就是說0x12345678在同一時刻只能由一個核進行訪問修改。這就解決了多核下的執行緒安全,這種操作也被稱之為原子操作,雖然原子可以再分,但是意思就是不能再分割的操作。
  Windows為了方便我們應用原子操作,也封裝好了幾個函式:InterlockedIncrementInterlockedExchangeAddInterlockedDecrementInterlockedFlushSListInterlockedExchangeInterlockedPopEntrySListInterlockedCompareExchangeInterlockedPushEntrySList。由於怎樣用函式不是我們教程講解的重點,我們研究的是怎樣實現,所以這塊地方就不贅述了。我們來看看微軟是怎麼實現原子操作加的:

; int __fastcall InterlockedIncrement(LPLONG lpAddend)
                public InterlockedIncrement
InterlockedIncrement proc near
                mov     eax, 1
                lock xadd [ecx], eax
                inc     eax
                retn
InterlockedIncrement endp

  這幾行就是實現原子操作的。由於是呼叫約定是快速呼叫,這個lpAddend引數是由ecx傳遞的。xadd指令就是實現先交換,再相加放到目的運算元,如下是白皮書描述:

Description
Exchanges the first operand (destination operand) with the second operand (source operand), then loads the sum of the two values into the destination operand. The destination operand can be a register or a memory location; the source operand is a register.

Operation
TEMP ← SRC + DEST;
SRC ← DEST;
DEST ← TEMP;

  此原子加法前面加了鎖而ecx指向的變數是多個執行緒可能訪問的,故執行緒安全。
  如果我寫了balabala一個程式碼塊,想要解決執行緒安全問題,我想使用LOCK方式解決可以嗎?比如下面這樣:

LOCK INC DWORD PTR DS:[0x12345678]
LOCK INC DWORD PTR DS:[0x12345678]
LOCK INC DWORD PTR DS:[0x12345678]
LOCK INC DWORD PTR DS:[0x12345678]
LOCK INC DWORD PTR DS:[0x12345678]

  這樣雖然每一行都保證只能一個執行緒佔有資源,但是保證這些程式碼只能由一個執行緒執行,還是不能實現的,所以不能實現執行緒安全。
  好了,鋪墊了這麼多,我們來講講臨界區是啥。一次只允許一個執行緒進入直到離開,這樣的東西就是臨界區,打比方一堆人排隊上一個單間廁所,一次只能由一個人進一個人出,如果人是執行緒,坑是變數等資源,那麼這個廁所就是所謂的臨界區。用程式碼演示一下:

DWORD dwFlag = 0;   //實現臨界區的方式就是加鎖
                    //鎖:全域性變數  進去加一 出去減一

if(dwFlag == 0)        //進入臨界區
{   
    dwFlag = 1;
    //.......
    //一堆程式碼
    //.......
    dwFlag = 0;   //離開臨界區
}

  當然這個程式碼是有問題的,不能夠實現臨界區,只是思想展示的示例。如果真正的實現臨界區,就必須用匯編,如下是實現進入臨界區的程式碼:

Lab:
    mov eax,1
    lock xadd [Flag],eax
    cmp eax,0
    jz endLab
    dec [Flag]
    //呼叫執行緒等待Sleep ……
endLab:
    ret

  其中Flag是上面的全域性變數,也就是所謂的鎖。我們再看看如何退出臨界區:

lock dec [Flag]

  為什麼加彙編加lock我就不贅述了。不過上面的實現,效能比較差,因為一旦兩個執行緒同時執行,一個執行緒正在跑著,另一個就去睡大覺了。對於臨界區,就介紹這麼多。

自旋鎖

  自旋鎖也是用來解決同步問題的,為什麼這個名字,我們首先看看微軟是如何實現自旋鎖這個東西的,故先定位到如下函式:

; void __stdcall KeAcquireSpinLockAtDpcLevel(PKSPIN_LOCK SpinLock)
                public KeAcquireSpinLockAtDpcLevel
KeAcquireSpinLockAtDpcLevel proc near

SpinLock        = dword ptr  4

                mov     ecx, [esp+SpinLock]

loc_469998:                             ; CODE XREF: KeAcquireSpinLockAtDpcLevel+14↓j
                lock bts dword ptr [ecx], 0
                jb      short loc_4699A2
                retn    4
; ---------------------------------------------------------------------------

loc_4699A2:                             ; CODE XREF: KeAcquireSpinLockAtDpcLevel+9↑j
                                        ; KeAcquireSpinLockAtDpcLevel+18↓j
                test    dword ptr [ecx], 1
                jz      short loc_469998
                pause
                jmp     short loc_4699A2
KeAcquireSpinLockAtDpcLevel endp

  lock bts dword ptr [ecx], 0這行程式碼的作用是將ECX指向資料的第0位置1,如果[ECX]原來的值為0 那麼CF = 1,否則CF = 0lock保證了只能單核處理。
  如果CF = 1,也就是說這個已經上鎖了,就跳轉到loc_4699A2這個地方,如果鎖上著,就繼續往下走,pause會讓CPU暫停一會,然後死迴圈,轉起圈,直至鎖被釋放,這就是所謂的自旋鎖。
  自旋鎖只對於多核才有意義,如果是單核反而會造成大量的效能損失。自旋鎖與臨界區、事件、互斥體一樣,都是一種同步機制,都可以讓當前執行緒處於等待狀態,區別在於自旋鎖不用切換執行緒,有關自旋鎖的知識就介紹這麼多。

本節練習

本節的答案將會在下一節的正文給出,務必把本節練習做完後看下一個講解內容。不要偷懶,實驗是學習本教程的捷徑。

  俗話說得好,光說不練假把式,如下是本節相關的練習。如果練習沒做好,就不要看下一節教程了,越到後面,不做練習的話容易夾生了,開始還明白,後來就真的一點都不明白了。本節練習不多,請保質保量的完成。

1️⃣ 自己實現一個臨界區,不得使用本篇章介紹的實現。
2️⃣ 多核情況下,實現在高併發的核心函式內部進行Hook,而不能出錯。

下一篇

  

相關文章