并发是指多个线程在同时执行:
单核(是分时执行,不是真正的同时)
多核(在某一个时刻,会同时有多个线程再执行)
同步则是保证在并发执行的环境中各个线程可以有序的执行
DWORD dwFlag = 0; //实现临界区的方式就是加锁
//锁:全局变量 进去加一 出去减一
if(dwFlag == 0) //进入临界区
{
dwFlag = 1
.......
.......
.......
dwFlag = 0 //离开临界区
}
全局变量:Flag = 0
进入临界区:
Lab:
mov eax,1
lock xadd [Flag],eax
cmp eax,0
jz endLab
dec [Flag]
//线程等待Sleep..
endLab:
ret
离开临界区:
lock dec [Flag]
关键代码:
lock bts dword ptr [ecx], 0
LOCK是锁前缀,保证这条指令在同一时刻只能有一个CPU访问
BTS指令:设置并检测 将ECX指向数据的第0位置1
如果[ECX]原来的值==0 那么CF=1 否则CF=0
写pause,因为是多核,不用切换线程,等一等就好,Sleep会造成线程切换,没必要。
1、自旋锁只对多核有意义。
(查看不同版本的KeAcquireSpinLockAtDpcLevel函数)
2、自旋锁与临界区、事件、互斥体一样,都是一种同步机制,都可以让当前线程
处于等待状态,区别在于自旋锁不用切换线程。
我们在之前的课程里面讲解了如何自己实现临界区以及什么是Windows自旋锁,这两种同步方案在线程无法进入临界区时都会让当前线程进入等待状态,
一种是通过Sleep函数实现的,一种是通过让当前的CPU”空转”实现的,但这两种等待方式都有局限性:
1) 通过Sleep函数进行等待,等待时间该无法确定
2) 通过“空转”的方式进行等待,只有等待时间很短的情况下才有意义,否则对CPU资源是种浪费。而且自旋锁只能在多核的环境下才有意义。
有没有更加合理的等待方式呢?只有在条件成熟的时候才将当前线程唤醒?
在Windows中,一个线程可以通过等待一个或者多个可等待对象,从而进入等待状态,另一个线程可以在某些时刻唤醒等待这些对象的其他线程。
graph LR
线程AWaitForSingleObject/WaitForMultipleObjects
-->可等待对象
线程BSetEvent/ReleaseSemaphore/ReleaseMutant
-->可等待对象
在Windbg中查看如下结构体:
dt _KPROCESS 进程
dt _KTHREAD 线程
dt _KTIMER 定时器
dt _KSEMAPHORE 信号量
dt _KEVENT 事件
dt _KMUTANT 互斥体
dt _FILE_OBJECT 文件
这些可等待对象的特点是都是一个结构体,都有一个成员,即第一个成员_DISPATCHER_HEADER(有些·1比较特殊,文件比较特殊不在第一个成员)
那么第一个是与不是_DISPATCHER_HEADER的对象有什么区别呢
WaitForSingleObject(3环)
NtWaitForSingleObject(内核)
1) 通过3环用户提供的句柄,找到等待对象的内核地址。
2) 如果是以_DISPATCHER_HEADER开头,直接使用。
3) 如果不是以_DISPATCHER_HEADER开头的对象,则 找到在其中嵌入的_DISPATCHER_HEADER对象。
KeWaitForSingleObject(内核)
核心功能,后面会讲
被等待对象有个变量是WaitListHead圈着所有等待块,把等待他的所有线程的等待块,就是第一个参数WaitListEntry
1、等待中的线程,一定在等待链表中(KiWaitListHead),同时也一定在这张网上(KTHREAD +5C的位置不为空)。
2、线程通过调用WaitForSingleObject/WaitForMultipleObjects函数将自己挂到这张网上。
3、线程什么时候会再次执行取决于其他线程何时调用相关函数,等待对象不同调用的函数也不同。
无论可等待对象是何种类型,线程都是通过:
WaitForSingleObject
WaitForMultipleObjects
进入等待状态的,这两个函数是理解线程等待与唤醒进制的核心
WaitForSingleObject对应的内核函数:
NTSTATUS __stdcall NtWaitForSingleObject(
HANDLE Handle,
BOOLEAN Alertable,
PLARGE_INTEGER Timeout)
Handle 用户层传递的等待对象的句柄(具体细节参加句柄表专题)
Alertable 对应KTHREAD结构体的Alertable属性 如果为1 在插入用户APC时,该线程将被吵醒
Timeout 超时时间
1) 调用ObReferenceObjectByHandle函数,通过对象句柄找到等待对象结构体地址。
2) 调用KeWaitForSingleObject函数,进入关键循环。
这个函数开始执行需要准备等待块,当前线程通过等待块与被等待线程关联。当等待时间不为零,有2个要等待的事件,还有一个是定时器。
1) 向_KTHREAD(+70)位置的等待块赋值。
2) 如果超时时间不为0,KTHREAD(+70)第四个等待块与第一个等待块关联起来:
第一个等待块指向第四个等待块,第四个等待块指向第一个等待块。
3) KTHREAD(+5C)指向第一个_KWAIT_BLOCK。
4) 进入关键循环
_DISPATCHER_HEADER
+0x000 Type //对象类型
+0x001 Absolute
+0x002 Size
+0x003 Inserted
+0x004 SignalState //是否有信号(>0)
+0x008 WaitListHead //双向链表头 圈着所有等待块
while(true)//每次线程被其他线程唤醒,都要进入这个循环
{
if(符合激活条件)//1、超时 2、等待对象SignalState>0
{
//1) 修改SignalState
//2) 退出循环
}
else
{
if(第一次执行)
将当前线程的等待块挂到等待对象的链表(WaitListHead)中;
//将自己挂入等待队列(KiWaitListHead)
//切换线程...再次获得CPU时,从这里开始执行
}
}
//1) 线程将自己+5C位置清0
//2) 释放_KWAIT_BLOCK所占内存
不同的等待对象,用不同的方法来修改_DISPATCHER_HEADER(SignalState)
比如:如果可等待对象是EVENT,其他线程通常使用SetEvent来设置SignalState = 1
并且,将正在等待该对象的其他线程唤醒,也就是从等待链表(KiWaitListHead)中摘出来。但是,SetEvent函数并不会将线程从等待网上摘下来,是否要下来,由当前线程自己来决定。
在APC专题中讲过,当我们插入一个用户APC时(Alertable=1),当前线程是可以被唤醒的,但并不是真正的唤醒。因为,如果当前的线程在等待网上,执行完用户APC后,仍然要进入等待状态。
关于线程什么时候把自己变成等待的,就是通过线程自己操作的,线程在进入临界区之前会调用WaitForSingleObject或者WaitForMultipleObjects,去判断等待对象,信号量是否有信号,如果有修改信号量,退出函数退出循环,将自己+5C位置清零,释放内存,如果发现信号量为0,并且是第一次执行,将线程自己的等待块挂到此对象的等待列表里,创建线程关于这个等待对象的等待块将其挂到等待对象的链表中,如果线程切换再次获得CPU,当前线程便会在WaitForSingleObject或者WaitForMultipleObjects恢复执行(在哪切换在哪开始执行)从这里开始执行。
关于唤醒,总得来说就是其他唤醒线程把信号量激活(不同等待对象(比如Event)不同,唤醒函数不同,比如SetEvent),修改信号量,改为有信号,比如1,然后内核等待对象把所有等待他的线程(通过WaitListHead链找)从等待链表里摘掉,然后等待线程从当初挂起的地方开始执行,自己再次进入循环,判断能否被激活,比如还有没有等待其他对象,如果没有,当前线程便会在WaitForSingleObject或者WaitForMultipleObjects恢复执行(在哪切换在哪开始执行),如果符合唤醒条件,自己把自己线程的5C清0,然后自己把自己从等待网里弄出去。这样就把自己激活了(此时会修改SignalState的值,并将自己从等待网上摘下来,此时的线程才是真正的唤醒,),如果有继续变非唤醒,重新把自己挂到等待列表上。
在之前的课程里面讲过,线程在进入临界区之前会调用WaitForSingleObject或者WaitForMultipleObjects,此时如果有信号,线程会从函数中退出并进入临界区,如果没有信号那么线程将自己挂入等待链表,然后将自己挂入等待网,最后切换线程。
其他线程在适当的时候,调用方法修改被等待对象的SignalState为有信号(不同的等待对象,会调用不同的函数),并将等待该对象的其他线程从等待链表中摘掉,这样,当前线程便会在WaitForSingleObject(一次挂一到两个等待块)或者WaitForMultipleObjects(一次挂多个等待块)恢复执行(在哪切换在哪开始执行),如果符合唤醒条件,此时会修改SignalState的值,并将自己从等待网上摘下来,此时的线程才是真正的唤醒。
创建事件对象:信号
CreateEvent(NULL,
TRUE, //当前对象类型,TRUE 通知类型对象
//FALSE 事件同步对象
FALSE//创建是是否有信号,就是SignalState初始值
, NULL);
_DISPATCHER_HEADER
+0x000 Type
+0x001 Absolute
+0x002 Size
+0x003 Inserted
+0x004 SignalState
+0x008 WaitListHead
(代码演示)
SetEvent对应的内核函数:KeSetEvent
1) 修改信号值SignalState为1
2) 判断对象类型
3) 如果类型为通知类型对象(0) 唤醒所有等待该状态的线程
4) 如果类型为事件同步对象(1) 从链表头找到第一个,找等待类型是WaitAny的。
上一节课我们讲到了事件(EVENT)对象,线程在进入临界区之前会通过调用WaitForSingleObject或者WaitForMultipleObjects来判断当前的事件对象是否有信号(SignalState>0),只有当事件对象有信号时,才可以进入临界区(只允许一个线程进入直到退出的一段代码,不单指用EnterCriticalSection() 和 LeaveCriticalSection() 而形成的临界区)。
通过我们对EVENT对象相关函数的分析,我们发现,EVENT对象的SignalState值只有2种可能:
1 初始化时 或者调用 SetEvent
0 WaitForSingleObject、WaitForMultipleObjects、ResetEvent
信号量跟事件最大差异,运行多个线程同时进入临界区
HANDLE CreateSemaphore(
LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
LONG lInitialCount,
LONG lMaximumCount, //赋值给Limit
LPCTSTR lpName
);
_KSEMAPHORE
+0x000 Header : _DISPATCHER_HEADER
+0x010 Limit : Int4B //lMaximumCount
_DISPATCHER_HEADER
+0x000 Type //信号量类型为5
+0x001 Absolute
+0x002 Size
+0x003 Inserted
+0x004 SignalState //lInitialCount
+0x008 WaitListHead
graph LR
ReleaseSemaphore_3Ring-->NtReleaseSemaphore
NtReleaseSemaphore-->KeReleaseSemaphore
1) 设置SignalState = SignalState + N(参数,释放几个)
2) 通过WaitListHead找到所有线程,并从等待链表中摘掉。
可以通过
+0x010 MutantListEntry : _LIST_ENTRY
解决被遗弃。
互斥体(MUTANT)与事件(EVENT)和信号量(SEMAPHORE)一样,都可以用来进行线程的同步控制。
但需要指出的是,这几个对象都是内核对象,这就意味着,通过这些对象可以进行跨进程的线程同步控制,比如:
graph LR
A进程中的X线程-->等待对象Z
B进程中的Y线程-->等待对象Z
极端情况:
如果B进程的Y线程还没有来得及调用修改SignalState的函数(如SetEvent)
那么等待对象Z将被遗弃,这也就以为者X线程将永远等下去!
如果使用互斥体,A对象多次进入临界区,下面代码就不会死锁
WaitForSingleObject(A)
.....
WaitForMultipleObjects(A,B,C)
.....
SetEvent/ReleaseSemaphore
死锁
KMUTANT
+0x000 Header : _DISPATCHER_HEADER
+0x010 MutantListEntry : _LIST_ENTRY
+0x018 OwnerThread : Ptr32 _KTHREAD
+0x01c Abandoned : UChar
+0x01d ApcDisable : UChar
MutantListEntry:
拥有互斥体线程(KTHREAD+0x010 MutantListHead)是个链表头 圈着所有互斥体
OwnerThread:
正在拥有互斥体的线程
Abandoned:
是否已经被放弃不用
ApcDisable:
是否禁用内核APC
因为互斥体有个变量
+0x018 OwnerThread : Ptr32 _KTHREAD
HANDLE CreateMutex(
LPSECURITY_ATTRIBUTE SlpMutexAttributes, // 指向安全属性的指针
BOOL bInitialOwner, //初始化互斥对象的所有者,互斥体对象属于哪个线程。TRUE说明属于当前线程
LPCTSTR lpName // 指向互斥对象名的指针
);
CreateMutex->NtCreateMutant(内核函数) -> KeInitializeMutant(内核函数)
初始化MUTANT结构体:
MUTANT.Header.Type=2;
MUTANT.Header.SignalState=bInitialOwner?0:1;
MUTANT.OwnerThread=当前线程 or NULL;
MUTANT.Abandoned=0;
MUTANT.ApcDisable=0;
bInitialOwner==TRUE 将当前互斥体挂入到当前线程的互斥体链表
(KTHREAD+0x010 MutantListHead)
ReleaseMutex函数
BOOL WINAPI ReleaseMutex(HANDLE hMutex);
调用时,会这么调用
graph LR
ReleaseMutex-->NtReleaseMutant
NtReleaseMutant-->KeReleaseMutant
正常调用时:
MUTANT.Header.SignalState++;
如果SignalState=1 (因为每次进入临界区可以一直减)说明其他进程可用了 将该互斥体从线程链表中移除。
_KMUTANT
+0x000 Header : _DISPATCHER_HEADER
+0x010 MutantListEntry : _LIST_ENTRY
+0x018 OwnerThread : Ptr32 _KTHREAD
+0x01c Abandoned : UChar
+0x01d ApcDisable : UChar
OwnerThread:
正在拥有互斥体的线程
_KMUTANT
+0x000 Header : _DISPATCHER_HEADER
+0x010 MutantListEntry : _LIST_ENTRY
+0x018 OwnerThread : Ptr32 _KTHREAD
+0x01c Abandoned : UChar
+0x01d ApcDisable : UChar
MutantListEntry:
拥有互斥体线程(KTHREAD+0x010 MutantListHead)是个链表头 圈着所有互斥体
Abandoned:
是否已经被放弃不用
当等待对象被遗弃,系统会调用函数
graph LR
MmUnloadSystemImage-->KeReleaseMutant_X,Y,Abandon,Z_
MmUnloadSystemImage会将Abandon设置成1(初始是0)
那么被遗弃和正常释放最后都会调用KeReleaseMutant,区分方法就是参数Abandon。
if(Abandon == false) //正常调用
{
MUTANT.Header.SignalState++;
}
else
{
MUTANT.Header.SignalState == 1;
MUTANT.OwnerThread == NULL;
}
if(MUTANT.Header.SignalState==1)
MUTANT.OwnerThread == NULL;
从当前线程互斥体链表中将当前互斥体移除
(参见KeReleaseMutant函数)
KMUTANT
+0x000 Header : _DISPATCHER_HEADER
+0x010 MutantListEntry : _LIST_ENTRY
+0x018 OwnerThread : Ptr32 _KTHREAD
+0x01c Abandoned : UChar
+0x01d ApcDisable : UChar
ApcDisable:
是否禁用内核APC
Mutant 对应内核函数 NtCreateMutant ApcDisable=0
Mutex 对应内核函数 NtCreateMutex ApcDisable=1,意思是被禁止
(参见KeWaitForSingleObject函数)
[1] 滴水视频