avatar

Catalog
互斥体

前言

前一篇文章学习了信号量(Semaphore),信号量的引出是为了解决事件(Event)解决不了的同步问题,例如生产者消费者问题。本篇将要学习的互斥体(Mutant),同样是为了解决信号量与事件解决不了的事情而诞生的。

为什么要有互斥体

等待对象被遗弃问题

互斥体(Mutant)与事件(Event)和信号量(Semaphore)一样,都可以用来进行线程的同步控制。但需要指出的是,这几个对象都是内核对象,这就意味着,通过这些对象可以进行跨进程的线程同步控制,比如:

观察上图,X和Y分别属于不同进程中的线程,它们都要用到等待对象Z,正常情况下,通过事件或者信号量便可以完成同步控制。但是在极端情况下,如果B进程的Y线程还没有来得及调用修改SignalState的函数(例如SetEvent)就挂掉了,那么等待对象Z将被遗弃,这也就意味着X线程将永远等下去!在等待对象被遗弃的情况下,事件或信号量将无法继续维持线程的同步,而互斥体可以解决此类问题。

重入问题

事件只允许同一时间有一个线程进入临界区,信号量允许同一时间有多个线程进入临界区。考虑一种特殊情况

上图中,由于代码设计的问题,在WaitForSingleObject等待A对象后,内部代码又调用了WaitForMultipleObjects等待A对象。这种情况被称作重入,当调用一次WaitForSingleObject后,A对象的SignalState变为了0,此时已经没有信号了,当调用WaitForMultipleObjects时,由于被等待对象A是没有信号的,因此代码会永远困在该函数内部。这种情况叫做死锁。事件和信号量面对重入问题都显得无能为力,而互斥体可以解决重入问题,避免死锁的情况发生。

互斥体的创建与信号设置

前面提到,互斥体(Mutant)可以解决信号量(Semaphore)和事件(Event)解决不了的问题,这里就从结构,用法等方面开始了解互斥体。

互斥体结构

首先来看互斥体结构,它所包含的字段较多

Code
1
2
3
4
5
6
7
8
9
10
11
12
_KMUTANT						
+0x000 Header : _DISPATCHER_HEADER
+0x000 Type
+0x001 Absolute
+0x002 Size
+0x003 Inserted
+0x004 SignalState
+0x008 WaitListHead
+0x010 MutantListEntry : _LIST_ENTRY
+0x018 OwnerThread : Ptr32 _KTHREAD
+0x01c Abandoned : UChar
+0x01d ApcDisable : UChar
  • Type:互斥体的Type值为2。
  • SignalState:互斥体的信号通过两个函数进行设置:
    • 互斥体创建函数CreateMutex。
    • 互斥体信号设置函数ReleaseMutex。
  • MutantListEntry:在KThread+0x10处有一个MutantListHead字段,指向链表头,链表圈着所有该线程拥有的互斥体对象,这个MutantListEntry就是挂在链表的位置(例如Kapc的ApcListEntry其结构就是挂在APC链表的位置,因此通过APC链表取的Kapc首地址时需要,减去ApcListEntry在结构体中的偏移)
  • OwnerThread:正在拥有互斥体的线程;由CreateMutex函数第二个参数决定。
  • Abandoned:是否已经被放弃不用。
  • ApcDisable:是否禁用内核APC。

CreateMutex函数

CreateMutex是用来创建互斥体的函数,函数原型如下,其中第二个参数比较重要。

Code
1
2
3
4
5
6
7
HANDLE CreateMutex(
LPSECURITY_ATTRIBUTE SlpMutexAttributes, // 指向安全属性的指针
BOOL bInitialOwner, // 初始化互斥对象的所有者
LPCTSTR lpName // 指向互斥对象名的指针
);

CreateMutex -> NtCreateMutant(内核函数) -> KeInitializeMutant(内核函数)

创建互斥体,就是对互斥体结构进行初始化,大致如下:

Code
1
2
3
4
5
6
初始化Mutant结构体:
MUTANT.Header.Type = 2;
MUTANT.Header.SignalState = bInitialOwner?0:1;
MUTANT.OwnerThread = 当前线程/NULL;
MUTANT.Abandoned = 0;
MUTANT.ApcDisable = 0;

该函数最重要的是第二个参数bInitialOwner,它对互斥体结构中的两个字段都有影响:

  • SignalState:
    • bInitialOwner=TRUE:SignalState <- 0。
    • bInitialOwner=FALSE:SignalState <- 1。
  • OwnerThread:
    • bInitialOwner=TRUE:OwnerThread存的当前线程(创建互斥体的这个线程)的地址。同时,将自己0x10处(MutantListEntry)的地址挂到当前线程MutantListHead(KThread+0x10)指向的链表上。
    • bInitialOwner=FALSE:OwnerThread值为NULL。

ReleaseMutex函数

ReleaseMutex函数用于设置互斥体的信号(类似事件的SetEvent,信号量的ReleaseSemaphore)

Code
1
2
3
BOOL WINAPI ReleaseMutex(HANDLE hMutex);

ReleaseMutex -> NtReleaseMutant(内核函数) -> KeReleaseMutant(内核函数)

正常调用时:Mutant._DISPATCHER_HEADER.SignalState++。

如果SignalState=1,说明其它进程可以使用这个互斥体了,此时互斥体会从线程链表(KThread+0x10,MutantListHead指向的那个链表)中移除。这句话不好理解,需要结合代码分析,它可以用来解决 “等待对象被遗弃问题” 。

如何解决重入问题

前面提到,重入问题是事件(Event)与信号量(Semaphore)解决不了的,只有互斥体(Mutant)可以解决,这里就简单分析一下解决重入问题的过程。

OwnerThread的作用

回顾互斥体结构_KMUTANT,有一个字段OwnerThread,它就是解决重入的关键。

若一个互斥体被创建时,它的OwnerThread字段不为空,创建它的线程即为互斥体的所属线程。此时,初始化的互斥体SignalState字段被设置为0,也就是没有信号,这个时候别的线程是没法使用这个互斥体的。但是创建它的线程仍然可以使用。并且可以重复使用0x8000000次,这也是为什么互斥体可以重入的原因,因为创建它的线程可以在没有信号的情况下使用互斥体。至于为何创建它的线程在互斥体没有信号的情况下也可以使用,来看下面对KeWaitForSingleObject的分析。

KeWaitForSingleObject分析

这个函数可以说是同步系列的核心函数了,事件,信号量,互斥体都会调用它。

  • 首先定位到紫色方框部分,这里是开头,会先判断等待对象类型,若是互斥体,就接着执行;若是信号量或者事件,就跳转。
  • 然后到橙色方框这里,会根据SignalState的值判断是否有信号,如果有信号,就跳转。
  • 如果没有信号,会先到红色方框这里,判断当前线程与互斥体所属线程是否相同如果相同,就会跳到和有信号时一样的地方。也就是说,即使没有信号,互斥体也可以被它的所属线程使用。这样,对于拥有互斥体的线程,就可以重入该互斥体。那么究竟可以重入多少次呢?接着分析。
  • 跳转后,会先判断SignalState的值是否与0x80000000相等,也就是说,只要不等于0x80000000,就可以继续执行,执行到蓝色方框的时候,会给SignalState的值减去1。这里要分两种情况:
    • SignalState有信号:说明此时SignalState值大于0,任一线程可以等待这个互斥体,且没有重入。
    • SignalState无信号: 此时的SignalState值为小于等于0,执行到这一步,说明当前线程一定拥有该互斥体,此时SignalState仍会减1,最多可以减少至0x80000000。

结论:由于互斥体进入激活条件有2个(SignalState有信号,线程拥有该互斥体),满足任何一种情况,均可进入激活状态, 从而解决了重入的问题。

如何解决等待对象被遗弃问题

接下来是另一个问题,关于如何解决等待对象被遗弃的问题,就像文章开篇提到的情况,当一个线程还没有设置等待对象的信号(例如SetEvent),那么另一个对象就只能一直等待下去了。来看看互斥体是如何解决这个问题的。

互斥体定义了MutantListEntry和Abandoned两个字段,在处理等待对象遗弃的情况时会用到。

当一个线程异常 “死亡” 时,系统会调用内核函数MmUnloadSystemImage处理后事,它会根据 “死亡线程” 0x10位置指向的链表头,找到它所拥有的所有互斥体。将这些互斥体Abandoned字段值设置为1,并对它们调用KeReleaseMutant(X, Y, Abandon, Z)函数(ReleaseMutant的内核函数)。

KeReleaseMutant函数的逻辑如下:

前面提到,正常情况下(Abandon=FALSE),该函数仅对SignalState作加1的操作。如果出现,互斥体所属线程突然死亡的情况(Abandon=TRUE),该函数会将SignalState直接设置为1,并且将互斥体所属线程设置为NULL,同时把自己从死亡线程的互斥体链表中移除。这样互斥体便可再为其它线程所使用,从而解决了等待对象被遗弃问题。

关于禁用内核APC

前面的介绍里,互斥体仍有一个字段未被解释清楚,就是最后这个ApcDisable(KMUTANT+0x1d)

互斥体分为2种,一种是用户用的,另一种是内核用的,区别如下:

  • 用户互斥体
    • 结构名:Mutant(在3环被创建)
    • 对应内核函数:NtCreateMutant
    • ApcDisable:0
  • 内核互斥体
    • 结构名:Mutex(在0环被创建)
    • 对应内核函数:NtCreateMutex
    • ApcDisable:1

用户互斥体与内核互斥体结构名不同,但是结构体相同,主要的区别在于ApcDisable字段。用户互斥体是允许内核APC执行的,但是内核互斥体是不允许内核APC进行执行的。另一个细节是,互斥体只是一个内核对象,它是如何限制线程的执行(内核APC的执行)的呢?

在KeWaitForSingleObject中有如上的代码,可以根据ApcDisable的值修改KThread.KernelApcDisable(正在使用互斥体的线程),若ApcDisable值为0,则KernelApcDisable的值不会发生改变。若ApcDisable值为1,则KernelApcDisable的值将会减1,此时KernelApcDisable将会是一个不为0的值,内核APC将会被禁用(根据内核APC执行过程,若KernelApcDisable的值不为0,内核APC将会被禁用)。

参考资料

参考教程:

  • 海哥中级预习班教程

参考链接:

  1. https://blog.csdn.net/weixin_42052102/article/details/83473993 (CSDN-My classmates学习笔记)
  2. https://blog.csdn.net/qq_41988448/article/details/104895544 (CSDN-lzyddf学习笔记)
Author: cataLoc
Link: http://cata1oc.github.io/2020/08/17/%E4%BA%92%E6%96%A5%E4%BD%93/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
Donate
  • 微信
    微信
  • 支付寶
    支付寶