Windows NT提供了五种内核同步对象(Kernel Dispatcher Object),你可以用它们控制非任意线程(普通线程)的流程。表4-1列出了这些内核同步对象的类型及它们的用途。在任何时刻,任何对象都处于两种状态中的一种:信号态或非信号态。有时,当代码运行在某个线程的上下文中时,它可以阻塞这个线程的执行,调用KeWaitForSingleObject或KeWaitForMultipleObjects函数可以使代码(以及背景线程)在一个或多个同步对象上等待,等待它们进入信号态。内核为初始化和控制这些对象的状态提供了例程。
表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枚举类型,该枚举类型仅有两个值:KernelMode和UserMode。
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() <= DISPATCH_LEVEL); LARGE_INTEGER timeout; NTSTATUS status = KeWaitForMultipleObjects(count, objects, WaitType, WaitReason, WaitMode, Alertable, &timeout, waitblocks);
在这里,objects指向一个指针数组,每个数组元素指向一个同步对象,count是数组中指针的个数。count必须小于或等于MAXIMUM_WAIT_OBJECTS值(当前为64)。这个数组和它所指向的所有对象都必须在非分页内存中。WaitType是枚举类型,其值可以为WaitAll或WaitAny,它指出你是等到所有对象都进入信号态,还是只要有一个对象进入信号态就可以。
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是一个枚举值,可以为NotificationEvent或SynchronizationEvent。通知事件(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。
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() <= 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,代表有一个所有者释放了它的权利。boost和wait参数与在KeSetEvent函数中的作用相同。返回值为0代表信号灯的前一个状态是非信号态,非0代表信号灯的前一个状态为信号态。
KeReleaseSemaphore不允许你把计数器的值增加到超过limit指定的值。如果你这样做,该函数根本就不调整计数器的值,它将产生一个代码为STATUS_SEMAPHORE_LIMIT_EXCEEDED的异常。除非系统中存在捕获该异常的处理程序,否则将导致一个bug check。
下面调用读取信号灯的当前状态:
ASSERT(KeGetCurrentIrql() <= 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() <= 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() <= DISPATCH_LEVEL); KeInitializeTimer(timer);
在此,定时器处于非信号状态,它还没有开始倒计时,在这样的定时器上等待的线程永远得不到唤醒。为了启动定时器倒计时,我们调用KeSetTimer函数:
ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL); LARGE_INTEGER duetime; BOOLEAN wascounting = KeSetTimer(timer, duetime, NULL);
duetime是一个64位的时间值,单位为100纳秒。如果该值为正,则表示一个从1601年1月1日算起的绝对时间。如果该值为负,则它是相对于当前时间的一段时间间隔。
返回值如果为TRUE,则表明定时器已经启动。(在这种情况下,如果我们再调用KeSetTimer函数,则定时器放弃原来的时间重新开始倒计时)
下面语句读取定时器的当前状态:
ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL); BOOLEAN counting = KeReadStateTimer(timer);
KeInitializeTimer和KeSetTimer实际上是旧的服务函数,它们已经被新函数取代。我们可以用下面调用初始化定时器:
ASSERT(KeGetCurrentIqrl() <= DISPATCH_LEVEL); KeInitializeTimerEx(timer, NotificationTimer);
定时器设置函数也有扩展版本,KeSetTimerEx:
ASSERT(KeGetCurrentIrql() <= 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() <= 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() <= DISPATCH_LEVEL); KeInitializeTimerEx(timer, SynchronizationTimer);
SynchronizationTimer是枚举类型TIMER_TYPE的一个枚举值。另一个枚举值是NotificationTimer。
如果你在同步定时器上使用DPC例程,可以把排队DPC看成是定时器到期时发生的额外事情。即定时器到期时,系统把定时器置成信号态,并把DPC对象插入DPC队列。定时器进入信号态将使阻塞的线程得以释放。
周期性定时器
到现在为止,我们讨论过的定时器仅能定时一次。通过使用定时器的扩展设置函数,你可以请求一个周期性的超时:
ASSERT(KeGetCurrentIrql() <= 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); <--1 PVOID pollevents[] = { <--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); <--3 while (TRUE) { status = KeWaitForMultipleObjects(arraysize(pollevents), <--4 pollevents, WaitAny, Executive, KernelMode, FALSE, NULL, NULL); if (status == STATUS_WAIT_0) break; if (<device needs attention>) <--5 <do something>; } KeCancelTimer(&timer); PsTerminateSystemThread(STATUS_SUCCESS); }
定时函数
除了使用内核定时器对象外,你还可以使用另外两个定时函数,它们也许更适合你。第一函数是KeDelayExecutionThread,你可以在PASSIVE_LEVEL级上调用该函数并给出一个时间间隔。该函数省去了使用定时器时的麻烦操作,如创建,初始化,设置,等待操作。
ASSERT(KeGetCurrentIrql() == PASSIVE_LEVEL); LARGE_INTEGER duetime; NSTATUS status = KeDelayExecutionThread(WaitMode, Alertable, &duetime);
在这里,WaitMode、Alertable,和函数返回代码与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,则使该线程阻塞的等待原语函数将返回特殊状态值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参数
现在你已经有足够的背景资料了解等待原语中的Alertable和WaitMode参数。作为一个通用规则,你绝不要写同步响应用户模式请求的代码,仅能为确定的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() <= DISPATCH_LEVEL); ExInitializeFastMutex(FastMutex);
FastMutex是FAST_MUTEX对象的地址。快速互斥开始于无主状态。为了获取快速互斥对象,调用下面函数:
ASSERT(KeGetCurrentIrql() < DISPATCH_LEVEL); ExAcquireFastMutex(FastMutex);
或
ASSERT(KeGetCurrentIrql() < DISPATCH_LEVEL); ExAcquireFastMutexUnsafe(FastMutex);
第一种函数等待互斥变成有效状态,然后再把所有权赋给调用线程,最后把处理器当前的IRQL提升到APC_LEVEL。IRQL提升的结果是阻止所有APC的提交。第二种函数不改变IRQL。在使用这个“不安全”的函数获取快速互斥前你需要考虑潜在的死锁可能。必须避免运行在同一线程上下文下的APC例程获取同一个互斥或任何其它不能被递归锁定的对象。否则你将冒随时死锁那个线程的风险。
如果你不想在互斥没立即有效的情况下等待,使用“尝试获取”函数:
ASSERT(KeGetCurrentIrql() < DISPATCH_LEVEL); BOOLEAN acquired = ExTryToAcquireFastMutex(FastMutex);
如果返回值为TRUE,则你已经拥有了该互斥。如果为FALSE,表明该互斥已经被别人占有,你不能获取。
为了释放一个快速互斥并允许其它线程请求它,调用适当的释放函数:
ASSERT(KeGetCurrentIrql() < DISPATCH_LEVEL); ExReleaseFastMutex(FastMutex);
或
ASSERT(KeGetCurrentIrql() < 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() <= DISPATCH_LEVEL); KIRQL oldirql; KeAcquireSpinLock(spinlock, &oldirql); InsertHeadList(...); RemoveTailList(...); ... KeReleaseSpinLock(spinlock, oldirql); } // Access list using interlocked calls: VOID Function2() { ASSERT(KeGetCurrentIrql() <= 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字节对齐。