事件等待

事件等待

并发是指多个线程在同时执行:

  • 单核(是分时执行,不是真正的同时)
  • 多核(在某一个时刻,会同时有多个线程在执行)

同步则是保证在并发执行的各个环境中可以有序的执行

不同版本的内核文件

  • 单核
    • ntkrnlpa.exe 2-9-9-12分页
    • ntoskrnl.exe 10-10-12分页
  • 多核
    • ntkrnlpa.exe 2-9-9-12分页
    • ntoskrnl.exe 10-10-12分页

同样是这个文件名,单核和多核里面的代码是不一样的

为什么需要事件等待

首先需要明确计算机的核心数和线程数

超线程技术 : 逻辑核心 = 物理核心 * 每个核心的线程数(此线程数是可真正并行的线程数)

操作系统创建的线程是通过对逻辑核心的分片来虚拟出的线程,因此可以存在无数个

单行代码原子操作

也称为原子指令

LOCK指令

1
2
3
mov eax,[0x12345678]
add eax,1
mov [0x12345678],eax

如果产生线程切换就会出问题,因此是多线程不安全的

1
INC DWORD ptr ds:[0x12345678]

如果只有单核在跑,上述指令是安全的,但是在多核下可能会出现两个CPU同时执行这一个汇编指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int key = 0;
int x = 1000;
void threadCall()
{
Sleep(1);
__asm
{
dec [x];
}
}
void main()
{
for (int i = 0; i < 1000; i++)
{
HANDLE hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)threadCall, NULL, 0, NULL);
}
Sleep(10000);
printf("%d\r\n", x);
getchar();
}

image-20211123140139897

上图表示多核同时执行的时候,多次反复吞掉了中间值,因此不是0。

dec [x]前加上lock才能保证其是原子操作,即结果为0

image-20211123140514418

也是说lock后,就是一个原子操作。原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程),在单处理器系统(UniProcessor,简称 UP)中,能够在单条指令中完成的操作都可以认为是原子操作,因为中断只能发生在指令与指令之间。在多处理器系统(Symmetric Multi-Processor,简称 SMP)中情况有所不同,由于系统中有多个处理器在独立的运行,即使在能单条指令中完成的操作也可能受到干扰。

1
LOCK INC DWORD ptr ds:[0x12345678]

这样就真正实现了多核的原子操作(多线程安全),lock指令锁住了这条指令所在的内存,某一时刻只能有单核访问。保证了这条指令在多处理器环境中 的原子性

lock只能接如下汇编指令,不然会产生操作码异常(ud)

image-20211119150018704

LOCK前缀只能预加在以下指令前面,并且只能加在这些形式的指令前面,其中目标操作数是内存操作数:add、adc、and、btc、btr、bts、cmpxchg、cmpxch8b,cmpxchg16b,dec,inc,neg,not,or,sbb,sub,xor,xaddxchg

xchg指令不管有没有声明LOCK前缀,总是会声明LOCK信号。

参考:kernel32.InterlockedIncrement

原子操作相关的API:

  • InterlockedIncrement 全局变量++
  • InterlockedDecrement 全局变量–
  • InterlockedExchange 交换
  • InterlockedCompareExchange 比较交换
  • InterlockedExchangeAdd
  • InterlockedFlushSList
  • InterlockedPopEntrySList
  • InterlockedPushEntrySList

InterlockedIncrement逆向

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.text:7C8097F6 ; LONG __stdcall InterlockedIncrement(volatile LONG *lpAddend)
.text:7C8097F6 public _InterlockedIncrement@4
.text:7C8097F6 _InterlockedIncrement@4 proc near ; CODE XREF: CreatePipe(x,x,x,x)+57↓p
.text:7C8097F6 ; BasepCreateDefaultTimerQueue()+41↓p ...
.text:7C8097F6
.text:7C8097F6 lpAddend = dword ptr 4
.text:7C8097F6
.text:7C8097F6 mov ecx, [esp+lpAddend]
.text:7C8097FA mov eax, 1
.text:7C8097FF
.text:7C8097FF loc_7C8097FF: ; DATA XREF: .data:_BasepLockPrefixTable↓o
.text:7C8097FF lock xadd [ecx], eax;lock保证了该内存空间同时只能有一个核操作;xadd表示交换加,即先将两个数交换,再将二者之和送给第一个数。
.text:7C809803 inc eax;函数返回值返回加后的结果
.text:7C809804 retn 4
.text:7C809804 _InterlockedIncrement@4 endp

多行代码原子操作

临界区:一次只允许一个线程进入直到离开

实现临界区的方式就是加锁

:全局变量,进去加1,出去减1

image-20210925185126974

自己实现临界区:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
;全局变量:Flag = 0,临界区标志
;进入临界区裸函数:
Lab:
mov eax,1;
lock xadd [Flag],eax;//必须先+1,再判断
cmp eax,0;//判断+1前原来的值是否为0
jz endLab;//+1前原来的值为0的话跳转进入临界区
dec [Flag];
;//线程等待
endLab:
ret;

;离开临界区裸函数:
lock dec [Flag]

实验

进入临界区enter函数,退出临界区leave函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void __declspec(naked) __fastcall enter(PVOID lock)
{
__asm
{
__start:
mov eax, 1;
lock xadd dword ptr[ecx], eax;
cmp eax, 0;
jnz label_enter;
retn;
label_enter:
lock dec[ecx];
push ecx;//因为下面要回到__start,因此保留ecx防止被SleepEx破坏了,去除会报错
//3)下面线程切换
push 1;
push 1;
call SleepEx;
pause;//线程切换可以换成自旋锁,空转会比较卡
pop ecx;
jmp __start;
}
}

void __declspec(naked) __fastcall leave(PVOID lock)
{
__asm
{
lock dec[ecx];
retn;
}
}

未进入临界区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int key = 0;
int x = 0;
void threadCall()
{
Sleep(100);
//enter(&key);//未进入临界区
x=x+1;
//leave(&key);
Sleep(100);
}

void main()
{

for (int i = 0; i < 1000; i++)
{
HANDLE hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)threadCall, NULL, 0, NULL);
}
Sleep(6000);
printf("%d\r\n",x);
getchar();
}

image-20211120121206696

进入临界区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int key = 0;
int x = 0;
void threadCall()
{
Sleep(100);
enter(&key);//进入临界区
x=x+1;
leave(&key);
Sleep(100);
}

void main()
{

for (int i = 0; i < 1000; i++)
{
HANDLE hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)threadCall, NULL, 0, NULL);
}
Sleep(6000);
printf("%d\r\n",x);
getchar();
}

image-20211120121347166

LOCK能保证某个处理器对存储该代码的共享内存的独占使用

问题

为什么离开临界区也要加lock,不然临界区无效??

系统进出临界区函数

有进入临界区函数RtlEnterCriticalSection,以及离开临界区函数RtlLeaveCriticalSection

ntdll中有进入临界区函数RtlEnterCriticalSection,以及离开临界区函数RtlLeaveCriticalSection

Windows自旋锁

关键代码:

1
lock bts dword ptr [ecx],0;

LOCK是锁前缀,保证这条指令在同一个时刻只能有一个CPU访问

BTS指令:设置并检测,将ECX指向数据的第0位置1,如果[ECX]原来的值==0,那么CF=1,否则CF=0

参考:KeAcquireSpinLockAtDpcLevel(SpinLock自旋锁)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.text:0040B40B ; __stdcall KeAcquireSpinLockAtDpcLevel(x)
.text:0040B40B public _KeAcquireSpinLockAtDpcLevel@4
.text:0040B40B _KeAcquireSpinLockAtDpcLevel@4 proc near
.text:0040B40B
.text:0040B40B arg_0 = dword ptr 4
.text:0040B40B
.text:0040B40B mov ecx, [esp+arg_0];ecx得到要锁的地址
.text:0040B40F
.text:0040B40F loc_40B40F: ; CODE XREF: KeAcquireSpinLockAtDpcLevel(x)+14↓j
.text:0040B40F lock bts dword ptr [ecx], 0
.text:0040B414 jb short loc_40B419;jb表示CF==1的时候跳转,如果[ecx]原来的值不为0就跳转
.text:0040B416 retn 4
.text:0040B419 ; ---------------------------------------------------------------------------
.text:0040B419
.text:0040B419 loc_40B419: ; CODE XREF: KeAcquireSpinLockAtDpcLevel(x)+9↑j 已经有线程进入临界区了
.text:0040B419 ; KeAcquireSpinLockAtDpcLevel(x)+18↓j
.text:0040B419 test dword ptr [ecx], 1;
.text:0040B41F jz short loc_40B40F;[ecx]原来的值为0就跳转回去重新试图进入临界区
.text:0040B421 pause;让当前的CPU空转一会儿,降降温,因此叫自旋锁,在多核才有意义
.text:0040B423 jmp short loc_40B419
.text:0040B423 _KeAcquireSpinLockAtDpcLevel@4 endp

总结:

  1. 自旋锁只对多核有意义。(查看不同版本的KeAcquireSpinLockAtDpcLevel函数)
  2. 自旋锁与临界区,事件,互斥体一样,都是一种同步机制,都可以让当前线程处于等待状态,区别在于自旋锁不用切换线程(自旋,即pause空转)。

线程等待与唤醒

之前讲解了如何自己实现临界区以及什么是Windows自旋锁,这两种同步方案在线程无法进入临界区时都会让当前线程进入等待状态,一种是通过Sleep函数实现的,一种是通过让当前的CPU“空转”实现的,但这两种等待方式都有局限性:

  1. 通过Sleep函数进程等待,等待时间该如何确定呢?
  2. 通过“空转”的方式进行等待,只有等待时间很短的情况下才有意义,否则对CPU资源是种浪费,而且自旋锁只能在多核环境下才有意义。

有没有更加合理的等待方式呢?只有在条件成熟的时候才将当前线程唤醒?

Windows的等待与唤醒机制

在Windows中,一个线程可以通过等待一个或者多个可等待对象,从而进入等待状态,另一个线程可以在某些时刻唤醒等待这些对象的其他线程

image-20210927173327826

如下可等待对象结构体

结构体 结构类型
_KPROCESS 进程
_KTHREAD 线程
_KTIMER 定时器
_KSEMAPHORE 信号量
_KEVENT 事件
_KMUTANT 互斥体
_FILE_OBJECT 文件

其共同点是他们都有一个成员是_DISPATCHER_HEADER内嵌结构体。

_FILE_OBJECT没有_DISPATCHER_HEADER内嵌结构体的成员,但它含有_KEVENT内嵌结构体

可等待对象的差异

image-20210927174427599

一个线程等待一个对象

image-20211006113147014

当前线程通过等待块_KWAIT_BLOCK与可等待对象建立起了联系

线程结构体+5C的位置成员WaitBlockList,指向等待块_KWAIT_BLOCK结构体

_KWAIT_BLOCK结构体
1
2
3
4
5
6
7
8
9
10
typedef struct _KWAIT_BLOCK
{
LIST_ENTRY WaitListEntry;//存储了当前可等待对象被别的线程等待的所有等待块的双向链表,该双向链表头是_DISPATCH_HEADER的WaitListHead成员
PKTHREAD Thread;//当前等待块是哪个线程的
PVOID Object;//被等待对象的地址。比如等待对象为进程,则此处为进程内核地址
PKWAIT_BLOCK NextWaitBlock;//单向循环链表,指向当前线程的下一个等待块,如果当前线程只有一个等待块则指向自己
WORD WaitKey;//等待块的索引,由0开始
UCHAR WaitType;//设置等待所有可等待对象执行完毕才执行就是0,如果设置等待所有可等待对象其中一个执行完毕就执行此位就是1.
UCHAR SpareByte;
} KWAIT_BLOCK, *PKWAIT_BLOCK;
_DISPATCH_HEADER结构体
1
2
3
4
5
6
7
8
9
struct _DISPATCHER_HEADER
  {
   UCHAR Type;        //类型(Event-0或1,互斥体-2,信号量-5,Thread....)   
   UCHAR Absolute;
   UCHAR Size;
   UCHAR Inserted;  
   LONG SignalState;   //是否有信号,>0表示有信号
   struct _LIST_ENTRY WaitListHead; //存储了当前可等待对象被别的线程等待的所有等待块的双向链表头
  };

一个线程等待多个对象

image-20211006113218139

等待网

image-20211006120626662

一个线程只要进入等待链表,他一定在这张网上挂着(Sleep的线程也在这个网上挂着,只是他等待的可等待对象是定时器)

总结

  1. 等待中的线程,一定在等待链表中(KiWaitListHead),同时也一定在这张网上(KTHREAD+5C的位置不为空)
  2. 线程通过调用WaitForSingleObject/WaitForMultipleObjects函数将自己挂到这张网上。
  3. 线程什么时候会再次执行取决于其他线程何时调用相关函数,等待对象不同调用的函数也不同。

WaitForSingleObject函数分析

无论可等待对象是何种类型,线程都是通过:

  • WaitForSingleObject
  • WaitForMultipleObjects

进入等待状态的,这两个函数是理解线程等待与唤醒机制的核心

1
2
3
4
5
//WaitForSingleObject对应的内核函数:
NTSTATUS __stdcall NtWaitForSingleObject(
HANDLE Handle,
BOOLEAN Alertable,
PLARGE_INTEGER Timeout)

参数解读:

  • Handle—用户层传递的等待对象的句柄
  • Alertable—对应KTHREAD结构体的Alertable属性,若为1,在插入用户APC的时候,该线程将被吵醒
  • Timeout—超时时间

WaitForSingleObject函数流程

  1. 调用ObReferenceObjectByHandle函数,通过对象句柄找到等待对象结构体地址
  2. 调用KeWaitForSingleObject函数,进入关键循环
KeWaitForSingleObject函数流程
1
2
3
4
5
6
7
8
9
10
11
 //_KTHREAD结构体节选:
+0x05c WaitBlockList : Ptr32 _KWAIT_BLOCK//指向第一个等待块
//下面两成员是同一个位置,表示的是同一个成员的两种情况
+0x060 WaitListEntry : _LIST_ENTRY//当线程处于等待状态的时候指向等待链表(8字节)
+0x060 SwapListEntry : _SINGLE_LIST_ENTRY//等待线程的内存交换到硬盘的信息记录在该链表(8字节)
+0x068 WaitTime : Uint4B
+0x06c BasePriority : Char//优先级,其初始值是所属进程的BasePriority值(KPROCESS->BasePriority),以后可以通过KeSetBasePriorityThread()函数重新设定
+0x06d DecrementCount : UChar
+0x06e PriorityDecrement : Char
+0x06f Quantum : Char//当前线程剩余的CPU时间片
+0x070 WaitBlock : [4] _KWAIT_BLOCK//等待哪个对象

+0x070的WaitBlock预留4个等待块空间。第四个等待块空间是固定给定时器使用的。

如果等待的可等待对象数量>3个,那么就会新分配空间,而不是存在默认的这个空间。

上半部分
  1. 向_KTHREAD(+70)位置的等待块赋值
  2. 如果超时时间不为0(这种情况下,即使只等待一个可等待对象,也实际上是等待两个对象,这种情况下第四个等待块位置是固定给定时器用的),KTHREAD(+70)第四个等待块与第一个等待块关联起来:第一个等待块指向第四个等待块,第四个等待块指向第一个等待块(就是等待块中的NextWaitBlock成员指向,如果不设置超时时间,NextWaitBlock是指向自己)
  3. KTHREAD(+5c)指向第一个_KWAIT_BLOCK
  4. 进入关键循环
下半部分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
while(true)//每次线程被其他线程唤醒,都要进入这个循环
{
if(符合激活条件)//1.超时 2.等待对象SingnalState>0(有信号)
{
//1.修改SignalState(不一定是清零,针对不同可等待对象类型的处理方式不一样)
//2.退出循环
}
else
{
if(第一次执行)
将当前线程的等待块挂到等待对象的链表(WaitListHead)中;(挂入等待网)
//将自己挂入等待队列(KiWaitListHead)
//切换线程...再次获得CPU时,从这里开始执行
}
}
//1.线程将自己的+5C位置清零(从等待网中摘出该线程,线程真正复活)
//2.释放_KWAIT_BLOCK所占内存

KeWaitForSingleObject和KeWaitForMultipleObjects的唯一区别仅仅:是前者挂一到两个等待块,后者挂多个等待块。

【重点理解】妙不可言

不同的等待对象,用不同的方法来修改_DISPATCHER_HEADER(SignalState)比如:如果可等待对象是EVENT,其他线程通常使用SetEvent来设置SignalState = 1,并且将正在等待该对象的其他线程临时唤醒,也就是从等待链表(KiWaitListHead)中摘出来(线程临时复活)。但是,SetEvent函数并不会将线程从等待网上摘下来,是否要下来由当前线程自己来决定。

强制唤醒

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

不同的可等待对象的两点差异:

  1. 符合激活条件处不同
  2. 修改SignalState处不同

事件EVENT

事件的**_DISPATCHER_HEADER中的Type为0或1**

线程在进入临界区之前会调用WaitForSingleObject或者WaitForMultipleObjects

  • 此时如果有信号,线程会从函数中退出并进入临界区
  • 如果没有信号,那么线程将自己挂入等待链表,然后将自己挂入等待网,最后切换线程

其他线程在适当的时候,调用方法修改被等待对象的SignalState为有信号(不同的等待对象会调用不同的函数),并将等待该对象的其他线程从等待链表中摘掉,这样,当前线程便会在WaitForSingleObject或者WaitForMultipleObjects恢复执行(在哪线程切换在哪恢复执行),如果符合唤醒条件此时会修改SignalState的值,并将自己从等待网上摘下来,此时的线程才是真正的唤醒。

创建事件对象

1
CreateEvent(NULL,TRUE,FALSE,NULL);
  • 上面函数的第三个参数就是代表了信号初始值,即_DISPATCHER_HEADER中的SignalState成员。
  • 第二个参数本质就是_DISPATCHER_HEADER中的Type成员(反值)
    • TRUE—-通知类型对象—-_DISPATCHER_HEADER中的Type成员为0
    • FALSE—-事件同步对象—-_DISPATCHER_HEADER中的Type成员为1

SetEvent函数分析

SetEvent对应的内核函数:KeSetEvent

  1. 修改信号值SignalState为1
  2. 判断对象类型
    • 如果类型为通知类型对象(0),唤醒所有等待该状态的线程
    • 如果类型为事件同步对象(1),从链表头找到第一个WaitType为1(表示等待一个而非等待全部)的等待块

节选KeWaitForSingleObject单内核版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
.text:00405168 loc_405168:                             ; CODE XREF: KeWaitForSingleObject(x,x,x,x,x)+C0↓j
.text:00405168 cmp [ebx+_DISPATCHER_HEADER.SignalState], 0 ; 判断等待对象的SignalState
.text:0040516C jle loc_40527A ; <=0就是没有信号,不符合激活条件
.text:00405172 mov al, [ebx+_DISPATCHER_HEADER.Type] ; 若大于0,取事件类型
.text:00405174 mov cl, al
.text:00405176 and cl, 7
.text:00405179 cmp cl, 1
.text:0040517C jz loc_40E778 ; 这个类型决定了函数如何处理SignalState type为1则跳,跳转后就是SignalState清零再跳回loc_405189
.text:0040517C ;
.text:0040517C ; 信号量:递减
.text:0040517C ;
.text:0040517C ; 事件:0--不修改SignalState 1--SignalState清零
.text:00405182 cmp al, 5
.text:00405184 jnz short loc_405189
.text:00405186 dec [ebx+_DISPATCHER_HEADER.SignalState] ; 将SignalState减1
.text:00405189
.text:00405189 loc_405189: ; CODE XREF: KeWaitForSingleObject(x,x,x,x,x)-1C↑j
.text:00405189 ; KiScanReadyQueues()+EF↓j
.text:00405189 xor edi, edi
.text:0040518B jmp loc_4052AC
.text:00405248 loc_405248: ; CODE XREF: KeWaitForSingleObject(x,x,x,x,x)+126↓j
.text:00405248 mov edx, [ebp+Timeout] ; 关键循环
.text:0040524B
.text:0040524B loc_40524B: ; CODE XREF: KeWaitForSingleObject(x,x,x,x,x)+3B0EC↓j
.text:0040524B ; KeWaitForSingleObject(x,x,x,x,x)+3B0F5↓j
.text:0040524B cmp [esi+_KTHREAD.ApcState.KernelApcPending], 0 ; 每次循环先判断是否有内核APC,有就先执行
.text:0040524F mov eax, ds:_KeTickCount.LowPart
.text:00405254 mov [esi+68h], eax ; 修改当前线程的等待时间
.text:00405257 jnz loc_4461F7 ; 如果有内核APC就去执行
.text:0040525D
.text:0040525D loc_40525D: ; CODE XREF: KeWaitForSingleObject(x,x,x,x,x)+4105D↓j
.text:0040525D cmp [ebx+_DISPATCHER_HEADER.Type], 2 ; 判断等待对象的类型是否是互斥体(2为互斥体)
.text:00405260 jnz loc_405168 ; 如果不是互斥体,跳转
.text:00405266 mov eax, [ebx+4]
.text:00405269 test eax, eax
.text:0040526B jg loc_40D364
.text:00405271 cmp esi, [ebx+18h]
.text:00405274 jz loc_40D364
.text:0040527A
.text:0040527A loc_40527A: ; CODE XREF: KeWaitForSingleObject(x,x,x,x,x)-34↑j
.text:0040527A ; .text:00446222↓j
.text:0040527A cmp [ebp+Alertable], 0
.text:0040527E jnz loc_42BB51 ; 如果Alertable为1,说明可以强制唤醒
.text:00405284 cmp [ebp+WaitMode], 0
.text:00405288 jz short loc_405294
.text:0040528A cmp byte ptr [esi+4Ah], 0
.text:0040528E jnz loc_440534

信号量Semaphore

信号量的**_DISPATCHER_HEADER中的Type为5**

上面讲到了事件(EVENT)对象,线程在进入临界区之前会通过调用WaitForSingleObject或者WaitForMultipleObjects来判断当前的事件对象是否有信号(SignalState>0),只有当事件对象有信号时,才可以进入临界区(只允许一个线程进入直到退出的一段代码),不单指用EnterCriticalSection()和LeaveCriticalSection()而形成的临界区)。

通过我们对EVENT对象相关函数的分析,我们发现,EVENT对象的SignalState值只有2种可能:

  • 1 ==== 初始化时 或者 调用SetEvent时
  • 0 ==== WaitForSingleObject,WaitForMultipleObjects,ResetEvent

信号量和事件的区别

image-20211010140706561

image-20211010140736176

信号量和事件的区别在于,信号量允许同时有几个线程进入临界区

创建信号量对象

1
2
3
4
5
6
HANDLE WINAPI CreateSemaphore(
_In_opt_ LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
_In_ LONG lInitialCount,//!!!
_In_ LONG lMaximumCount,//!!!
_In_opt_ LPCTSTR lpName
);

内核结构解析:

1
2
3
4
5
6
7
8
9
10
_KSEMAPHORE
+0x000 Header : _DISPATCHER_HEADER
+0x010 Limit : Int4B //lMaximumCount,SignalState允许设置的最大值
_DISPATCHER_HEADER
+0x000 Type //信号量类型为5
+0x001 Absolute
+0x002 Size
+0x003 Inserted
+0x004 SignalState //lInitialCount,信号量的这个值可以设置为比1大的值
+0x008 WaitListHead

释放信号量对象

1
2
3
ReleaseSemaphore();
->NtReleaseSemaphore();
->KeReleaseSemaphore();(核心功能)

KeReleaseSemaphore功能描述

  1. 设置SignalState = SignalState +N(参数)
  2. 通过WaitListHead找到所有等待当前信号量的线程,从等待链表中摘掉

互斥体MUTANT

为什么要有互斥体的两个原因

  1. 解决等待对象被遗弃
  2. 允许重入

解决等待对象被遗弃

互斥体(MUTANT)与事件(EVENT)和信号量(SEMAPHORE)一样,都可以用来进行线程的同步控制。

但需要指出的是,这几个对象都是内核对象,这就意味着,通过这些对象可以进行跨进程的线程同步控制

1
2
3
4
5
6
    A进程中的X线程---》
等待对象Z
B进程中的Y线程---》
//存在一种极端情况:
如果B进程的Y线程还没有来得及调用修改SignalState的函数(如SetEvent)
那么等待对象Z将被遗弃,这也就意味着X线程将永远等下去!

互斥体可以有效解决该问题,系统会处理

当进程意外结束的时候,操作系统的函数会找到正在被当前线程占用的互斥体

允许重入

1
2
3
4
5
WaitForSingleObject(A)//A走完这一步已经无信号了
.....
WaitForMultipleObjects(A,B,C)//由于A已经没信号了,所以A就会卡死在这里:这种情况叫死锁
.....
//A对象如果是互斥体就不会出现死锁,因为互斥体允许重复进入临界区。

互斥体结构

1
2
3
4
5
6
_KMUTANT						
+0x000 Header : _DISPATCHER_HEADER
+0x010 MutantListEntry : _LIST_ENTRY //圈着所有互斥体的链表头
+0x018 OwnerThread : Ptr32 _KTHREAD //正在拥有互斥体的线程,如果没信号,但有所属线程,依然可以进入临界区
+0x01c Abandoned : UChar //是否已经被放弃不用
+0x01d ApcDisable : UChar //是否禁用内核APC

创建互斥体

1
2
3
4
5
HANDLE CreateMutexW(
LPSECURITY_ATTRIBUTES lpMutexAttributes,//指向安全属性的指针
BOOL bInitialOwner,//初始化互斥体对象的所有者
LPCWSTR lpName//指向互斥对象名的指针
);
1
2
CreateMutex  ->  NtCteateMutant(ApcDisable==0)(内核函数)  -> KeInitializeMutant(内核函数)//3环调用CreateMutex宏一定创建的是Mutant(ApcDisable==0)
NtCreateMutex(ApcDisable==1)(内核函数) -> KeInitializeMutex(内核函数)
  • ApcDisable==0 NtCteateMutant
  • ApcDisable==1 NtCreateMutex

KeInitializeMutant主要功能:初始化MUTANT结构体

  1. MUTANT.Header.Type=2;
  2. MUTANT.Header.SignalState=bInitialOwner?0:1;
  3. MUTANT.Header.OwnerThread=当前线程 or NULL;
  4. MUTANT.Header.Abandoned=0;
  5. MUTANT.Header.ApcDisable=0;
  6. bInitialOwner == TRUE 将当前互斥体挂入到当前线程的互斥体链表(KTHREAD+0x10 MutantListHead)

释放互斥体

1
2
3
4
5
6
BOOL WINAPI ReleaseMutex(HANDLE hMutex);

ReleaseMutex -> NtReleaseMutant -> KeReleaseMutant
正常调用时:
MUTANT.Header.SignalState++;
如果SignalState=1 说明其他进程可用了,将该互斥体从线程互斥体链表中摘除

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

意外终结的时候,系统会调用如下函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
MmUnloadSystemImage->KeReleaseMutant(X,Y,Abandon,Z)//这个调用流程的Abandon一定是TRUE,表示释放的是非正常释放的互斥体

if(Abandon == false)//正常调用
{
MUTANT.Header.SignalState++;
}
else
{
MUTANT.Header.SignalState == 1;
MUTANT.OwnerThread == NULL;
}
if(MUTANT.Header.SignalState == 1);
{
MUTANT.OwnerThread == NULL;
//从当前线程互斥体链表中将当前互斥体移除
}

KeWaitForSingleObject函数分析