同步篇——内核对象

写在前面

  此系列是本人一个字一个字码出来的,包括示例和实验截图。由于系统内核的复杂性,故可能有错误或者不全面的地方,如有错误,欢迎批评指正,本教程将会长期更新。 如有好的建议,欢迎反馈。码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作。如想转载,请把我的转载信息附在文章后面,并声明我的个人信息和本人博客地址即可,但必须事先通知我

你如果是从中间插过来看的,请仔细阅读 羽夏看Win系统内核——简述 ,方便学习本教程。

  看此教程之前,问几个问题,基础知识储备好了吗?保护模式篇学会了吗?练习做完了吗?没有的话就不要继续了。


🔒 华丽的分割线 🔒


前言

  之前讲过,线程在进入临界区之前会调用WaitForSingleObject或者WaitForMultipleObjects,此时如果有信号,线程会从函数中退出并进入临界区,如果没有信号那么线程将自己挂入等待链表,然后将自己挂入等待网,最后切换线程。注意我们这里的临界区是指只允许一个线程进入直到退出的一段代码,不单指用EnterCriticalSectionLeaveCriticalSection而形成的临界区。
  其他线程在适当的时候,调用方法修改被等待对象的SignalState为有信号(不同的等待对象,会调用不同的函数),并将等待该对象的其他线程从等待链表中摘掉,这样,当前线程便会在WaitForSingleObject或者WaitForMultipleObjects恢复执行(在哪切换在哪开始执行),如果符合唤醒条件,此时会修改SignalState的值,并将自己从等待网上摘下来,此时的线程才是真正的唤醒。
  下面我将介绍不同的可等待内核对象之间的不同之处和实现,但是具体细节将会在总结与提升进行。在讲解之前我们把关键的结构体放到下面:

kd> dt _DISPATCHER_HEADER
ntdll!_DISPATCHER_HEADER
   +0x000 Type             : UChar
   +0x001 Absolute         : UChar
   +0x002 Size             : UChar
   +0x003 Inserted         : UChar
   +0x004 SignalState      : Int4B
   +0x008 WaitListHead     : _LIST_ENTRY

事件

  首先我们看看事件的内核结构体:

kd> dt _KEVENT
ntdll!_KEVENT
   +0x000 Header           : _DISPATCHER_HEADER

  可以看出事件这个内核对象十分简洁,就一个内嵌的必要的子结构体,没有啥杂七杂八的东西。我们通常用CreateEvent函数来进行创建使用这个内核对象,下面我们来讲解与同步相关的参数,如下是函数原型:

HANDLE WINAPI CreateEventW(
    LPSECURITY_ATTRIBUTES lpEventAttributes,
    BOOL bManualReset,
    BOOL bInitialState,
    LPCWSTR lpName
    );

  我们重点讲解中间两个参数。

bManualReset

  该值类型为布尔型,如果为True,则为通知类型对象;反之则为普通的事件同步对象。
  这个参数影响_DISPATCHER_HEADERType值,如果为通知类型对象,它的值为0,否则为1。我们可以做如下实验进行验证:

#include "stdafx.h"
#include <windows.h>
#include <stdlib.h>

HANDLE hEvent;

DWORD WINAPI ThreadProc1(LPVOID param)
{
   WaitForSingleObject(hEvent,INFINITE);
   puts("等待线程1执行!");
   return 0;
}

DWORD WINAPI ThreadProc2(LPVOID param)
{
   WaitForSingleObject(hEvent,INFINITE);
   puts("等待线程2执行!");
   return 0;
}

int main(int argc, char* argv[])
{
   hEvent = CreateEvent(NULL,TRUE,FALSE,NULL);
   CloseHandle(CreateThread(NULL,NULL,(LPTHREAD_START_ROUTINE)ThreadProc1,NULL,NULL,NULL));
   CloseHandle(CreateThread(NULL,NULL,(LPTHREAD_START_ROUTINE)ThreadProc2,NULL,NULL,NULL));
   SetEvent(hEvent);
   system("pause");
   CloseHandle(hEvent);
   return 0;
}

  如上代码就是通知型对象,我们看一看效果:

  可以看到,这两个线程都执行了,如果我把第二个参数改为FALSE,我们再看看效果:

  从实验结果我们可以看出,这里仅执行了一个线程,另一个线程仍处于等待状态,为什么出现这样的结果还是逆向WaitForSingleObject才能明白。

bInitialState

  这个就是设置_DISPATCHER_HEADERSignalState初始值的,TRUE就是1,反之就是0。我们将上面我们做实验的代码的第三个参数改为TRUE,并注释掉SetEvent函数,实验效果如下:

  关于事件就介绍这么多,其余的详细细节将会在总结与提升进行介绍。

信号量

  有了事件这一个可等待对象,为什么要有信号量这东西。信号量又是什么。

信号量(Semaphore),有时被称为信号灯,是在多线程环境下使用的一种设施,是可以用来保证两个或多个关键代码段不被并发调用。在进入一个关键代码段之前,线程必须获取一个信号量;一旦该关键代码段完成了,那么该线程必须释放信号量。

  这个虽然听起来说了信号量是啥,但又啥也没说。我们将通过示例来进行讲解。
  我们之前使用事件这个东西,如果有信号,如果是用于同步的类型,设置信号只能让1个线程通过WaitForSingleObjectSetEvent组成的代码临界区;如果是通知类型,就让全部的线程通过,示意图如下:

  但是有种情况,我让其有信号的时候,假设有5个线程,但我让3个线程执行,这个是事件方便实现的,于是乎,信号量应运而生,如下是其示意图:

  如果做项目比较多的话,就会遇到生成者线程和消费者线程问题,如下图所示:

  如果是上面第一个情况,这两种线程各一个线程,运用事件就可以很好的解决问题。但是对于第二种情况,这就不太适用了。如果我让这代表全局变量的三个绿块都加1,把它们都激活或者一个一个激活总是不太方便的,这个问题通过信号量就能轻松解决。我们来看看创建信号量的函数原型:

HANDLE WINAPI CreateSemaphoreW(
    LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
    LONG lInitialCount,
    LONG lMaximumCount,
    LPCWSTR lpName
    );

  然后看看信号量的结构体长啥样子:

kd> dt _KSEMAPHORE
ntdll!_KSEMAPHORE
   +0x000 Header           : _DISPATCHER_HEADER
   +0x010 Limit            : Int4B

lInitialCount

  这个参数会影响_DISPATCHER_HEADERSignalState的值。它不会像事件只设置0或者1,它可以设置更大的数,这个就是实现指定数目线程执行的关键所在。

lMaximumCount

  这个参数会影响KSEMAPHORE结构体中的Limit这个成员,是信号量对象的最大计数。

ReleaseSemaphore 浅析

  既然有创建信号量,就有释放信号量函数,让的调用流程如下:

graph TD ReleaseSemaphore --> NtReleaseSemaphore --> KeReleaseSemaphore

  有关信号量的其他具体细节将会在同步与提升进行讲解。

互斥体

  前面有事件、信号量,为什么还要有互斥体这个东西。肯定一个东西的出现必须解决一些问题。互斥体MUTANT与事件EVENT和信号量SEMAPHORE一样,都可以用来进行线程的同步控制。但是,这几个对象都是内核对象,这就意味着,通过这些对象可以进行跨进程的线程同步控制。假设有A进程中的X线程和B进程中的Y线程,它们都在等待内核对象Z。如果B进程的Y线程还没有来得及调用修改SignalState的函数,那么等待对象Z将被遗弃,这也就以为者X线程将永远等下去。
  互斥体还解决临界区冲入的问题,如下是一段代码:

WaitForSingleObject(A)
.....
WaitForMultipleObjects(A,B,C)
.....

  其中ABC都是等待对象。开始代码我们等待A,然后又继续等待ABC,如果它们都有一次信号,那么线程就会停到WaitForMultipleObjects不动,这就是所谓的死锁。
  介绍互斥体之前,我们看看其内部结构:

kd> dt _KMUTANT
nt!_KMUTANT
   +0x000 Header           : _DISPATCHER_HEADER
   +0x010 MutantListEntry  : _LIST_ENTRY
   +0x018 OwnerThread      : Ptr32 _KTHREAD
   +0x01c Abandoned        : UChar
   +0x01d ApcDisable       : UChar

  OwnerThread指向的是正在拥有互斥体的线程结构体。Abandoned指示是否已经被弃用的标志。ApcDisable指示是否禁用内核APC
  那么,互斥体是如何解决等待对象被遗弃问题呢?原因就存在MutantListEntry这个起作用,这个也是用来串糖葫芦的。在线程结构体的0x10偏移处的MutantListHead成员把拥有互斥体串串。当程序卸载时会调用MmUnloadSystemImage,最终会调用KeReleaseMutant这个函数来释放互斥体,而这个函数就会用到Abandon这个值。具体细节将会在总结与提升进行分析。
  对于应用层的内核结构Mutant,内核有一个和这个结构一模一样的Mutex。但是它们的区别就是ApcDisable值不一样,Mutex的值为1,而Mutant的值为0,可以通过它的初始化代码可以看出。其余的细节将会在下一篇进行讲解。

下一篇

  同步篇——总结与提升

posted @ 2022-02-12 09:07  寂静的羽夏  阅读(390)  评论(0编辑  收藏  举报