前言
前一篇文章学习了线程等待与唤醒机制。了解到无论可等待对象是何种类型,线程都是通过调用函数WaitForSingleObject或WaitForMultipleObjects进入等待状态的,这两个函数是理解线程等待与唤醒机制的核心。本篇以WaitForSingleObject为例,从函数执行流程的角度,分析线程进入等待状态,形成等待网的过程。
WaitForSingleObject
函数原型如下:
1 | DWORD WaitForSingleObject( |
WaitForSingleObject是3环函数,它不会完成什么功能,仅会根据系统服务号,去内核寻找对应的内核函数(NtWaitForSingleObject)。这里唯一需要注意的是hHandle,这是一个句柄,在句柄表一篇中提到过,句柄相当于内核对象的一个编号,通过这个编号可以获取到对应内核对象的地址。
NtWaitForSingleObject
WaitForSingleObject不会执行函数功能,会调用下一步需要执行的内核函数NtWaitForSingleObject,先来看函数原型:
1 | NTSTATUS __stdcall NtWaitForSingleObject( |
这个NtWaitForSingleObject函数它只做了两件事:
- 调用ObReferenceObjectByHandle函数,通过对象句柄找到等待对象结构体地址。
- 调用KeWaitForSingleObject函数,进入关键循环。
结论,这个NtWaitForSingleObject也没有实现线程等待功能,关键的函数是KeWaitForSingleObject。
KeWaitForSingleObject
函数原型如下:
1 | NTSTATUS KeWaitForSingleObject ( |
由于KeWaitForSingleObject函数内部实现非常复杂,这里就先不逆向分析该函数,采用海哥逆向的结论,并完成验证。
上半部分
- 向_KTHREAD(+0x70)位置的等待块赋值。
- 如果超时时间不为0,_KTHREAD(+0x70)第四个等待块将与第一个等待块关联起来:第一个等待块指向第四个等待块,第四个等待块指向第一个等待块。
- KTHREAD(+0x5C)指向第一个_KWAIT_BLOCK。
- 进入关键循环。
这里有两个要素,一个是等待块,一个是超时时间。先说等待块。
在KTHREAD(+0x70)处刚好有一个长度为4的_KWAIT_BLOCK数组,这个数组和等待块的结构体相同,因此当线程等待的对象等于或小于3个时,就会先占用这里的等待块,第4个等待块是给定时器用的。如果有4个等待对象就不会用这个位置了,它会一次性分配新的空间。下面来对此结论进行验证:
编译并运行如下代码(环境:Windows XP,Visual C++ 6.0)
c1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
HANDLE hEvent[2];
DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
::WaitForSingleObject(hEvent[0], -1);
printf("ThreadProc函数执行...\n");
return 0;
}
int main(int argc, char* argv[])
{
hEvent[0] = ::CreateEvent(NULL, TRUE, FALSE, NULL);
::CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)ThreadProc, NULL, 0, NULL);
getchar();
return 0;
}-
可以发现KTHREAD(+0x5C)处所指的等待块,刚好就是KTHREAD(+0x70)的位置(81c353e0+0x70=81c35450)
代码中只等待了一个事件,所以会使用_KTHREAD(+0x70)
可以看到,第二个和第三个等待块也都是空的,第四个等待块给了定时器用。
上面的部分,主要解决的是等待块分配的问题,最关键的核心算法,在于下面的循环。
关键循环
关键循环的逻辑大致如下
1 | while(true) //每次线程被其他线程唤醒,都要进入这个循环 |
想要弄清这个循环的逻辑,需要弄明白SignalState这个参数的含义。先看一下_DISPATCHER_HEADER结构:
1 | kd> dt _DISPATCHER_HEADER |
在线程等待与唤醒中学习过,可等待对象的特征就是其结构的第一个成员,或者其结构内部包含_DISPATCHER_HEADER结构。
现在开始理清循环的逻辑:
- 进入循环,判断当前被等待对象(例如Event)是否有信号(判断_DISPATCHER_HEADER.SignalState的值)
- 如果符合激活条件(超时、SignalState>0):
- 修改SignalState:这里不一定是清零,因为每种类型修改的方式不一样。
- 退出循环。
- 如果不符合激活条件:
- 如果是第一次执行:
- 将当前线程的等待块挂到等待对象的链表(_DISPATCHER_HEADER.WaitListHead)中。此时进入了等待网。
- 将自己挂入等待链表(KiWaitListHead),等待链表的内容可以参考之前的一篇文章。当线程自己把自己挂入等待链表以后,就相当于交付出去CPU控制权,并进行线程切换。此时循环才执行到一半,并未执行完,自己就被切换出去了。
- 当线程将自己挂入等待队列后,需要等待另一个线程将自己唤醒(设置等待对象信号SignalState>0),此时这个线程会根据其设置的等待对象WaitListHead链着的等待块,给所有等待块的所属线程一次临时复活的机会。这个临时复活意思是将这些线程从KiWaitListHead上面摘下来,也就是从等待链表上摘下来,但是,依旧挂在等待网上,所以被称作临时复活。
- 复活的线程里,第一个获取到CPU控制权的,从此处,也就是自己把自己挂入等待链表(KiWaitListHead)后的位置,继续执行。并重新进入循环的入口。
- 如果是第一次执行:
- 如果符合激活条件(超时、SignalState>0):
- 如果退出循环了,会执行到这里。这里简单讨论一下,临时复活的线程,如何完全复活。临时复活的线程,会接着执行并重新进入循环,如果它只有一个等待块,那么这个线程会符合激活条件,并退出循环。但又因为它退出循环之前,修改了SignalState的值,导致后面获得CPU控制权的不符合激活条件,并将重新挂到KiWaitListHead等待链表上。如果它有多个等待块,在判断第二个等待块时,也会因为不符合激活条件,也要重新将自己挂到KiWaitListHead上。也就是说,只有第一个获得CPU控制权,且只有一个等待块的线程,才能完全复活,别的临时复活的线程,要重新挂到等待链表上。
- 执行到这里,已经退出循环了,线程会将自己+5C的位置清0,释放_KWAIT_BLOCK所占内存,将自己从等待网上摘除,成功复活。
总结
不同的等待对象,用不同的方法来修改_DISPATCHER_HEADER.SignalState。比如:如果可等待对象是EVENT,其他线程通常使用SetEvent来设置SignalState = 1;并且,将正在等待该对象的其他线程唤醒,也就是从等待链表(KiWaitListHead)中摘出来。但是,SetEvent函数并不会将线程从等待网上摘下来,是否要下来,由当前线程自己来决定。
关于强制唤醒
在APC专题中讲过,当我们插入一个用户APC时(Alertable=1),当前线程是可以被唤醒的,但并不是真正的唤醒。因为,如果当前的线程在等待网上,执行完用户APC后,仍然要进入等待状态。
参考资料
参考教程:
- 海哥内核中级预习班
参考链接:
- https://blog.csdn.net/qq_41988448/article/details/104650212 (CSDN-lzyddf学习笔记)
- https://blog.csdn.net/weixin_42052102/article/details/83419566 (CSDN-My classmates学习笔记)