Windows事件等待学习笔记(三)—— WaitForSingleObject函数分析

  • 要点回顾
  • WaitForSingleObject
  • NtWaitForSingleObject
  • KeWaitForSingleObject:上半部分
  • 关键循环
  • 总结
  • 关于强制唤醒
  • 实验:证明等待块与等待块表的关系
  • 第一步:编译并运行以下代码
  • 第二步:再WinDbg中找到该进程
  • 第三步:查看线程信息


要点回顾

无论可等待对象是何种类型,线程都是通过 WaitForSingleObjectWaitForMultipleObjects进入等待状态的,这两个函数是理解线程等待与唤醒进制的核心

WaitForSingleObject

DWORD WaitForSingleObject(
	HANDLE hHandle,        // handle to object
	DWORD dwMilliseconds   // time-out interval);

对应的内核函数NtWaitForSingleObject

NTSTATUS __stdcall NtWaitForSingleObject(
	HANDLE Handle, 				//用户层传递的等待对象的句柄(具体细节参加句柄表专题)
	BOOLEAN Alertable, 			//对应KTHREAD结构体的Alertable属性
								//如果为1 在插入用户APC时,该线程将被吵醒  
	PLARGE_INTEGER Timeout		//超时时间);

NtWaitForSingleObject

  1. 调用ObReferenceObjectByHandle函数,通过对象句柄找到等待对象结构体地址
  2. 调用KeWaitForSingleObject函数,进入关键循环

KeWaitForSingleObject:上半部分

  1. 准备等待块,当等待对象少于四个时,并不为等待对象分配新的空间,而是向 _KTHREAD(+70) 位置的等待块赋值,_KTHREAD(+5C) 指向第一个等待块的位置
    注意:无论使用与否,_KTHREAD(+70)的第四个等待块被定时器占据,如果用的话,将会把定时器与第一个等待块相关联
  2. 如果超时时间不为0KTHREAD(+70) 第四个等待块与第一个等待块关联起来:
    第一个等待块指向第四个等待块,第四个等待块指向第一个等待块。
  3. KTHREAD(+5C) 指向第一个 _KWAIT_BLOCK
  4. 进入关键循环

关键循环

  1. 判断当前被等待对象是否有信号
    (每一个线程等待对象是通过等待块进行关联的,但是对象有一个条件:至少有一个成员为 _DISPATCHER_HEADER 结构体)
_DISPATCHER_HEADER
   +0x000 Type			//类型 可通过IDA或WinDbg查看所需对象的类型
   						//IDA:分析二进制代码
   						//WinDbg:Wait一个对象,然后进行查看
   +0x001 Absolute         
   +0x002 Size             
   +0x003 Inserted         
   +0x004 SignalState	//是否有信号(大于0表示有信号)        
   +0x008 WaitListHead  //双向链表头  圈着所有等待块
  1. 第一次循环时,若等待对象未超时,但是有信号,就不会将当前线程的等待块挂到等待对象的链表(WaitListHead)中,直接修改信号的值,退出循环
  2. 第一次循环时,若等待对象未超时,但是无信号,就将当前线程的等待块挂到等待对象的链表(WaitListHead)中,将线程自己挂入等待队列(KiWaitListHead),切换线程
  3. 当线程将自己挂入等待队列后,需要等待另一个线程将自己唤醒(设置等待对象信号量>0),当其它线程将自己唤醒后,再沿着等待网找是谁唤醒了自己,找到了之后将自己从等待链表(KiWaitListHead)中摘出,但并未从等待网中摘出
  4. 线程从哪里切换就从哪里复活

完整逻辑

while(true)//每次线程被其他线程唤醒,都要进入这个循环
{
	if(符合激活条件)//1、超时   2、等待对象SignalState>0 
	{
		//1) 修改SignalState
		//2) 退出循环
	}
	else
	{
		if(第一次执行)
		      将当前线程的等待块挂到等待对象的链表(WaitListHead)中;

		//将自己挂入等待队列(KiWaitListHead)
		//切换线程...再次获得CPU时,从这里开始执行
	}
}

退出循环

  1. 线程将自己+5C的位置清0(WaitBlockList)
  2. 释放 _KWAIT_BLOCK 所占用的内存

总结

  1. 不同的等待对象,用不同的方法来修改 _DISPATCHER_HEADER->SignalState
  2. 如果可等待对象是EVENT,其他线程通常使用SetEvent来设置SignalState = 1,并且,将正在等待该对象的其他线程唤醒,也就是从等待链表(KiWaitListHead)中摘出来,此时线程临时复活
  3. SetEvent函数并不会将线程从等待网上摘下来,是否要下来,由当前线程自己来决定
  4. 若使用SetEvent这种函数直接将线程从等待网上摘下来,将会非常麻烦,因为可能有非常多的线程在等待一个对象,无法判断该将谁摘下(一个也线程可能等待着多个对象)
    比如线程A线程B同时在等待着一个对象,这时如果有线程C调用了SetEvent(将等待对象的信号量置1),线程A线程B会被临时唤醒(从KiWaitLkistHead摘下),并行进入关键循环,假设线程A先运行,线程A会设置等待对象信号量<=0,然后将自己从等待网上摘下来,此时线程A彻底复活。线程B再去判断等待对象是否有信号量时,已经没有信号量了,这时线程B会将自己重新挂入等待链表中(有点绕,慢慢理解)
  5. 不同对象调用API修改信号个数只在细节上有差异,本质上都是一样的

关于强制唤醒

描述:在APC专题中讲过,当插入一个用户APC时(Alertable=1),当前线程是可以被唤醒的,但并不是真正的唤醒。因为如果当前的线程在等待网上,执行完用户APC后,线程仍然要进入等待状态

实验:证明等待块与等待块表的关系

第一步:编译并运行以下代码
#include <stdio.h>
#include <windows.h>

HANDLE hEvent[3];

DWORD WINAPI ThreadProc(LPVOID lpParamter)
{
	::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;
}
第二步:再WinDbg中找到该进程

Android 等待事件结果 事件等待函数_Windows


Android 等待事件结果 事件等待函数_信号量_02


Android 等待事件结果 事件等待函数_信号量_03

第三步:查看线程信息

Android 等待事件结果 事件等待函数_链表_04


Android 等待事件结果 事件等待函数_句柄_05