📄
字号:
漫谈兼容内核之十五:Windows线程的等待/唤醒机制
[align=center] 毛德操[/align]
对于任何一个现代的操作系统,进程间通信都是不可或缺的。
共享内存区显然可以用作进程间通信的手段。两个进程把同一组物理内存页面分别映射到各自的用户空间,然后一个进程往里面写,另一个进程就可以读到所写入的内容。所以,共享内存区天然就是一种进程间通信机制。但是这又是很原始的手段,因为这里有个读出方如何知道共享区的内容已经被写入方改变的问题。轮询,或者定期轮询,当然也是个办法,但是一般而言效率毕竟太低。所以,这里需要有个能够对通信双方的活动加以有效协调的机制,这就是“进程间同步”机制。进程间同步本身也是一种进程间通信(因为涉及信息的交换),当然也是一种原始的进程间通信,但同时又是更高级的进程间通信机制的基石。
所以,在谈论通信机制之前,应该先考察一下进程间同步机制。在Linux中,这就是进程的睡眠/唤醒机制,或者说阻塞/解阻塞机制,体现为信息的接收方(进程)在需要读取信息、而发送方(进程)尚未向其发送之时就进入睡眠,到发送方向其发送信息时则加以唤醒。在Windows中,这个过程的原理是一样的,只是名称略有不同,称为“等待/唤醒”,表现形式上也有些不同。
在Windows中,进程间通信必须凭籍着某个已打开的“对象(Object)”才能发生(其实Linux中也是一样,只是没有统一到“对象”这个概念上)。我们不妨把这样的对象想像成某类货品的仓库,信息的接受方试图向这个仓库领货。如果已经到货,那当然可以提了就走,但要是尚未到货就只好等待,到一边歇着去(睡眠),直至到了货才把它(唤醒)叫回来提货。Windows专门为此过程提供了两个系统调用,一个是NtWaitForSingleObject(),另一个是NtWaitForMultipleObjects()。后者是前者的推广、扩充,使得一个线程可以同时在多个对象上等待。
于是,在Windows应用程序中,当一个线程需要从某个对象“提货”、即获取信息时,就通过系统调用NtWaitForSingleObject()实现在目标对象上的等待,当前线程因此而被“阻塞”、即进入睡眠状态,直至所等待的条件得到满足时才被唤醒。
[code]NTSTATUS STDCALL
NtWaitForSingleObject(IN HANDLE ObjectHandle,
IN BOOLEAN Alertable,
IN PLARGE_INTEGER TimeOut OPTIONAL)
{
. . . . . .
PreviousMode = ExGetPreviousMode();
if(TimeOut != NULL && PreviousMode != KernelMode)
{
_SEH_TRY
{
ProbeForRead(TimeOut, sizeof(LARGE_INTEGER), sizeof(ULONG));
/* make a copy on the stack */
SafeTimeOut = *TimeOut;
TimeOut = &SafeTimeOut;
}
_SEH_HANDLE
{
Status = _SEH_GetExceptionCode();
}
_SEH_END;
if(!NT_SUCCESS(Status))
{
return Status;
}
}
Status = ObReferenceObjectByHandle(ObjectHandle, SYNCHRONIZE, NULL,
PreviousMode, &ObjectPtr, NULL);
. . . . . .
if (!KiIsObjectWaitable(ObjectPtr))
{
DPRINT1("Waiting for object type '%wZ' is not supported\n",
&BODY_TO_HEADER(ObjectPtr)->ObjectType->TypeName);
Status = STATUS_HANDLE_NOT_WAITABLE;
}
else
{
Status = KeWaitForSingleObject(ObjectPtr, UserRequest,
PreviousMode, Alertable, TimeOut);
}
ObDereferenceObject(ObjectPtr);
return(Status);
}[/code]
参数ObjectHandle和TimeOut的作用不言自明。另一个参数Alertable是个布尔量,表示是否允许本次等待因用户空间APC而中断,或者说被“警醒”。警醒与唤醒是不同的,唤醒是因为所等待的条件得到了满足(仓库到了货),而警醒是因为别的原因(与仓库无关)。
我们知道,Windows的系统调用函数既可以从用户空间通过自陷指令int 0x2e加以调用,也可以在内核中直接加以调用。如果是从用户空间调用,而且又有以指针形式传递的参数,那就需要从用户空间读取这些指针所指的内容。但是,这些指针所指处的(虚存)页面是否有映射呢?这是没有保证的。如果没有映射,那么在访问时就会发生“页面错误”异常。另一方面,既然读不到调用参数,原定的操作也就无法继续下去了。为此,代码中把对于目标是否可读的测试ProbeForRead()以及参数内容的复制放在_SEH_TRY{}中,并且设置好“页面错误”异常处理的向量,使得一旦发生“页面错误”异常就执行_SEH_HANDLE{}中的操作。这是Windows的“结构化出错处理”即SHE机制的一部分,以后还要有专文介绍。由于篇幅的关系,以后在系统调用的程序中就不再列出这些代码了。
NtWaitForSingleObject()中实质性的操作只有两个。一是ObReferenceObjectByHandle(),就是通过已打开对象的Handle获取指向该目标对象(数据结构)的指针。第二个操作就是KeWaitForSingleObject(),这是下面要讲的。不过,并非对于所有的对象都可以执行这个函数,有的对象是“可等待”的,有的对象却是“不可等待”的,所以先要通过一个函数KiIsObjectWaitable()加以检验。这样,一言以蔽之,NtWaitForSingleObject()的作用就是对可等待目标对象的数据结构执行KeWaitForSingleObject()。
那么什么样的对象才是可等待的呢?看一下这个函数的代码就知道了:
[code]BOOL inline FASTCALL KiIsObjectWaitable(PVOID Object)
{
POBJECT_HEADER Header;
Header = BODY_TO_HEADER(Object);
if (Header->ObjectType == ExEventObjectType ||
Header->ObjectType == ExIoCompletionType ||
Header->ObjectType == ExMutantObjectType ||
Header->ObjectType == ExSemaphoreObjectType ||
Header->ObjectType == ExTimerType ||
Header->ObjectType == PsProcessType ||
Header->ObjectType == PsThreadType ||
Header->ObjectType == IoFileObjectType) {
return TRUE;
} else {
return FALSE;
}
}[/code]
可见,所谓“可等待”的对象包括进程、线程、Timer、文件,以及用于进程间通信的对象Event、Mutant、Semaphore,还有用于设备驱动的IoCompletion。这IoCompletion属于设备驱动框架,所以KeWaitForSingleObject()既是进程间通信的重要一环,同时也是设备驱动框架的一个重要组成部分。
注意这里(取自ReactOS)关于对象数据结构的处理是很容易让人摸不着头脑的,因而需要加一些说明。首先,每个进程的“打开对象表”是由Handle表项构成的,是一个HANDLE_TABLE_ENTRY结构指针数组。而HANDLE_TABLE_ENTRY数据结构中有个指针指向另一个数据结构(而且这个指针的低位又被用于一些标志位),可是这个数据结构并非具体对象的数据结构,而是一个通用的OBJECT_HEADER数据结构:
[code]typedef struct _OBJECT_HEADER
/*
* PURPOSE: Header for every object managed by the object manager
*/
{
UNICODE_STRING Name;
LIST_ENTRY Entry;
LONG RefCount;
LONG HandleCount;
BOOLEAN Permanent;
BOOLEAN Inherit;
struct _DIRECTORY_OBJECT* Parent;
POBJECT_TYPE ObjectType;
PSECURITY_DESCRIPTOR SecurityDescriptor;
/*
* PURPOSE: Object type
* NOTE: This overlaps the first member of the object body
*/
CSHORT Type;
/*
* PURPOSE: Object size
* NOTE: This overlaps the second member of the object body
*/
CSHORT Size;
} OBJECT_HEADER, *POBJECT_HEADER;[/code]
紧随在OBJECT_HEADER后面的才是具体对象的数据结构的正身、即Body。所以OBJECT_HEADER和Body合在一起才构成一个对象的完整的数据结构。但是,当传递一个对象的数据结构指针时,所传递的指针却既不是指向其正身,又不是指向其OBJECT_HEADER,而是指向其OBJECT_HEADER结构中的字段Type。宏定义HEADER_TO_BODY说明了这一点:
[code]#define HEADER_TO_BODY(objhdr) \
(PVOID)((ULONG_PTR)objhdr + sizeof(OBJECT_HEADER) \
- sizeof(COMMON_BODY_HEADER))[/code]
就是说,具体对象数据结构的起点是objhdr加上OBJECT_HEADER的大小、再减去COMMON_BODY_HEADER的大小。而COMMON_BODY_HEADER定义为:
[code]typedef struct
{
CSHORT Type;
CSHORT Size;
} COMMON_BODY_HEADER, *PCOMMON_BODY_HEADER;[/code]
显然这就是OBJECT_HEADER中的最后两个字段。那么具体对象的数据结构又是什么样的呢?我们以Semaphore的数据结构KSEMAPHORE为例:
[code]typedef struct _KSEMAPHORE {
DISPATCHER_HEADER Header;
LONG Limit;
} KSEMAPHORE;[/code]
它的第一个成分是一个DISPATCHER_HEADER数据结构,但是却看不到Type和Size这两个字段,也看不到COMMON_BODY_HEADER。我们进一步看DISPATCHER_HEADER的定义:
[code]typedef struct _DISPATCHER_HEADER {
UCHAR Type;
UCHAR Absolute;
UCHAR Size;
UCHAR Inserted;
LONG SignalState;
LIST_ENTRY WaitListHead;
} DISPATCHER_HEADER, *PDISPATCHER_HEADER;[/code]
与COMMON_BODY_HEADER相比较,我们确实看到这里有Type和Size,但是中间却又夹着别的字段。但是,仔细观察,就可看出在COMMON_BODY_HEADER中Type的类型是16位的CSHORT,而在这里是8位的UCHAR,而且下面Absolute的类型也是UCHAR。这就清楚了,原来COMMON_BODY_HEADER中(以及OBJECT_HEADER中)的Type虽然是16位的,实际上却只用了其低8位,而在DISPATCHER_HEADER中则将其高8位用作Absolute。编译器在分配空间时是由低到高(地址)分配的,所以Type是低8位而Absolute是高8位。同样的道理也适用于Size和Inserted。这样的安排当然使代码的可读性变得很差,笔者尚不明白为什么非得要这么干。另一方面,不同的对象有不同的数据结构,所以代码中有关对象指针的类型一般总是PVOID,这似乎也合理。但是既然第一个成分总是DISPATCHER_HEADER,那为什么不用PDISPATCHER_HEADER呢?那样至少也可以改善一些可读性。
回到NtWaitForSingleObject()的代码,我们需要进一步往下看KeWaitForSingleObject()的代码。不过在此之前先得考察一下有关的数据结构。
首先,执行这个函数的主体是个线程,而所等待的又是通过一个对象传递的信息,就一定要有个数据结构把这二者连系起来,这就是KWAIT_BLOCK数据结构:
[code]typedef struct _KWAIT_BLOCK
/*
* PURPOSE: Object describing the wait a thread is currently performing
*/
{
LIST_ENTRY WaitListEntry;
struct _KTHREAD* Thread;
struct _DISPATCHER_HEADER *Object;
struct _KWAIT_BLOCK* NextWaitBlock;
USHORT WaitKey;
USHORT WaitType;
} KWAIT_BLOCK, *PKWAIT_BLOCK;[/code]
这里的Thread和Object都是指针。前者指向一个KTHREAD数据结构,代表着正在等待的线程;后者指向一个对象的数据结构,虽然指针的类型是DISPATCHER_HEADER*,但是如上所述这是不管什么对象的数据结构中的第一个成分,所以指向这个数据结构也就是指向了它所在对象的数据结构。此外,结构中的成分WaitListEntry显然是用来把这个数据结构挂入某个(双链)队列的,同时指针NextWaitBlock也是用来维持一个(单链)队列。这是因为一个“等待块”即KWAIT_BLOCK数据结构可能同时出现在两个队列中。首先,多个线程可能在同一个对象上等待,每个线程为此都有一个等待块,从而形成特定目标对象的等待队列,这就是由WaitListEntry维持的队列。这样,对于一个具体的对象而言,其等待队列中的每个等待块都代表着一个线程。同时,一个线程又可能同时在多个对象上等待,因而又可能有多个等待块。对于这个线程而言,每个等待块都代表着一个不同的对象,这些等待块则通过NextWaitBlock构成一个队列。其余字段的作用以后就会明白。
既然等待是具体线程的行为,线程数据结构中就得有相应的安排,KTHREAD结构中与此有关的成分如下:
[code]typedef struct _KTHREAD
{
/* For waiting on thread exit */
DISPATCHER_HEADER DispatcherHeader; /* 00 */
. . . . . .
LONG WaitStatus; /* 50 */
KIRQL WaitIrql; /* 54 */
CHAR WaitMode; /* 55 */
UCHAR WaitNext; /* 56 */
UCHAR WaitReason; /* 57 */
PKWAIT_BLOCK WaitBlockList; /* 58 */
LIST_ENTRY WaitListEntry; /* 5C */
ULONG WaitTime; /* 64 */
CHAR BasePriority; /* 68 */
UCHAR DecrementCount; /* 69 */
UCHAR PriorityDecrement; /* 6A */
CHAR Quantum; /* 6B */
KWAIT_BLOCK WaitBlock[4]; /* 6C */
PVOID LegoData; /* CC */
. . . . . .
} KTHREAD;[/code]
首先我们注意到这里有个结构数组WaitBlock[4],这就是KWAIT_BLOCK数据结构座落所在,需要时就在这里就地取材。之所以是个数组,是因为有时候需要同时在多个对象上等待,这就是KeWaitForMultipleObjects()的目的,有点类似于Linux中的select()。此时KWAIT_BLOCK指针WaitBlockList指向本线程的等待块队列。如前所述,这个队列中的每个等待块都代表着一个对象。WaitStatus则是状态信息,在结束等待时反映着结束的原因。
下面我们就来看KeWaitForSingleObject()的代码。
[code][NtWaitForSingleObject() > KeWaitForSingleObject()]
NTSTATUS STDCALL
KeWaitForSingleObject(PVOID Object,
KWAIT_REASON WaitReason,
KPROCESSOR_MODE WaitMode,
BOOLEAN Alertable,
PLARGE_INTEGER Timeout)
{
PDISPATCHER_HEADER CurrentObject;
PKWAIT_BLOCK WaitBlock;
PKWAIT_BLOCK TimerWaitBlock;
PKTIMER ThreadTimer;
PKTHREAD CurrentThread = KeGetCurrentThread();
NTSTATUS Status;
NTSTATUS WaitStatus;
. . . . . .
/* Check if the lock is already held */
if (CurrentThread->WaitNext) {
⌨️ 快捷键说明
复制代码
Ctrl + C
搜索代码
Ctrl + F
全屏模式
F11
切换主题
Ctrl + Shift + D
显示快捷键
?
增大字号
Ctrl + =
减小字号
Ctrl + -