內核同步對象(上)

Windows NT提供了五種內核同步對象(Kernel Dispatcher Object),你可以用它們控制非任意線程(普通線程)的流程。表4-1列出了這些內核同步對象的類型及它們的用途。在任何時刻,任何對象都處於兩種狀態中的一種:信號態或非信號態。有時,當代碼運行在某個線程的上下文中時,它可以阻塞這個線程的執行,調用KeWaitForSingleObjectKeWaitForMultipleObjects函數可以使代碼(以及背景線程)在一個或多個同步對象上等待,等待它們進入信號態。內核爲初始化和控制這些對象的狀態提供了例程。

表4-1. 內核同步對象

對象數據類型描述
Event(事件) KEVENT 阻塞一個線程直到其它線程檢測到某事件發生
Semaphore(信號燈) KSEMAPHORE 與事件對象相似,但可以滿足任意數量的等待
Mutex(互斥) KMUTEX 執行到關鍵代碼段時,禁止其它線程執行該代碼段
Timer(定時器) KTIMER 推遲線程執行一段時期
Thread(線程) KTHREAD 阻塞一個線程直到另一個線程結束

在下幾段中,我將描述如何使用內核同步對象。我將從何時可以調用等待原語阻塞線程開始講起,然後討論用於每種對象的支持例程。最後討論與線程警惕(thread alert)和提交APC(異步過程調用)相關的概念。

何時阻塞和怎樣阻塞一個線程

爲了理解WDM驅動程序何時以及如何利用內核同步對象阻塞一個線程,你必須先對線程有一些基本瞭解。通常,如果在線程執行時發生了軟件或硬件中斷,那麼在內核處理中斷期間,該線程仍然是“當前”線程。而內核模式代碼執行時所在的上下文環境就是指這個“當前”線程的上下文。爲了響應各種中斷,Windows NT調度器可能會切換線程,這樣,另一個線程將成爲新的“當前”線程。

術語“任意線程上下文(arbitrary thread context)”和“非任意線程上下文(nonarbitrary thread context)”用於精確描述驅動程序例程執行時所處於的上下文種類。如果我們知道程序正處於初始化I/O請求線程的上下文中,則該上下文不是任意上下文。然而,在大部分時間裏,WDM驅動程序無法知道這個事實,因爲控制哪個線程應該激活的機會通常都是在中斷髮生時。當應用程序發出I/O請求時,將產生一個從用戶模式到內核模式的轉換,而創建併發送該IRP的I/O管理器例程將繼續運行在非任意線程的上下文中。我們用術語“最高級驅動程序”來描述第一個收到該IRP的驅動程序。

通常,只有給定設備的最高級驅動程序才能確切地知道它執行在一個非任意線程的上下文中。這是因爲驅動程序派遣例程通常把請求放入隊列後立即返回調用者。之後通過回調函數,請求被提出隊列並下傳到低級驅動程序。一旦派遣例程掛起某個請求,所有對該請求的後期處理必須發生在任意線程上下文中。

解釋完線程上下文後,我們可以陳訴出關於線程阻塞的簡單規則:

當我們處理某個請求時,僅能阻塞產生該請求的線程。

通常,僅有設備的最高級驅動程序才能應用這個規則。但有一個重要的例外,IRP_MN_START_DEVICE請求,所有驅動程序都以同步方式處理這個請求。即驅動程序不排隊或掛起該類請求。當你收到這種請求時,你可以直接從堆棧中找到請求的發起者。正如我在第六章中講到的,處理這種請求時你必須阻塞那個線程。

下面規則表明在提升的IRQL級上不可能發生線程切換:

執行在高於或等於DISPATCH_LEVEL級上的代碼不能阻塞線程。

這個規則表明你只能在DriverEntry函數、AddDevice函數,或驅動程序的派遣函數中阻塞當前線程。因爲這些函數都執行在PASSIVE_LEVEL級上。沒有必要在DriverEntry或AddDevice函數中阻塞當前線程,因爲這些函數的工作僅僅是初始化一些數據結構。

在單同步對象上等待

你可以按下面方法調用KeWaitForSingleObject函數:

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
LARGE_INTEGER timeout;
NTSTATUS status = KeWaitForSingleObject(object, WaitReason, WaitMode, Alertable, &timeout);

ASSERT語句指出必須在低於或等於DISPATCH_LEVEL級上調用該例程。

在這個調用中,object指向你要等待的對象。注意該參數的類型是PVOID,它應該指向一個表4-1中列出的同步對象。該對象必須在非分頁內存中,例如,在設備擴展中或其它從非分頁內存池中分配的數據區。在大部分情況下,執行堆棧可以被認爲是非分頁的。

WaitReason是一個純粹建議性的值,它是KWAIT_REASON枚舉類型。實際上,除非你指定了WrQueue參數,否則任何內核代碼都不關心此值。線程阻塞的原因被保存到一個不透明的數據結構中,如果你瞭解這個數據結構,那麼在調試某種死鎖時,你也許會從這個原因代碼中獲得一些線索。通常,驅動程序應把該參數指定爲Executive,代表無原因。

WaitMode是MODE枚舉類型,該枚舉類型僅有兩個值:KernelModeUserMode

Alertable是一個布爾類型的值。它不同於WaitReason,這個參數以另一種方式影響系統行爲,它決定等待是否可以提前終止以提交一個APC。如果等待發生在用戶模式中,那麼內存管理器就可以把線程的內核模式堆棧換出。如果驅動程序以自動變量(在堆棧中)形式創建事件對象,並且某個線程又在提升的IRQL級上調用了KeSetEvent,而此時該事件對象剛好又被換出內存,結果將產生一個bug check。所以我們應該總把alertable參數指定爲FALSE,即在內核模式中等待。

最後一個參數&timeout是一個64位超時值的地址,單位爲100納秒。正數的超時表示一個從1601年1月1日起的絕對時間。調用KeQuerySystemTime函數可以獲得當前系統時間。負數代表相對於當前時間的時間間隔。如果你指定了絕對超時,那麼系統時鐘的改變也將影響到你的超時時間。如果系統時間越過你指定的絕對時間,那麼永遠都不會超時。相反,如果你指定相對超時,那麼你經過的超時時間將不受系統時鐘改變的影響。

指定0超時將使KeWaitForSingleObject函數立即返回,返回的狀態代碼指出對象是否處於信號態。如果你的代碼執行在DISPATCH_LEVEL級上,則必須指定0超時,因爲在這個IRQL上不允許阻塞。每個內核同步對象都提供一組KeReadStateXxx服務函數,使用這些函數可以直接獲得對象的狀態。然而,取對象狀態與0超時等待不完全等價:當KeWaitForSingleObject發現等待被滿足後,它執行特殊對象要求的附加動作。相比之下,取對象狀態不執行任何附加動作,即使對象已經處於信號態。

超時參數也可以指定爲NULL指針,這代表無限期等待。

該函數的返回值指出幾種可能的結果。STATUS_SUCCESS結果是你所希望的,表示等待被滿足。即在你調用KeWaitForSingleObject時,對象或者已經進入信號態,或者後來進入信號態。如果等待以第二種情況滿足,則有必要在同步對象上執行附加動作。當然,這個附加動作還要參考對象的類型,我將在後面討論具體對象類型時再解釋這一點。(例如,一個同步類型的事件在你的等待滿足後需要重置該事件)

返回值STATUS_TIMEOUT指出在指定的超時期限內對象未進入信號態。如果指定0超時,則函數將立即返回。返回代碼爲STATUS_TIMEOUT,代表對象處於非信號態,返回代碼爲STATUS_SUCCESS,代表對象處於信號態。如果指定NULL超時,則不可能有返回值。

其它兩個返回值STATUS_ALERTED和STATUS_USER_APC表示等待提前終止,對象未進入信號態。原因是線程接收到一個警惕(alert)或一個用戶模式的APC。

在多同步對象上等待

KeWaitForMultipleObjects函數用於同時等待一個或多個同步對象。該函數調用方式如下:

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
LARGE_INTEGER timeout;
NTSTATUS status = KeWaitForMultipleObjects(count,
        objects,
        WaitType,
        WaitReason,
        WaitMode,
        Alertable,
        &timeout,
        waitblocks);

在這裏,objects指向一個指針數組,每個數組元素指向一個同步對象,count是數組中指針的個數。count必須小於或等於MAXIMUM_WAIT_OBJECTS值(當前爲64)。這個數組和它所指向的所有對象都必須在非分頁內存中。WaitType是枚舉類型,其值可以爲WaitAllWaitAny,它指出你是等到所有對象都進入信號態,還是只要有一個對象進入信號態就可以。

waitblocks參數指向一個KWAIT_BLOCK結構數組,內核用這個結構數組管理等待操作。你不需要初始化這些結構,內核僅需要知道這個結構數組在哪裏,內核用它來記錄每個對象在等待中的狀態。如果你僅需要等待小數量的對象(不超過THREAD_WAIT_OBJECTS,該值當前爲3),你可以把該參數指定爲NULL。如果該參數爲NULL,KeWaitForMultipleObjects將使用線程對象中預分配的等待塊數組。如果你等待的對象數超過THREAD_WAIT_OBJECTS,你必須提供一塊長度至少爲count * sizeof(KWAIT_BLOCK)的非分頁內存。

其餘參數與KeWaitForSingleObject中的對應參數作用相同,而且大部分返回碼也有相同的含義。

如果你指定了WaitAll,則返回值STATUS_SUCCESS表示等待的所有對象都進入了信號態。如果你指定了WaitAny,則返回值在數值上等於進入信號態的對象在objects數組中的索引。如果碰巧有多個對象進入了信號態,則該值僅代表其中的一個,可能是第一個也可能是其它。你可以認爲該值等於STATUS_WAIT_0加上數組索引。你可以先用NT_SUCCESS測試返回碼,然後再從其中提取數組索引:

NTSTATUS status = KeWaitForMultipleObjects(...);
if (NT_SUCCESS(status))
{
  ULONG iSignalled = (ULONG) status - (ULONG) STATUS_WAIT_0;
  ...
}

如果KeWaitForMultipleObjects返回成功代碼,它也將執行等待被滿足的那個對象的附加動作。如果多個對象同時進入信號態而你指定的WaitType參數爲WaitAny,那麼該函數僅執行返回值指定對象的附加動作。

內核事件

表4-2列出了用於處理內核事件的服務函數。爲了初始化一個事件對象,我們首先應該爲其分配非分頁存儲,然後調用KeInitializeEvent

ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL);
KeInitializeEvent(event, EventType, initialstate);

event是事件對象的地址。EventType是一個枚舉值,可以爲NotificationEventSynchronizationEvent。通知事件(notification event)有這樣的特性,當它進入信號態後,它將一直處於信號態直到你明確地把它重置爲非信號態。此外,當通知事件進入信號態後,所有在該事件上等待的線程都被釋放。這與用戶模式中的手動重置事件相似。而對於同步事件(synchronization event),只要有一個線程被釋放,該事件就被重置爲非信號態。這又與用戶模式中的自動重置事件相同。而KeWaitXxx函數在同步事件對象上執行的附加動作就是把它重置爲非信號態。最後的參數initialstate是布爾量,爲TRUE表示事件的初始狀態爲信號態,爲FALSE表示事件的初始狀態爲非信號態。

表4-2. 用於內核事件對象的服務函數

服務函數描述
KeClearEvent 把事件設置爲非信號態,不報告以前的狀態
KeInitializeEvent 初始化事件對象
KeReadStateEvent 取事件的當前狀態
KeResetEvent 把事件設置爲非信號態,返回以前的狀態
KeSetEvent 把事件設置爲信號態,返回以前的狀態
注意
在這些關於同步原語的段中,我還要再談論一下DDK文檔中對IRQL的使用限定。在當前發行的Windows 2000中,DDK有時比OS實際要求的有更多的限制。例如,KeClearEvent可以在任何IRQL上調用,但DDK卻要求調用者必須在低於或等於DISPATCH_LEVEL級上調用。KeInitializeEvent也可以在任何IRQL上調用,但DDK要求僅在PASSIVE_LEVEL級上調用該函數。然而,你應該尊重DDK中的描述,也許某一天Microsoft會利用文檔中的這些限制。

調用KeSetEvent函數可以把事件置爲信號態:

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
LONG wassignalled = KeSetEvent(event, boost, wait);

在上面代碼中,ASSERT語句強制你必須在低於或等於DISPATCH_LEVEL級上調用該函數。event參數指向一個事件對象,boost值用於提升等待線程的優先級。wait參數的解釋見文字框“KeSetEvent的第三個參數”,WDM驅動程序幾乎從不把wait參數指定爲TRUE。如果該事件已經處於信號態,則該函數返回非0值。如果該事件處於非信號態,則該函數返回0。

多任務調度器需要人爲地提升等待I/O操作或同步對象的線程的優先級,以避免餓死長時間等待的線程。這是因爲被阻塞的線程往往是放棄自己的時間片並且不再要求獲得CPU,但只要這些線程獲得了比其它線程更高的優先級,或者其它同一優先級的線程用完了自己的時間片,它們就可以恢復執行。注意,正處於自己時間片中的線程不能被阻塞。

用於提升阻塞線程優先級的boost值不太好選擇。一個較好的笨方法是指定IO_NO_INCREMENT值,當然,如果你有更好的值,可以不用這個值。如果事件喚醒的是一個處理時間敏感數據流的線程(如聲卡驅動程序),那麼應該使用適合那種設備的boost值(如IO_SOUND_INCREMENT)。重要的是,不要爲一個愚蠢的理由去提高等待者的優先級。例如,如果你要同步處理一個IRP_MJ_PNP請求,那麼在你要停下來等待低級驅動程序處理完該IRP時,你的完成例程應調用KeSetEvent。由於PnP請求對於處理器沒有特殊要求並且也不經常發生,所以即使是聲卡驅動程序也也應該把boost參數指定爲IO_NO_INCREMENT。

調用KeReadStateEvent函數(在任何IRQL上)可以測試事件的當前狀態:

LONG signalled = KeReadStateEvent(event);

返回值不爲0代表事件處於信號態,爲0代表事件處於非信號態。

注意
Windows 98不支持KeReadStateEvent函數,但支持上面描述的其它KeReadStateXxx函數。爲了獲得事件的狀態,我們必須使用Windows 98的其它同步原語。

調用KeResetEvent函數(在低於或等於DISPATCH_LEVEL級)可以立即獲得事件對象的當前狀態,但該函數會把事件對象重置爲非信號狀態。

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
LONG signalled = KeResetEvent(event);

如果你對事件的上一個狀態不感興趣,可以調用KeClearEvent函數,象下面這樣:

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
KeClearEvent(event);

KeClearEvent函數執行得更快,因爲它在讀取事件的當前狀態後不設置事件爲非信號態。

內核信號燈

內核模式信號燈是一個有同步語義的整數計數器。信號燈計數器爲正值時代表信號態,爲0時代表非信號態。計數器不能爲負值。釋放信號燈將使信號燈計數器增1,在一個信號燈上等待將使該信號燈計數器減1。如果計數器值被減爲0,則信號燈進入非信號態,之後其它調用KeWaitXxx函數的線程將被阻塞。注意如果等待線程的個數超過了計數器的值,那麼並不是所有等待的線程都可以恢復運行。

內核提供了三個服務函數來控制信號燈對象的狀態。(見表4-3) 信號燈對象應該在PASSIVE_LEVEL級上初始化:

ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL);
KeInitializeSemaphore(semaphore, count, limit);

在這個調用中,semaphore參數指向一個在非分頁內存中的KSEMAPHORE對象。count是信號燈計數器的初始值,limit是計數器能達到的最大值,它必須與信號燈計數器的初始值相同。

表4-3. 內核信號燈對象服務函數

服務函數描述
KeInitializeSemaphore 初始化信號燈對象
KeReadStateSemaphore 取信號燈當前狀態
KeReleaseSemaphore 設置信號燈對象爲信號態

如果你創建信號燈時指定limit參數爲1,則該對象與僅有一個線程的互斥對象類似。但內核互斥對象有一些信號燈沒有的特徵,這些特徵用於防止死鎖。所以,沒有必要創建limit爲1的信號燈。

如果你以一個大於1的limit值創建信號燈, 則該信號燈允許多個線程同時訪問某些資源。在隊列理論中我們會發現同樣的原理,單隊列可以被多個服務程序使用。多個服務程序使用一個隊列要比每個服務程序都有各自的隊列更合理。這兩種形式的平均等待時間是相同的,但前者的等待次數更少。使用信號燈,你可以把一組軟件或硬件服務程序按照隊列原理組織起來。

信號燈的所有者可以調用KeReleaseSemaphore函數釋放信號燈:

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
LONG wassignalled = KeReleaseSemaphore(semaphore, boost, delta, wait);

這裏出現了一個delta參數,它必須爲正數,該函數把delta值加到semaphore指向的信號燈計數器上,這將把信號燈帶入信號態,並使等待線程釋放。通常,該參數應該指定爲1,代表有一個所有者釋放了它的權利。boostwait參數與在KeSetEvent函數中的作用相同。返回值爲0代表信號燈的前一個狀態是非信號態,非0代表信號燈的前一個狀態爲信號態。

KeReleaseSemaphore不允許你把計數器的值增加到超過limit指定的值。如果你這樣做,該函數根本就不調整計數器的值,它將產生一個代碼爲STATUS_SEMAPHORE_LIMIT_EXCEEDED的異常。除非系統中存在捕獲該異常的處理程序,否則將導致一個bug check。

下面調用讀取信號燈的當前狀態:

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
LONG signalled = KeReadStateSemaphore(semaphore);

非0返回值表示信號燈處於信號態,0返回值代表信號燈爲非信號態。不要把該返回值假定爲計數器的當前值。

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