同步篇——內核對象

寫在前面

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

你如果是從中間插過來看的,請仔細閱讀 羽夏看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,可以通過它的初始化代碼可以看出。其餘的細節將會在下一篇進行講解。

下一篇

  同步篇——總結與提升