內核同步對象(上)

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函數可以獲得當前系統時間。負數代表相對於當前時間的時間間隔。如果你指定了絕對超時,那麼系統時鐘的改變也將影響到你的超時時間。如果系統時間越過你指定的絕對時間,那麼永遠都不會超時。相反,如果你指定相對超時,那麼你經過的超時時間將不受系統時鐘改變的影響。

爲什麼是1601年1月1日

許多年以前,當我第一次學習Win32 API時,我曾迷惑爲什麼選擇1601年1月1日作爲Windows NT的時間起點。在我寫了一組時間轉換函數後我明白了這個問題的原因。每個人都知道可被4整除的年份是閏年。許多人也知道世紀年(如1900年)應例外,雖然這些年份都能被4整除但它們不是閏年。少數人還知道能被400整除的年份(如1600和2000)是例外中的例外,它們也是閏年。而1601年1月1日正好是一個400年週期的開始。如果把它作爲時間信息的起點,那麼把NT時間信息轉換爲常規日期表達(或相反)就不用做任何跳躍操作。

指定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() &lt;= 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() &lt;= 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。

KeSetEvent的第三個參數

wait參數的目的是允許在內部快速地把控制從一個線程傳遞到另一個線程。除了設備驅動程序之外,大部分系統部件都可以創建雙事件對象。例如,客戶線程和服務器線程使用雙事件對象來界定它們的通信。當服務器線程需要喚醒對應的客戶線程時,它首先調用KeSetEvent函數,並指定wait參數爲TRUE,然後立即調用KeWaitXxx函數使自己進入睡眠狀態。由於這兩個操作都以原子方式完成,所以在控制交接時沒有其它線程被喚醒。

DDK總是稍稍地描述一些內部細節,但我發現有些描述另人迷惑。我將以另一種方式解釋這些內部細節,看過這些細節後你就會明白爲什麼我們總指定這個參數爲FALSE。在內部,內核使用一個“同步數據庫鎖(dispatcher database lock)”來保護線程的阻塞、喚醒,和調度操作。KeSetEvent函數需要獲取這個鎖,KeWaitXxx函數也是這樣。如果你把這個參數指定爲TRUE,則KeSetEvent函數將設置一個標誌以便KeWaitXxx函數知道你使用了TRUE參數,然後它返回,並且不釋放這個鎖。當你後來(應該立即調用,因爲你此時正運行在一個比任何硬件設備都高的IRQL上,並且你佔有着一個被極其頻繁爭奪的自旋鎖)調用KeWaitXxx函數時,它不必再獲取這個鎖。產生的效果就是你喚醒了等待的線程並同時把自己置入睡眠狀態,而不給其它線程任何運行的機會。

你應該明白,以wait參數爲TRUE調用KeSetEvent的函數必須存在於非分頁內存中,因爲它在某段時間內執行在提升的IRQL上。很難想象一個普通設備驅動程序會需要使用這種機制,因爲驅動程序決不會比內核更瞭解線程的調度。底線是:對該參數總使用FALSE。實際上,Microsoft暴露該參數給我們的原因仍不十分清楚。

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

LONG signalled = KeReadStateEvent(event);

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

注意

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

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

ASSERT(KeGetCurrentIrql() &lt;= DISPATCH_LEVEL); LONG signalled = KeResetEvent(event);

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

ASSERT(KeGetCurrentIrql() &lt;= 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() &lt;= 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() &lt;= DISPATCH_LEVEL); LONG signalled = KeReadStateSemaphore(semaphore);

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

內核同步對象(下)

內核互斥對象

互斥(mutex)就是互相排斥(mutual exclusion)的簡寫。內核互斥對象爲多個競爭線程串行化訪問共享資源提供了一種方法(不一定是最好的方法)。如果互斥對象不被某線程所擁有,則它是信號態,反之則是非信號態。當線程爲了獲得互斥對象的控制權而調用KeWaitXxx例程時,內核同時也做了一些工作以幫助避免可能的死鎖。同樣,互斥對象也需要與KeWaitForSingleObject類似的附加動作。內核可以確保線程不被換出,並且阻止所有APC的提交,內核專用APC(如IoCompleteRequest用以完成I/O請求的APC)除外。

通常我們應該使用executive部件輸出的快速互斥對象而不是內核互斥對象。這兩者的主要不同是,內核互斥可以被遞歸獲取,而executive快速互斥則不能。即內核互斥的所有者可以調用KeWaitXxx並指定所擁有的互斥對象從而使等待立即被滿足。如果一個線程真的這樣做,它必須也要以同樣的次數釋放該互斥對象,否則該互斥對象不被認爲是空閒的。

如果你需要長時間串行化訪問一個對象,你應該首先考慮使用互斥(而不是依賴提升的IRQL和自旋鎖)。利用互斥對象控制資源的訪問,可以使其它線程分佈到多處理器平臺上的其它CPU中運行,還允許導致頁故障的代碼仍能鎖定資源而不被其它線程訪問。表4-4列出了互斥對象的服務函數。

表4-4. 互斥對象服務函數

服務函數
描述

KeInitializeMutex
初始化互斥對象

KeReadStateMutex
取互斥對象的當前狀態

KeReleaseMutex
設置互斥對象爲信號態

爲了創建一個互斥對象,你需要爲KMUTEX對象保留一塊非分頁內存,然後象下面這樣初始化:

ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); KeInitializeMutex(mutex, level);

mutex是KMUTEX對象的地址,level參數最初是用於輔助避免多互斥對象帶來的死鎖。但現在,內核忽略level參數。

互斥對象的初始狀態爲信號態,即未被任何線程擁有。KeWaitXxx調用將使調用者接管互斥對象的控制並使其進入非信號態。

利用下面函數可以獲取互斥對象的當前狀態:

ASSERT(KeGetCurrentIrql() &lt;= DISPATCH_LEVEL); LONG signalled = KeReadStateMutex(mutex);

返回值0表示互斥對象已被佔用,非0表示未被佔用。

下面函數可以使所有者放棄其佔有的互斥對象並使其進入信號態:

ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); LONG wassignalled = KeReleaseMutex(mutex, wait);

wait參數與KeSetEvent函數中的含義相同。該函數返回值總是0,表示該互斥對象曾被佔用過,如果不是這種情況(所有者釋放的不是它自己的對象),KeReleaseMutex將產生bug check。

出於完整性的考慮,我想提一下KeWaitForMutexObject函數,它是DDK中的宏(見WDM.H)。其定義如下:

#define KeWaitForMutexObject KeWaitForSingleObject

內核定時器

內核還提供了一種定時器對象,該對象可以在指定的絕對時間或間隔時間後自動從非信號態變爲信號態。它還可以週期性地進入信號態。我們可以用它來安排一個定期執行的DPC回調函數。表4-5列出了用於定時器對象的服務函數。

表4-5. 內核定時器對象的服務函數

服務函數
描述

KeCancelTimer
取消一個活動的定時器

KeInitializeTimer
初始化一次性的通知定時器

KeInitializeTimerEx
初始化一次性的或重複通知的或同步的定時器

KeReadStateTimer
獲取定時器的當前狀態

KeSetTimer
爲通知定時器設定時間

KeSetTimerEx
爲定時器設定時間和其它屬性

通知定時器用起來象事件

在這一段中,我們將創建一個通知定時器對象並等到它達到預定時間。首先,我們在非分頁內存中分配一個KTIMER對象。然後,我們在低於或等於DISPATCH_LEVEL級上初始化這個定時器對象:

PKTIMER timer; // someone gives you this ASSERT(KeGetCurrentIrql() &lt;= DISPATCH_LEVEL); KeInitializeTimer(timer);

在此,定時器處於非信號狀態,它還沒有開始倒計時,在這樣的定時器上等待的線程永遠得不到喚醒。爲了啓動定時器倒計時,我們調用KeSetTimer函數:

ASSERT(KeGetCurrentIrql() &lt;= DISPATCH_LEVEL); LARGE_INTEGER duetime; BOOLEAN wascounting = KeSetTimer(timer, duetime, NULL);

duetime是一個64位的時間值,單位爲100納秒。如果該值爲正,則表示一個從1601年1月1日算起的絕對時間。如果該值爲負,則它是相對於當前時間的一段時間間隔。

返回值如果爲TRUE,則表明定時器已經啓動。(在這種情況下,如果我們再調用KeSetTimer函數,則定時器放棄原來的時間重新開始倒計時)

下面語句讀取定時器的當前狀態:

ASSERT(KeGetCurrentIrql() &lt;= DISPATCH_LEVEL); BOOLEAN counting = KeReadStateTimer(timer);

KeInitializeTimer和KeSetTimer實際上是舊的服務函數,它們已經被新函數取代。我們可以用下面調用初始化定時器:

ASSERT(KeGetCurrentIqrl() &lt;= DISPATCH_LEVEL); KeInitializeTimerEx(timer, NotificationTimer);

定時器設置函數也有擴展版本,KeSetTimerEx

ASSERT(KeGetCurrentIrql() &lt;= DISPATCH_LEVEL); LARGE_INTEGER duetime; BOOLEAN wascounting = KeSetTimerEx(timer, duetime, 0, NULL);

我將在本章後面解釋該函數擴展版本的新參數。

即使定時器開始倒計時,它仍處於非信號態,直到到達指定的時間。在那個時刻,該定時器對象自動變爲信號態,所有等待的線程都被釋放。

在這一小節中,我們想讓定時器去觸發一個DPC例程。使用這種方法,不論你的線程有什麼優先級都會響應超時事件。(因爲線程只能在PASSIVE_LEVEL級上等待,而定時器到時間後,獲取CPU控制權的線程是隨機的。然而,DPC例程執行在提升的IRQL級上,它可以有效地搶先所有線程)

我們用同樣的方法初始化定時器對象。另外我們還再初始化一個KDPC對象,該對象應該在非分頁內存中分配。如下面代碼:

PKDPC dpc; // points to KDPC you've allocated ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); KeInitializeTimer(timer); KeInitializeDpc(dpc, DpcRoutine, context);

用KeInitializeTimer或KeInitializeTimerEx初始化定時器對象。DpcRoutine是一個DPC(推遲過程調用)例程的地址,這個例程必須存在於非分頁內存中。context參數是一個任意的32位值(類型爲PVOID),它將作爲參數傳遞給DPC例程。dpc參數是一個指向KDPC對象的指針(該對象必須在非分頁內存中。例如,在你的設備擴展中)。

當開始啓動定時器的倒計時,我們把DPC對象指定爲KeSetTimer或KeSetTimerEx函數的一個參數:

ASSERT(KeGetCurrentIrql() &lt;= DISPATCH_LEVEL); LARGE_INTEGER duetime; BOOLEAN wascounting = KeSetTimer(timer, duetime, dpc);

這個KeSetTimer調用與上一段中的調用的不同之處是,我們在最後一個參數中指定了一個DPC對象地址。當定時器時間到時,系統將把該DPC排入隊列,並且只要條件允許就立即執行它。在最差的情況下,它也與在PASSIVE_LEVEL級上喚醒線程一樣快。DPC函數的定義如下:

VOID DpcRoutine(PKDPC dpc, PVOID context, PVOID junk1, PVOID junk2) { ... }

即使你爲KeSetTimer或KeSetTimerEx提供了DPC參數,你仍可以調用KeWaitXxx函數使自己在PASSIVE_LEVEL級上等待。在單CPU的系統上,DPC將在等待完成前執行,因爲它執行在更高的IRQL上。

 

同步定時器

 

與事件對象類似,定時器對象也有兩種形式:通知方式和同步方式。通知定時器允許有任意數量的等待線程。同步定時器正相反,它只允許有一個等待線程。一旦有線程在這種定時器上等待,定時器就自動進入非信號態。爲了創建同步定時器,你必須使用擴展形式的初始化函數:

ASSERT(KeGetCurrentIrql() &lt;= DISPATCH_LEVEL); KeInitializeTimerEx(timer, SynchronizationTimer);

SynchronizationTimer是枚舉類型TIMER_TYPE的一個枚舉值。另一個枚舉值是NotificationTimer

如果你在同步定時器上使用DPC例程,可以把排隊DPC看成是定時器到期時發生的額外事情。即定時器到期時,系統把定時器置成信號態,並把DPC對象插入DPC隊列。定時器進入信號態將使阻塞的線程得以釋放。

週期性定時器

到現在爲止,我們討論過的定時器僅能定時一次。通過使用定時器的擴展設置函數,你可以請求一個週期性的超時:

ASSERT(KeGetCurrentIrql() &lt;= DISPATCH_LEVEL); LARGE_INTEGER duetime; BOOLEAN wascounting = KeSetTimerEx(timer, duetime, period, dpc);

這裏,period是週期超時值,單位爲毫秒(ms),dpc是一個可選的指向KDPC對象的指針。這種定時器在第一次倒計時時使用duetime時間,到期後再使用period值重複倒計時。爲了準確地完成周期定時,應該把duetime時間指定爲與週期間隔參數一樣的相對時間。指定爲0的duetime參數將使定時器立即完成第一次倒計時,然後開始週期性倒計時。由於不用重複等待超時通知,所以週期性定時器常常與DPC對象聯用。

取消一個週期性定時器

在定時器對象超出定義範圍之外前,一定要調用KeCancelTimer取消任何已創建的週期性定時器。如果這個週期性定時器帶有一個DPC,則還需要在取消該定時器之後調用KeRemoveQueueDpc。甚至即使你做了這兩件事,還可能出現一個無法解決的問題。如果你在DriverUnload例程中取消這種定時器,可能會出現一種罕見的情形:你的驅動程序已被卸載,但那個DPC例程的實例卻仍運行在另一個CPU上。這個問題只有等待未來版本的操作系統來解決。你可以儘早地取消這種定時器以便減少該問題出現的可能性,比如在IRP_MN_REMOVE_DEVICE的處理程序中。

一個例子

內核定時器的一個用處是爲定期檢測設備活動的系統線程提供循環定時。今天很少有設備需要循檢服務,但你可能遇到例外情況。我將在第九章中討論這個主題。隨書光盤中有一個例子(POLLING)演示了這個概念。這個例子的部分代碼以固定的間隔時間循檢設備。這個循環可以被設置的kill事件所打破,所以程序使用了KeWaitForMultipleObjects函數。實際的代碼要比下面的例子更復雜一些,下面代碼片段主要側重於定時器的使用:

VOID PollingThreadRoutine(PDEVICE_EXTENSION pdx) { NTSTATUS status; KTIMER timer; KeInitializeTimerEx(&timer, SynchronizationTimer); &lt;--1 PVOID pollevents[] = { &lt;--2 (PVOID) &pdx->evKill, (PVOID) &timer, }; ASSERT(arraysize(pollevents) <= THREAD_WAIT_OBJECTS); LARGE_INTEGER duetime = {0}; #define POLLING_INTERVAL 500 KeSetTimerEx(&timer, duetime, POLLING_INTERVAL, NULL); &lt;--3 while (TRUE) { status = KeWaitForMultipleObjects(arraysize(pollevents), &lt;--4 pollevents, WaitAny, Executive, KernelMode, FALSE, NULL, NULL); if (status == STATUS_WAIT_0) break; if (&lt;device needs attention>) <--5 &lt;do something>; } KeCancelTimer(&timer); PsTerminateSystemThread(STATUS_SUCCESS); }

  1. 在此,我們把一個內核定時器初始化成同步方式。它只能用於一個線程,本線程。
  2. 我們需要爲KeWaitForMultipleObjects函數提供一個同步對象指針數組。第一個數組元素是kill事件對象,驅動程序的其它部分可能在系統線程需要退出時設置這個對象,以終止循環。第二個數組元素就是定時器對象。
  3. KeSetTimerEx語句啓動週期定時器。由於duetime參數是0,所以定時器立即進入信號態。然後每隔500毫秒觸發一次。
  4. 在循檢循環內,我們等待定時器到期或kill事件發生。如果等待由於kill事件而結束,我們退出循環,並做一些清理工作,最後終止這個系統線程。如果等待是由定時器到期而結束,我們就前進到下一步處理。
  5. 在這裏,設備驅動程序可以做一些與硬件有關的操作。

定時函數

除了使用內核定時器對象外,你還可以使用另外兩個定時函數,它們也許更適合你。第一函數是KeDelayExecutionThread,你可以在PASSIVE_LEVEL級上調用該函數並給出一個時間間隔。該函數省去了使用定時器時的麻煩操作,如創建,初始化,設置,等待操作。

ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); LARGE_INTEGER duetime; NSTATUS status = KeDelayExecutionThread(WaitMode, Alertable, &duetime);

在這裏,WaitModeAlertable,和函數返回代碼與KeWaitXxx中的對應部分有相同的含義。duetime也是內核定時器中使用的同一種時間表達類型。

如果你需要延遲一段非常短的時間(少於50毫秒),可以調用KeStallExecutionProcessor,在任何IRQL級上:

KeStallExecutionProcessor(nMicroSeconds);

這個延遲的目的是允許硬件在程序繼續執行前有時間爲下一次操作做準備。實際的延遲時間可能大大超過你請求的時間,因爲KeStallExecutionProcessor可以被其它運行在更高IRQL級上的活動搶先,但不能被同一IRQL級上的活動搶先。

內核線程同步

操作系統的進程結構部件(Process Structure)提供了一些例程,WDM驅動程序可以使用這些例程創建和控制內核線程,這些例程可以幫助驅動程序週期性循檢設備,我將在第九章中討論這些例程。出於完整性考慮,我在這裏先提一下。如果在KeWaitXxx調用中指定一個內核線程對象,那麼你的線程將被阻塞直到那個內核線程結束運行。那個內核線程通過調用PsTerminateSystemThread函數終止自身。

爲了等待某內核線程結束,你首先應獲得一個KTHREAD對象(不透明對象)的指針,在內部,該對象用於代表內核線程,但這裏還有一點問題,當你運行在某線程的上下文中時,你可以容易地獲取當前線程的KTHREAD指針:

ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL); PKTHREAD thread = KeGetCurrentThread();

不幸的是,當你調用PsCreateSystemThread創建新內核線程時,你僅能獲取該線程的不透明句柄。爲了獲得KTHREAD對象指針,你必須使用對象管理器服務函數:

HANDLE hthread; PKTHREAD thread; PsCreateSystemThread(&hthread, ...); ObReferenceObjectByHandle(hthread, THREAD_ALL_ACCESS, NULL, KernelMode, (PVOID*) &thread, NULL); ZwClose(hthread);

ObReferenceObjectByHandle函數把你提供的句柄轉換成一個指向下層內核對象的指針。一旦有了這個指針,你就可以調用ZwClose關閉那個句柄。在某些地方,你還需要調用ObDereferenceObject函數釋放對該線程對象的引用。

ObDereferenceObject(thread);

線程警惕和APC

在內部,Windows NT內核有時使用線程警惕(thread alert)來喚醒線程。這種方法使用APC(異步過程調用)來喚醒線程去執行某些特殊例程。用於生成警惕和APC的支持例程沒有輸出給WDM驅動程序開發者使用。但是,由於DDK文檔和頭文件中有大量地方引用了這個概念,所以我想在這裏談一下。

當某人通過調用KeWaitXxx例程阻塞一個線程時,需要指定一個布爾參數,該參數表明等待是否是警惕的(alertable)。一個警惕的等待可以提前完成,即不用滿足任何等待條件或超時,僅由於線程警惕。線程警惕起源於用戶模式的native API函數NtAlertThread。如果因爲警惕等待提前終止,則內核返回特殊的狀態值STATUS_ALERTED。

APC機制使操作系統能在特定線程上下文中執行一個函數。APC的異步含義是,系統可以有效地中斷目標線程以執行一個外部例程。APC的動作有點類似於硬件中斷使處理器從任何當前代碼突然跳到ISR的情形,它是不可預見的。

APC來自三種地方:用戶模式、內核模式,和特殊內核模式。用戶模式代碼通過調用Win32 API函數QueueUserAPC請求一個用戶模式APC。內核模式代碼通過調用一個未公開的函數請求一個APC,而且該函數在DDK頭文件中沒有原型。某些逆向工程師可能已經知道該例程的名稱以及如何調用它,但該函數的確是僅用於內部,所以我不在這裏討論它。系統把APC排入一個特殊線程直到和適的執行條件出現。和適的執行條件要取決於APC的類型,如下:

  • 特殊的內核APC被儘可能快地執行,既只要APC_LEVEL級上有可調度的活動。在很多情況下,特殊的內核APC甚至能喚醒阻塞的線程。
  • 普通的內核APC僅在所有特殊APC都被執行完,並且目標線程仍在運行,同時該線程中也沒有其它內核模式APC正執行時才執行。
  • 用戶模式APC在所有內核模式APC執行完後才執行,並且僅在目標線程有警惕屬性時才執行。

如果系統喚醒線程去提交一個APC,則使該線程阻塞的等待原語函數將返回特殊狀態值STATUS_KERNEL_APC或STATUS_USER_APC。

APC與I/O請求

內核使用APC概念有多種目的。由於本書僅討論驅動程序的編寫,所以我僅解釋APC與執行I/O操作之間的關係。在某些場合,當用戶模式程序在一個句柄上執行同步的ReadFile操作時,Win32子系統就調用一個名爲NtReadFile(儘管未公開,但已經被廣泛瞭解)的內核模式例程。該函數創建並提交一個IRP到適當的設備驅動程序,而驅動程序通常返回STATUS_PENDING以指出操作未完成。NtReadFile然後向ReadFile也返回這個狀態代碼,於是ReadFile調用NtWaitForSingleObject函數,這將使應用程序在那個用戶模式句柄指向的文件對象上等待。NtWaitForSingleObject接着調用KeWaitForSingleObject以執行一個非警惕的用戶模式的等待,在文件對象內部的一個事件對象上等待。

當設備驅動程序最後完成了讀操作時,它調用IoCompleteRequest函數,該函數接下來排隊一個特殊的內核模式APC。該APC例程然後調用KeSetEvent函數使文件對象進入信號態,因此應用程序被釋放並得以繼續執行。有時,I/O請求被完成後還需要執行一些其它任務,如緩衝區複製,而這些操作又必須發生在請求線程的地址上下文中,因此會需要其它種類的APC。如果請求線程不處於警惕性的等待狀態,則需要內核模式APC。如果在提交APC時線程並不適合運行,則需要特殊的APC。實際上,APC例程就是用於喚醒線程的機制。

內核模式例程也能調用NtReadFile函數。但驅動程序應該調用ZwReadFile函數替代,它使用與用戶模式程序一樣的系統服務接口到達NtReadFile(注意,NtReadFile函數未公開給設備驅動程序使用)。如果你遵守DDK的限定調用ZwReadFile函數,那麼你向NtReadFile的調用與用戶模式中的調用幾乎沒有什麼不同,僅有兩處不同。第一,ZwReadFile函數更小,並且任何等待都將在內核中完成。另一個不同之處是,如果你調用了ZwCreateFile函數並指定了同步操作,則I/O管理器將自動等待你的讀操作直到完成。這個等待可以是警惕的也可以不是,取決於你在ZwCreateFile調用中指定的實際選項。

如何指定Alertable和WaitMode參數

現在你已經有足夠的背景資料瞭解等待原語中的AlertableWaitMode參數。作爲一個通用規則,你絕不要寫同步響應用戶模式請求的代碼,僅能爲確定的I/O控制請求這樣做。一般說來,最好掛起長耗時的操作(從派遣例程中返回STATUS_PENDING代碼)而以異步方式完成。再有,你不要一上來就調用等待原語。線程阻塞僅適合設備驅動程序中的某幾個地方使用。下面幾段介紹了這幾個地方。

內核線程 有時,當你的設備需要週期性循檢時,你需要創建自己的內核模式線程。

處理PnP請求 我將在第六章中討論如何處理PnP管理器發送給你的I/O請求。有幾個PnP請求需要你在驅動程序這邊同步處理。換句話說,你把這些請求傳遞到低級驅動程序並等待它們完成。你將調用KeWaitForSingleObject函數並在內核模式中等待,這是由於PnP管理器是在內核模式線程的上下文中調用你的驅動程序。另外,如果你需要執行作爲處理PnP請求一部分的輔助請求時,例如,與USB設備通信,你應在內核模式中等待。

處理其它I/O請求 當你正在處理其它種類的I/O請求時,並且你知道正運行在一個非任意線程上下文中時,那麼你在行動前必須仔細考慮,如果你確信那個線程可以被阻塞,你應該在調用者所處於的處理器模式中等待。在多數情況下,你可以利用IRP中的RequestorMode域。此外,你還可以調用ExGetPreviousMode來確定前一個處理器模式。如果你在用戶模式中等待,並允許用戶程序調用QueueUserAPC提前終止等待,你應該執行一個警惕性等待。

我最後要提到的情況是,在用戶模式中等待並要允許用戶模式APC打斷,你應使用警惕性等待。

底線是:使用非警惕性等待,除非你知道不這樣做的原因。

 

下面是MFC中的同步對象及其用途,僅供參考

CSemaphore的對象代表一個“信號燈”:一種同步對象,允許一個或多個進程中的有限數量的線程訪問某個資源。一個CSemaphore對象維護着當前正訪問某個資源的進程的個數。信號燈通常用於控制僅能支持有限數量用戶的共享資源的訪問。CSemaphore對象計數器的當前值表示還可以允許多少個用戶使用它保護的共享資源。當這個數到達0時,所有試圖訪問被保護資源的操作都被放入一個系統隊列等待,直到計數數值上升到0以上或等待超時。

CEvent的對象代表一個“事件”:一種同步對象,用於一個線程通知另一個線程某事件發生。事件通常用於線程想知道何時執行其任務。例如,複製數據到某文件的線程需要被通知數據何時準備好。用CEvent對象可以通知複製線程數據已經有效,這樣線程就可以儘快地執行其任務。CEvent對象有兩種類型:手動和自動。手動CEvent對象將停留在SetEvent或ResetEvent設置的狀態,除非你再次設置它。自動CEvent對象在一個線程釋放後自動回到非信號態(無效狀態)。

CMutex的對象代表一個“互斥”:一種同步對象,可以使一個線程排斥性地訪問某個資源。互斥可以用於一次僅允許一個線程訪問的資源。例如,向鏈表添加一個接點的過程就是一次只允許一個線程執行。通過使用CMutex對象控制鏈表,一次只能有一個線程獲得鏈表的訪問權。

CCriticalSection的對象代表一個“關鍵段”:一種同步對象,代表一次只允許一個線程訪問的資源或代碼片段。例如,向鏈表添加一個節點。使用CCriticalSection對象控制的鏈表一次只允許一個線程訪問該鏈表。如果着重於執行速度並且被保護資源不跨進程使用,也可以用關鍵段代替互斥。

其它內核模式同步要素

Windows 2000內核爲同步線程執行和保護共享對象訪問提供了一些額外的方法。在這一節中,我們將討論快速互斥(fast mutex)對象,通過對無競爭情況的優化處理,它可以提供比普通內核互斥對象更快的執行性能。我還將描述一種名稱中含有“互鎖(Interlocked)”術語的支持函數。這些函數都執行某種公用操作,例如增加或減少一個整數的值,從鏈表中插入或刪除一個表項,這些操作都是以原子方式執行,從而可以避免多任務或多處理器的干擾。

快速互斥對象

參照內核互斥,表4-6列出了快速互斥的優點和缺點。有利的一面,快速互斥在沒有實際競爭的情況下可以快速獲取和釋放。不利的一面,你不能遞歸獲取一個快速互斥對象。即如果你擁有快速互斥對象你就不能發出APC,這意味着你將處於APC_LEVEL或更高的IRQL,在這一級上,線程優先級將失效,但你的代碼將不受干擾地執行,除非有硬件中斷髮生。

表4-6. 內核互斥和快速互斥的比較

內核互斥
快速互斥

可以被單線程遞歸獲取(系統爲其維護一個請求計數器)
不能被遞歸獲取

速度慢
速度快

所有者只能收到“特殊的”內核APC
所有者不能收到任何APC

所有者不能被換出內存
不自動提升被阻塞線程的優先級(如果運行在大於或等於APC_LEVEL級),除非你使用XxxUnsafe函數並且執行在PASSIVE_LEVEL級上

可以是多對象等待的一部分
不能作爲KeWaitForMultipleObjects的參數使用

表4-7 列出了與快速互斥相關的服務函數

表4-7. 快速互斥服務函數

服務函數
描述

ExAcquireFastMutex
獲取快速互斥,如果必要則等待

ExAcquireFastMutexUnsafe
獲取快速互斥,如果必要則等待,調用者必須先停止接收APC

ExInitializeFastMutex
初始化快速互斥對象

ExReleaseFastMutex
釋放快速互斥

ExReleaseFastMutexUnsafe
釋放快速互斥,不解除APC提交禁止

ExTryToAcquireFastMutex
獲取快速互斥,如果可能,立即獲取不等待

爲了創建一個快速互斥,你必須先在非分頁內存中分配一個FAST_MUTEX數據結構。然後調用ExInitializeFastMutex函數初始化該快速互斥對象。實際上,在WDM.H中該函數是一個宏:

ASSERT(KeGetCurrentIrql() &lt;= DISPATCH_LEVEL); ExInitializeFastMutex(FastMutex);

FastMutex是FAST_MUTEX對象的地址。快速互斥開始於無主狀態。爲了獲取快速互斥對象,調用下面函數:

ASSERT(KeGetCurrentIrql() &lt; DISPATCH_LEVEL); ExAcquireFastMutex(FastMutex);

ASSERT(KeGetCurrentIrql() &lt; DISPATCH_LEVEL); ExAcquireFastMutexUnsafe(FastMutex);

第一種函數等待互斥變成有效狀態,然後再把所有權賦給調用線程,最後把處理器當前的IRQL提升到APC_LEVEL。IRQL提升的結果是阻止所有APC的提交。第二種函數不改變IRQL。在使用這個“不安全”的函數獲取快速互斥前你需要考慮潛在的死鎖可能。必須避免運行在同一線程上下文下的APC例程獲取同一個互斥或任何其它不能被遞歸鎖定的對象。否則你將冒隨時死鎖那個線程的風險。

如果你不想在互斥沒立即有效的情況下等待,使用“嘗試獲取”函數:

ASSERT(KeGetCurrentIrql() &lt; DISPATCH_LEVEL); BOOLEAN acquired = ExTryToAcquireFastMutex(FastMutex);

如果返回值爲TRUE,則你已經擁有了該互斥。如果爲FALSE,表明該互斥已經被別人佔有,你不能獲取。

爲了釋放一個快速互斥並允許其它線程請求它,調用適當的釋放函數:

ASSERT(KeGetCurrentIrql() &lt; DISPATCH_LEVEL); ExReleaseFastMutex(FastMutex);

ASSERT(KeGetCurrentIrql() &lt; DISPATCH_LEVEL); ExReleaseFastMutexUnsafe(FastMutex);

快速互斥之所以快速是因爲互斥的獲取和釋放步驟都爲沒有競爭的情況做了優化。獲取互斥的關鍵步驟是自動減和測試一個整數計數器,該計數器指出有多少線程佔有或等待該互斥。如果測試表明沒有其它線程佔有該互斥,則沒有額外的工作需要做。如果測試表明有其它線程擁有該互斥,則當前線程將阻塞在一個同步事件上,該同步事件是FAST_MUTEX對象的一部分。釋放互斥時必須自動增並測試計數器。如果測試表明當前沒有等待線程,則沒有額外的工作要做。如果還有線程在等待,則互斥所有者需調用KeSetEvent函數釋放一個等待線程。

互鎖運算

在WDM驅動程序能調用的函數中,有一些函數可以以線程安全和多處理器安全的方式執行算術運算。見表4-8。這些例程有兩種形式,第一種形式以Interlocked爲名字開頭,它們可以執行原子操作,其它線程或CPU不能干擾它們的執行。另一種形式以ExInterlocked爲名字開頭,它們使用自旋鎖。

表4-8. 互鎖運算服務函數

服務函數
描述

InterlockedCompareExchange
比較並有條件地交換兩個值

InterlockedDecrement
整數減1

InterlockedExchange
交換兩個值

InterlockedExchangeAdd
加兩個值並返回和

InterlockedIncrement
整數加1

ExInterlockedAddLargeInteger
向64位整數加

ExInterlockedAddLargeStatistic
向ULONG加

ExInterlockedAddUlong
向ULONG加並返回原始值

ExInterlockedCompareExchange64
交換兩個64位值

InterlockedXxx函數可以在任意IRQL上調用;由於該函數不需要自旋鎖,所以它們還可以在PASSIVE_LEVEL級上處理分頁數據。儘管ExInterlockedXxx函數也可以在任意IRQL上調用,但它們需要在大於或等於DISPATCH_LEVEL級上操作目標數據,所以它們的參數需要在非分頁內存中。使用ExInterlockedXxx的唯一原因是,如果你有一個數據變量,且需要增減該變量的值,並且有時還需要用其它指令序列直接訪問該變量。你可以在對該變量的多條訪問代碼周圍明確聲明自旋鎖,然後僅用ExInterlockedXxx函數執行簡單的增減操作。

InterlockedXxx函數

InterlockedIncrement向內存中的長整型變量加1,並返回加1後的值:

LONG result = InterlockedIncrement(pLong);

pLong是類型爲LONG的變量的地址,概念上,該函數的操作等價於C語句:return ++*pLong,但它與簡單的C語句的不同地方是提供了線程安全和多處理器安全。InterlockedIncrement可以保證整數變量被成功地增1,即使其它CPU上的線程或同一CPU上的其它線程同時嘗試改變這個整數的值。就操作本身來說,它不能保證所返回的值仍是該變量當前的值,甚至即使僅僅過了一個機器指令週期,因爲一旦這個增1原子操作完成,其它線程或CPU就可能立即修改這個變量。

InterlockedDecrement除了執行減1操作外,其它方面同上。

LONG result = InterlockedDecrement(pLong);

InterlockedCompareExchange函數可以這樣調用:

LONG target; LONG result = InterlockedCompareExchange(&target, newval, oldval);

target是一個類型爲LONG的整數,既可以用於函數的輸入也可以用於函數的輸出,oldval是你對target變量的猜測值,如果這個猜測正確,則newval被裝入target。該函數的內部操作與下面C代碼類似,但它是以原子方式執行整個操作,即它是線程安全和多處理器安全的:

LONG CompareExchange(PLONG ptarget, LONG newval, LONG oldval) { LONG value = *ptarget; if (value == oldval) *ptarget = newval; return value; }

換句話說,該函數總是返回target變量的歷史值給你。此外,如果這個歷史值等於oldval,那麼它把target的值設置爲newval。該函數用原子操作實現比較和交換,而交換僅在歷史值猜測正確的情況下才發生。

你還可以調用InterlockedCompareExchangePointer函數來執行類似的比較和交換操作,但該函數使用指針參數。該函數或者定義爲編譯器內部的內聯函數,或者是一個真實的函數,取決於你編譯時平臺的指針寬度,以及編譯器生成內聯代碼的能力。下面例子中使用了這個指針版本的比較交換函數,它把一個結構加到一個單鏈表的頭部,而不用使用自旋鎖或提升IRQL:

typedef struct _SOMESTRUCTURE { struct _SOMESTRUCTURE* next; ... } SOMESTRUCTURE, *PSOMESTRUCTURE; ... void InsertElement(PSOMESTRUCTURE p, PSOMESTRUCTURE* anchor) { PSOMESTRUCTURE next, first; do { p->next = first = *anchor; next = InterlockedCompareExchangePointer(anchor, p, first); } while (next != first); }

每一次循環中,我們都假設新元素將連接到鏈表的當前頭部,即變量first中的地址。然後我們調用InterlockedCompareExchangePointer函數來查看anchor是否仍指向first,即使在過了幾納秒之後。如果是這樣,InterlockedCompareExchangePointer將設置anchor,使其指向新元素p。並且如果InterlockedCompareExchangePointer的返回值也與我們的假設一致,則循環終止。如果由於某種原因,anchor不再指向那個first元素(可能被其它併發線程或CPU修改過),我們將發現這個事實並重復循環。

最後一個函數是InterlockedExchange,它使用原子操作替換整數變量的值並返回該變量的歷史值:

LONG value; LONG oldval = InterlockedExchange(&value, newval);

正如你猜到的,還有一個InterlockedExchangePointer函數,它交換指針值(64位或32位,取決於具體平臺)。

ExInterlockedXxx函數

每一個ExInterlockedXxx函數都需要在調用前創建並初始化一個自旋鎖。注意,這些函數的操作數必須存在於非分頁內存中,因爲這些函數在提升的IRQL上操作數據。

ExInterlockedAddLargeInteger加兩個64位整數並返回被加數的歷史值:

LARGE_INTEGER value, increment; KSPIN_LOCK spinlock; LARGE_INTEGER prev = ExInterlockedAddLargeInteger(&value, increment, &spinlock);

value是被加數。increment是加數。spinlock是一個已經初始化過的自旋鎖。返回值是被加數的歷史值。該函數的操作過程與下面代碼類似,但除了自旋鎖的保護:

__int64 AddLargeInteger(__int64* pvalue, __int64 increment) { __int64 prev = *pvalue; *pvalue += increment; return prev; }

注意,並不是所有編譯器都支持__int64整型類型,並且不是所有計算機都能用原子指令方式執行64位加操作。

ExInterlockedAddUlong與ExInterlockedAddLargeInteger類似,但它的操作數是32位無符號整數:

ULONG value, increment; KSPIN_LOCK spinlock; ULONG prev = ExInterlockedAddUlong(&value, increment, &spinlock);

該函數同樣返回被加數的加前值。

ExInterlockedAddLargeStatistic與ExInterlockedAddUlong類似,但它把32位值加到64位值上。該函數在本書出版時還沒有在DDK中公開,所以我在這裏僅給出它的原型:

VOID ExInterlockedAddLargeStatistic(PLARGE_INTEGER Addend, ULONG Increment);

該函數要比ExInterlockedAddUlong函數快,因爲它不需要返回被加數的加前值。因此,它也不需要使用自旋鎖來同步。該函數的操作也是原子性的,但僅限於調用同一函數的其它調用者。換句話說,如果你在一個CPU上調用ExInterlockedAddLargeStatistic函數,而同時另一個CPU上的代碼正訪問Addend變量,那麼你將得到不一致的結果。我將用該函數在Intel x86上的執行代碼(並不是實際的源代碼)來解釋這個原因:

mov eax, Addend mov ecx, Increment lock add [eax], ecx lock adc [eax+4], 0

這個代碼在低32位沒有進位的情況下可以正常工作,但如果存在着進位,那麼在ADD和ADC指令之間其它CPU可能進入,如果那個CPU調用的ExInterlockedCompareExchange64函數複製了這個時刻的64位變量值,那麼它得到值將是不正確的。即使每個加法指令前都有lock前綴保護其操作的原子性(多CPU之間),但多個這樣的指令組成的代碼塊將無法保持原子性。

鏈表的互鎖訪問

Windows NT的executive部件提供了三組特殊的鏈表訪問函數,它們可以提供線程安全的和多處理器安全的鏈表訪問。這些函數支持雙鏈表、單鏈表,和一種稱爲S鏈表(S-List)的特殊單鏈表。我在前面章中已經討論過單鏈表和雙鏈表的非互鎖訪問。在這裏,我將解釋這些鏈表的互鎖訪問。

如果你需要一個FIFO隊列,你應該使用雙鏈表。如果你需要一個線程安全的和多處理器安全的下推棧,你應該使用S鏈表。爲了以線程安全和多處理器安全的方式使用這些鏈表,你必須爲它們分配並初始化一個自旋鎖。但S鏈表並沒有真正使用自旋鎖。S鏈表中存在順序號,內核利用它可以實現比較-交換操作的原子性。

用於互鎖訪問各種鏈表對象的函數都十分相似,所以我將以函數的功能來組織這些段。我將解釋如何初始化這三種鏈表,如何向這三種鏈表中插入元素,如何從這三種鏈表中刪除元素。

初始化

你可以象下面這樣初始化這些鏈表:

LIST_ENTRY DoubleHead; SINGLE_LIST_ENTRY SingleHead; SLIST_HEADER SListHead; InitializeListHead(&DoubleHead); SingleHead.Next = NULL; ExInitializeSListHead(&SListHead);

不要忘記爲每種鏈表分配並初始化一個自旋鎖。另外,鏈表頭和所有鏈表元素的存儲都必須來自非分頁內存,因爲支持例程需要在提升的IRQL上訪問這些鏈表。注意,在鏈表頭的初始化過程中不需要使用自旋鎖,因爲此時不存在競爭。

插入元素

雙鏈表可以在頭部或尾部插入元素,但單鏈表和S鏈表僅能在頭部插入元素:

PLIST_ENTRY pdElement, pdPrevHead, pdPrevTail; PSINGLE_LIST_ENTRY psElement, psPrevHead; PKSPIN_LOCK spinlock; pdPrevHead = ExInterlockedInsertHeadList(&DoubleHead, pdElement, spinlock); pdPrevTail = ExInterlockedInsertTailList(&DoubleHead, pdElement, spinlock); psPrevHead = ExInterlockedPushEntryList(&SingleHead, psElement, spinlock); psPrevHead = ExInterlockedPushEntrySList(&SListHead, psElement, spinlock);

返回值是插入前鏈表頭(或尾)的地址。注意,被插入的鏈表元素地址是一個鏈表表項結構的地址,這個地址通常要嵌入到更大的應用結構中,調用CONTAINING_RECORD宏可以獲得外圍應用結構的地址。

刪除元素

你可以從這些鏈表的頭部刪除元素:

pdElement = ExInterlockedRemoveHeadList(&DoubleHead, spinlock); psElement = ExInterlockedPopEntryList(&SingleHead, spinlock); psElement = ExInterlockedPopEntrySList(&SListHead, spinlock);

如果鏈表爲空則函數的返回值爲NULL。你應該先測試返回值是否爲NULL,然後再用CONTAINING_RECORD宏取外圍應用結構的指針。

IRQL的限制

你只能在低於或等於DISPATCH_LEVEL級上調用S鏈表函數。只要所有對鏈表的引用都使用ExInterlockedXxx函數,那麼訪問雙鏈表和單鏈表的ExInterlockedXxx函數可以在任何IRQL上調用。這些函數沒有IRQL限制的原因是因爲它們在執行時都禁止了中斷,這就等於把IRQL提升到最高可能的級別。一旦中斷被禁止,這些函數就獲取你指定的自旋鎖。因爲此時在同一CPU上沒有其它代碼能獲得控制,並且其它CPU上的代碼也不能獲取那個自旋鎖,所以你的鏈表是安全的。

注意

DDK文檔中關於這條規則的陳述過於嚴格,它認爲所有調用者必須運行在低於或等於你的中斷對象DIRQL之下的某個IRQL上。實際上,並不需要所有調用者都在同一IRQL上,同樣也不必限制IRQL必須小於或等於DIRQL。

最好在代碼的一個部分使用ExInterlockedXxx互鎖函數訪問單鏈表或雙鏈表(不包括S鏈表),在另一部分使用非互鎖函數(InsertHeadList等等)。在使用一個非互鎖原語前,應該提前獲取調用使用的自旋鎖。另外,應該限制低於或等於DISPATCH_LEVEL級的代碼訪問鏈表。例如:

// Access list using noninterlocked calls: VOID Function1() { ASSERT(KeGetCurrentIrql() &lt;= DISPATCH_LEVEL); KIRQL oldirql; KeAcquireSpinLock(spinlock, &oldirql); InsertHeadList(...); RemoveTailList(...); ... KeReleaseSpinLock(spinlock, oldirql); } // Access list using interlocked calls: VOID Function2() { ASSERT(KeGetCurrentIrql() &lt;= DISPATCH_LEVEL); ExInterlockedInsertTailList(..., spinlock); }

第一個函數必須運行在低於或等於DISPATCH_LEVEL上,因爲這裏需要調用KeAcquireSpinLock函數。第二個函數的IRQL限定原因是這樣的:假定Function1在準備訪問鏈表階段獲取了自旋鎖,而獲取自旋鎖時需要把IRQL暫時提升到DISPATCH_LEVEL級,現在再假定在同一CPU上有一箇中斷髮生在更高級的IRQL上,然後Function2獲得了控制,而它又調用了一個ExInterlockedXxx函數,而此時內核正要獲取同一個自旋鎖,因此CPU將死鎖。導致這個問題的原因是允許用同一個自旋鎖的代碼運行在兩個不同的IRQL上:Function1在DISPATCH_LEVEL級上,而Function2在HIGH_LEVEL級上。

共享數據的非互鎖訪問

如果你要提取一個對齊的數據,那麼調用任何一個InterlockedXxx函數就可以正確地做到。支持NT的CPU必然保證你能獲得一個首尾一致的值,即使互鎖操作發生在數據被提取前後的短暫時間內。然而,如果數據沒有對齊,當前的互鎖訪問也會禁止其它的互鎖訪問,不至於造成併發訪問而取到不一致的值。想象一下,如果有一個整數,其大小跨過了物理內存中的緩衝邊界,此時,CPU A想提取這個整數,而CPU B在同一時間要在這個值上執行一個互鎖加1操作。那麼即將發生的一系列事情可能是:(a) CPU A提取了含有該值高位部分的緩衝線,(b) CPU B執行了一個互鎖增1操作並向該值高位部分產生了一個進位,(c) CPU A接着提取了包含該值低位部分的緩衝線。確保這個值不跨過一個緩衝界限可以避免這個問題,但最容易的解決辦法是確保該值按其數據類型的自然邊界對齊,如ULONG類型按4字節對齊。

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