同步篇——內核對象

寫在前面

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

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

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


🔒 華麗的分割線 🔒


前言

  之前講過,執行緒在進入臨界區之前會調用WaitForSingleObject
或者WaitForMultipleObjects,此時如果有訊號,執行緒會從函數中退出並進入臨界區,如果沒有訊號那麼執行緒將自己掛入等待鏈表,然後將自己掛入等待網,最後切換執行緒。注意我們這裡的臨界區是指只允許一個執行緒進入直到退出的一段程式碼,不單指用EnterCriticalSectionLeaveCriticalSection而形成的臨界區。
  其他執行緒在適當的時候,調用方法修改被等待對象的SignalState為有訊號(不同的等待對象,會調用不同的函數),並將等待該對象的其他執行緒從等待鏈表中摘掉,這樣,當前執行緒便會在WaitForSingleObject或者WaitForMultipleObjects恢復執行(在哪切換在哪開始執行),如果符合喚醒條件,此時會修改SignalState的值,並將自己從等待網上摘下來,此時的執行緒才是真正的喚醒。
  下面我將介紹不同的可等待內核對象之間的不同之處和實現,但是具體細節將會在總結與提升進行。在講解之前我們把關鍵的結構體放到下面:

kd> dt _DISPATCHER_HEADER
ntdll!_DISPATCHER_HEADER
   +0x000 Type             : UChar
   +0x001 Absolute         : UChar
   +0x002 Size             : UChar
   +0x003 Inserted         : UChar
   +0x004 SignalState      : Int4B
   +0x008 WaitListHead     : _LIST_ENTRY

事件

  首先我們看看事件的內核結構體:

kd> dt _KEVENT
ntdll!_KEVENT
   +0x000 Header           : _DISPATCHER_HEADER

  可以看出事件這個內核對象十分簡潔,就一個內嵌的必要的子結構體,沒有啥雜七雜八的東西。我們通常用CreateEvent函數來進行創建使用這個內核對象,下面我們來講解與同步相關的參數,如下是函數原型:

HANDLE WINAPI CreateEventW(
    LPSECURITY_ATTRIBUTES lpEventAttributes,
    BOOL bManualReset,
    BOOL bInitialState,
    LPCWSTR lpName
    );

  我們重點講解中間兩個參數。

bManualReset

  該值類型為布爾型,如果為True,則為通知類型對象;反之則為普通的事件同步對象。
  這個參數影響_DISPATCHER_HEADERType值,如果為通知類型對象,它的值為0,否則為1。我們可以做如下實驗進行驗證:

#include "stdafx.h"
#include <windows.h>
#include <stdlib.h>

HANDLE hEvent;

DWORD WINAPI ThreadProc1(LPVOID param)
{
   WaitForSingleObject(hEvent,INFINITE);
   puts("等待執行緒1執行!");
   return 0;
}

DWORD WINAPI ThreadProc2(LPVOID param)
{
   WaitForSingleObject(hEvent,INFINITE);
   puts("等待執行緒2執行!");
   return 0;
}

int main(int argc, char* argv[])
{
   hEvent = CreateEvent(NULL,TRUE,FALSE,NULL);
   CloseHandle(CreateThread(NULL,NULL,(LPTHREAD_START_ROUTINE)ThreadProc1,NULL,NULL,NULL));
   CloseHandle(CreateThread(NULL,NULL,(LPTHREAD_START_ROUTINE)ThreadProc2,NULL,NULL,NULL));
   SetEvent(hEvent);
   system("pause");
   CloseHandle(hEvent);
   return 0;
}

  如上程式碼就是通知型對象,我們看一看效果:

  可以看到,這兩個執行緒都執行了,如果我把第二個參數改為FALSE,我們再看看效果:

  從實驗結果我們可以看出,這裡僅執行了一個執行緒,另一個執行緒仍處於等待狀態,為什麼出現這樣的結果還是逆向WaitForSingleObject才能明白。

bInitialState

  這個就是設置_DISPATCHER_HEADERSignalState初始值的,TRUE就是1,反之就是0。我們將上面我們做實驗的程式碼的第三個參數改為TRUE,並注釋掉SetEvent函數,實驗效果如下:

  關於事件就介紹這麼多,其餘的詳細細節將會在總結與提升進行介紹。

訊號量

  有了事件這一個可等待對象,為什麼要有訊號量這東西。訊號量又是什麼。

訊號量(Semaphore),有時被稱為訊號燈,是在多執行緒環境下使用的一種設施,是可以用來保證兩個或多個關鍵程式碼段不被並發調用。在進入一個關鍵程式碼段之前,執行緒必須獲取一個訊號量;一旦該關鍵程式碼段完成了,那麼該執行緒必須釋放訊號量。

  這個雖然聽起來說了訊號量是啥,但又啥也沒說。我們將通過示例來進行講解。
  我們之前使用事件這個東西,如果有訊號,如果是用於同步的類型,設置訊號只能讓1個執行緒通過WaitForSingleObjectSetEvent組成的程式碼臨界區;如果是通知類型,就讓全部的執行緒通過,示意圖如下:

  但是有種情況,我讓其有訊號的時候,假設有5個執行緒,但我讓3個執行緒執行,這個是事件方便實現的,於是乎,訊號量應運而生,如下是其示意圖:

  如果做項目比較多的話,就會遇到生成者執行緒和消費者執行緒問題,如下圖所示:

  如果是上面第一個情況,這兩種執行緒各一個執行緒,運用事件就可以很好的解決問題。但是對於第二種情況,這就不太適用了。如果我讓這代表全局變數的三個綠塊都加1,把它們都激活或者一個一個激活總是不太方便的,這個問題通過訊號量就能輕鬆解決。我們來看看創建訊號量的函數原型:

HANDLE WINAPI CreateSemaphoreW(
    LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
    LONG lInitialCount,
    LONG lMaximumCount,
    LPCWSTR lpName
    );

  然後看看訊號量的結構體長啥樣子:

kd> dt _KSEMAPHORE
ntdll!_KSEMAPHORE
   +0x000 Header           : _DISPATCHER_HEADER
   +0x010 Limit            : Int4B

lInitialCount

  這個參數會影響_DISPATCHER_HEADERSignalState的值。它不會像事件只設置0或者1,它可以設置更大的數,這個就是實現指定數目執行緒執行的關鍵所在。

lMaximumCount

  這個參數會影響KSEMAPHORE結構體中的Limit這個成員,是訊號量對象的最大計數。

ReleaseSemaphore 淺析

  既然有創建訊號量,就有釋放訊號量函數,讓的調用流程如下:

graph TD
ReleaseSemaphore –> NtReleaseSemaphore –> KeReleaseSemaphore

  有關訊號量的其他具體細節將會在同步與提升進行講解。

互斥體

  前面有事件、訊號量,為什麼還要有互斥體這個東西。肯定一個東西的出現必須解決一些問題。互斥體MUTANT與事件EVENT和訊號量SEMAPHORE一樣,都可以用來進行執行緒的同步控制。但是,這幾個對象都是內核對象,這就意味著,通過這些對象可以進行跨進程的執行緒同步控制。假設有A進程中的X執行緒和B進程中的Y執行緒,它們都在等待內核對象Z。如果B進程的Y執行緒還沒有來得及調用修改SignalState的函數,那麼等待對象Z將被遺棄,這也就以為者X執行緒將永遠等下去。
  互斥體還解決臨界區沖入的問題,如下是一段程式碼:

WaitForSingleObject(A)
.....
WaitForMultipleObjects(A,B,C)
.....

  其中ABC都是等待對象。開始程式碼我們等待A,然後又繼續等待ABC,如果它們都有一次訊號,那麼執行緒就會停到WaitForMultipleObjects不動,這就是所謂的死鎖。
  介紹互斥體之前,我們看看其內部結構:

kd> dt _KMUTANT
nt!_KMUTANT
   +0x000 Header           : _DISPATCHER_HEADER
   +0x010 MutantListEntry  : _LIST_ENTRY
   +0x018 OwnerThread      : Ptr32 _KTHREAD
   +0x01c Abandoned        : UChar
   +0x01d ApcDisable       : UChar

  OwnerThread指向的是正在擁有互斥體的執行緒結構體。Abandoned指示是否已經被棄用的標誌。ApcDisable指示是否禁用內核APC
  那麼,互斥體是如何解決等待對象被遺棄問題呢?原因就存在MutantListEntry這個起作用,這個也是用來串糖葫蘆的。在執行緒結構體的0x10偏移處的MutantListHead成員把擁有互斥體串串。當程式卸載時會調用MmUnloadSystemImage,最終會調用KeReleaseMutant這個函數來釋放互斥體,而這個函數就會用到Abandon這個值。具體細節將會在總結與提升進行分析。
  對於應用層的內核結構Mutant,內核有一個和這個結構一模一樣的Mutex。但是它們的區別就是ApcDisable值不一樣,Mutex的值為1,而Mutant的值為0,可以通過它的初始化程式碼可以看出。其餘的細節將會在下一篇進行講解。

下一篇

  同步篇——總結與提升