更多的锁介绍可以先看看这篇文章:多线程锁详解之【序章】
正文:
互斥量,一个应用起来跟临界区十分相似的锁,但互斥量是可以命名的,可以跨进程使用,而临界区只能在单个进程内使用,效率方面互斥量往往也比临界区要低,这是什么原因导致的呢?让我们先来学习一遍互斥锁的源码,再讨论上面提出的问题。
源码:
先来看看互斥锁是如何创建的:
typedef struct _KMUTANT {
DISPATCHER_HEADER Header;
LIST_ENTRY MutantListEntry; //是个链表节点,用来作为线程互斥门链表的节点
struct _KTHREAD *RESTRICTED_POINTER OwnerThread; //正在拥有互斥门的线程
BOOLEAN Abandoned; //发生一次抛弃事件
UCHAR ApcDisable; //是否禁用内核APC
} KMUTANT, *PKMUTANT, *RESTRICTED_POINTER PRKMUTANT, KMUTEX,
*PKMUTEX, *RESTRICTED_POINTER PRKMUTEX;
/*
//提供一个 wdm.h (微软)的定义给大家看看,但我们以 ReactOS 的定义作为讲解
typedef struct _KMUTANT {
DISPATCHER_HEADER Header;
LIST_ENTRY MutantListEntry;
struct _KTHREAD *OwnerThread;
union {
UCHAR MutantFlags;
struct {
UCHAR Abandoned : 1;
UCHAR Spare1 : 7;
} DUMMYSTRUCTNAME;
} DUMMYUNIONNAME;
UCHAR ApcDisable;
} KMUTANT, *PKMUTANT, *PRKMUTANT, KMUTEX, *PKMUTEX, *PRKMUTEX;
*/
NTSTATUS NTAPI NtCreateMutant(OUT PHANDLE MutantHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL,
IN BOOLEAN InitialOwner)
{
KPROCESSOR_MODE PreviousMode = ExGetPreviousMode();
HANDLE hMutant;
PKMUTANT Mutant;
NTSTATUS Status;
PAGED_CODE();
DPRINT("NtCreateMutant(0x%p, 0x%x, 0x%p)\n",
MutantHandle, DesiredAccess, ObjectAttributes);
/* Check if we were called from user-mode */
if (PreviousMode != KernelMode)
{
/* Enter SEH Block */
_SEH2_TRY
{
/* Check handle pointer */
ProbeForWriteHandle(MutantHandle);
}
_SEH2_EXCEPT(EXCEPTION_EXECUTE_HANDLER)
{
/* Return the exception code */
_SEH2_YIELD(return _SEH2_GetExceptionCode());
}
_SEH2_END;
}
/* Create the Mutant Object*/
Status = ObCreateObject(PreviousMode,
ExMutantObjectType,
ObjectAttributes,
PreviousMode,
NULL,
sizeof(KMUTANT),
0,
0,
(PVOID*)&Mutant);
/* Check for success */
if(NT_SUCCESS(Status))
{
/* Initalize the Kernel Mutant */
DPRINT("Initializing the Mutant\n");
KeInitializeMutant(Mutant, InitialOwner);
/* Insert the Object */
Status = ObInsertObject((PVOID)Mutant,
NULL,
DesiredAccess,
0,
NULL,
&hMutant);
/* Check for success */
if (NT_SUCCESS(Status))
{
/* Enter SEH for return */
_SEH2_TRY
{
/* Return the handle to the caller */
*MutantHandle = hMutant;
}
_SEH2_EXCEPT(ExSystemExceptionFilter())
{
/* Get the exception code */
Status = _SEH2_GetExceptionCode();
}
_SEH2_END;
}
}
/* Return Status */
return Status;
}
如果你已经看过了事件对象的创建过程,就会发现互斥锁创建的过程是如此的熟悉。这里我们不过多赘述这个函数的流程是做了写什么了,都是一些三板斧动作。主要是讲述一些与其他锁对象不同的地方。
首先是函数名,不知道你发现没,其他的内核锁,都是在 CreateXXX 函数加上 Nt 两个字母,而互斥量的Nt 层函数不叫 NtCreateMutex 而叫 NtCreateMutant ,为什么这样叫?说实话我也不知道,毕竟只是一个命名而已。一般 CreateMutex 函数称之为创建互斥量, NtCreateMutant 函数称之为创建互斥门,当然它们也可以统称为创建互斥锁(哈哈,有点嚼舌根)。
而 NtCreateMutant 构建出来的对象,也是以 DISPATCHER_HEADER 结构体作为第一个成员,所以说 DISPATCHER_HEADER 这个结构体是构成锁的核心要素。而为了更好地了解 KMUTANT 这个结构体,我们先看看 KeInitializeMutant 如何对它初始化:
VOID
NTAPI
KeInitializeMutant(IN PKMUTANT Mutant,
IN BOOLEAN InitialOwner)
{
PKTHREAD CurrentThread;
KIRQL OldIrql;
/* Check if we have an initial owner */
if (InitialOwner)
{
/* We also need to associate a thread */
CurrentThread = KeGetCurrentThread();
Mutant->OwnerThread = CurrentThread;
/* We're about to touch the Thread, so lock the Dispatcher */
OldIrql = KiAcquireDispatcherLock();
/* And insert it into its list */
InsertTailList(&CurrentThread->MutantListHead,
&Mutant->MutantListEntry);
/* Release Dispatcher Lock */
KiReleaseDispatcherLock(OldIrql);
}
else
{
/* In this case, we don't have an owner yet */
Mutant->OwnerThread = NULL;
}
/* Now we set up the Dispatcher Header */
Mutant->Header.Type = MutantObject;
Mutant->Header.Size = sizeof(KMUTANT) / sizeof(ULONG);
Mutant->Header.DpcActive = FALSE;
Mutant->Header.SignalState = InitialOwner ? 0 : 1;
InitializeListHead(&(Mutant->Header.WaitListHead));
/* Initialize the default data */
Mutant->Abandoned = FALSE; //是否发生了抛弃事件
Mutant->ApcDisable = 0; //禁用APC
}
呀,比事件对象信号灯这些锁的初始化要复杂点。主要是多了个 InitialOwner 参数的判断(本质是 CreateMutex 的第二个参数),当 InitialOwner 为 FALSE 的时候,明显是用来第一次初始化结构体的,而当 InitialOwner 为 TRUE 时,KeInitializeMutant 函数除了初始化互斥门对象,还能把当前线程标记为拥有了互斥锁的使用权。而当线程拥有使用权后,会把互斥门对象保存到 CurrentThread->MutantListHead 队列当中,这有什么作用呢?
大家还记得互斥量的一个特性吗?就是当拥有互斥锁权限的线程,在结束时没有释放锁权限的话,系统会帮其释放锁权限。这就是为什么当线程拥有了锁时,要把互斥门存放到 CurrentThread->MutantListHead 的原因。
那为什么系统要主动帮线程释放互斥锁权限呢?因为互斥锁常用于跨进程操作,有时候进程崩溃是不可控的,如果系统不主动帮忙释放互斥锁的话,将会导致其他访问同一个互斥锁的进程无辜卡死。当系统主动帮我们释放互斥锁后,下一次的 WaitForSingleObject 函数的调用将会返回 WAIT_ABANDONED 错误。
而 KMUTANT 结构当中的 OwnerThread 成员也比较简单,它跟临界区的 OwnerThread 成员变量一样,都是用来标记哪条线程拥有了锁权限。但临界区还有一个 RecursionCount 的成员变量,用来作为线程重入计数的。互斥门同样支持线程重入(也就是一个线程可以多次嵌套调用 WaitForSingleObject 函数),那互斥门的 RecursionCount 跑哪里去了呢?
其实互斥门的重入计数,是利用了 SignalState 变量作计算。哈哈,看来 SignalState 变量的作用真不简单,而至于 SignalState 变量是怎么运算的呢?我们先来看看 NtReleaseMutant 和 KeReleaseMutant 函数吧。
NTSTATUS NTAPI NtReleaseMutant(IN HANDLE MutantHandle,
IN PLONG PreviousCount OPTIONAL)
{
PKMUTANT Mutant;
KPROCESSOR_MODE PreviousMode = ExGetPreviousMode();
NTSTATUS Status;
PAGED_CODE();
DPRINT("NtReleaseMutant(MutantHandle 0x%p PreviousCount 0x%p)\n",
MutantHandle,
PreviousCount);
/* Check if we were called from user-mode */
if ((PreviousCount) && (PreviousMode != KernelMode))
{
/* Entry SEH Block */
_SEH2_TRY
{
/* Make sure the state pointer is valid */
ProbeForWriteLong(PreviousCount);
}
_SEH2_EXCEPT(EXCEPTION_EXECUTE_HANDLER)
{
/* Return the exception code */
_SEH2_YIELD(return _SEH2_GetExceptionCode());
}
_SEH2_END;
}
/* Open the Object */
Status = ObReferenceObjectByHandle(MutantHandle,
MUTANT_QUERY_STATE,
ExMutantObjectType,
PreviousMode,
(PVOID*)&Mutant,
NULL);
/* Check for Success and release if such */
if (NT_SUCCESS(Status))
{
/*
* Release the mutant. doing so might raise an exception which we're
* required to catch!
*/
_SEH2_TRY
{
/* Release the mutant */
LONG Prev = KeReleaseMutant(Mutant,
MUTANT_INCREMENT,
FALSE,
FALSE);
/* Return the previous count if requested */
if (PreviousCount) *PreviousCount = Prev;
}
_SEH2_EXCEPT(ExSystemExceptionFilter())
{
/* Get the exception code */
Status = _SEH2_GetExceptionCode();
}
_SEH2_END;
/* Dereference it */
ObDereferenceObject(Mutant);
}
/* Return Status */
return Status;
}
LONG NTAPI KeReleaseMutant(IN PKMUTANT Mutant,
IN KPRIORITY Increment, //MUTANT_INCREMENT : 1
IN BOOLEAN Abandon, //FALSE
IN BOOLEAN Wait //FALSE
)
{
KIRQL OldIrql;
LONG PreviousState;
PKTHREAD CurrentThread = KeGetCurrentThread();
BOOLEAN EnableApc = FALSE;
ASSERT_MUTANT(Mutant);
ASSERT_IRQL_LESS_OR_EQUAL(DISPATCH_LEVEL);
/* Lock the Dispatcher Database */
OldIrql = KiAcquireDispatcherLock();//加锁
/* Save the Previous State */
PreviousState = Mutant->Header.SignalState; //保存下来
/* Check if it is to be abandonned */
if (Abandon == FALSE)
{
/* Make sure that the Owner Thread is the current Thread */
if (Mutant->OwnerThread != CurrentThread)
{
//如果当前线程不是锁的持有者,则抛出异常
/* Release the lock */
KiReleaseDispatcherLock(OldIrql);释放锁
/* Raise an exception */
ExRaiseStatus(Mutant->Abandoned ? STATUS_ABANDONED :
STATUS_MUTANT_NOT_OWNED);
}
/* If the thread owns it, then increase the signal state */
Mutant->Header.SignalState++; //信号量加 1
}
else /*if (Abandon == TRUE)*/
{
/* It's going to be abandonned */
//Abandon 为 TRUE,是线程在退出前,没有完全释放锁的缘故,这个时候
//系统会帮我们调用一次 KeReleaseMutant ,把锁释放掉
//既然某个线程已经彻底弃用这个锁,那么不管它曾经重入多少次
//到了这里 SignalState 都强行置 1 ,SignalState 为 1 就代表有信号了
//SignalState 有信号就能够唤醒另外一条休眠的线程(如果阻塞队列不为空)
Mutant->Header.SignalState = 1;
Mutant->Abandoned = TRUE; //没有被正确释放,设置标记
}
/* Check if the signal state is only single */
if (Mutant->Header.SignalState == 1)
{
/* Check if it's below 0 now */
if (PreviousState <= 0)
{
//如果当前 SignalState 为 1,且之前为 0 ,则表示已经当前线程已经完全释放锁
//所以线程结构体也不需要再记录互斥门,把互斥门从线程对象中移除
/* Remove the mutant from the list */
RemoveEntryList(&Mutant->MutantListEntry);
/* Save if we need to re-enable APCs */
EnableApc = Mutant->ApcDisable;
}
//else :
/* Remove the Owning Thread and wake it */
Mutant->OwnerThread = NULL; //SignalState 为 TRUE 表示没线程占用了
/* Check if the Wait List isn't empty */
//如果阻塞队列不为空,则表示有其他线程等待这个互斥锁
if (!IsListEmpty(&Mutant->Header.WaitListHead))
{
/* Wake the Mutant */
KiWaitTest(&Mutant->Header, Increment);//唤醒一条正在等待的线程
}
}
/* Check if the caller wants to wait after this release */
if (Wait == FALSE)
{
/* Release the Lock */
KiReleaseDispatcherLock(OldIrql);
}
else
{
/* Set a wait */
CurrentThread->WaitNext = TRUE;
CurrentThread->WaitIrql = OldIrql;
}
/* Check if we need to re-enable APCs */
if (EnableApc) KeLeaveCriticalRegion();
/* Return the previous state */
return PreviousState;//返回之前的状态
}
由于 NtReleaseMutant 都是一些三板斧套路,我们跳过 NtReleaseMutant 函数直接分析 KeReleaseMutant 函数。
可以看到 KeReleaseMutant 对 SignalState 的处理是递增,那么就意味着 WaitForSingleObject 函数里面是递减操作了。我们动动手指计算一下,
初始化的互斥门(线程没申请锁权限)的 SignalState 值为 1
然后调用 WaitForSingleObject 拿到锁权限时,SignalState 递减等于 0
那线程重入时,再一次调用 WaitForSingleObject ,这个函数为什么没发生阻塞呢?我摘取一小段 KeWaitForSingleObject 函数的代码给大家分析一下:
NTSTATUS
NTAPI
KeWaitForSingleObject(IN PVOID Object,
IN KWAIT_REASON WaitReason, //线程上次被切出原因
IN KPROCESSOR_MODE WaitMode,
IN BOOLEAN Alertable,
IN PLARGE_INTEGER Timeout OPTIONAL)
{
/* Check if it's a mutant */
//先检测所等待的对象是否有信号了,不过互斥对象需要特殊判断。
if (CurrentObject->Header.Type == MutantObject)//包含Mutant结构的信号处理
{
/* Check its signal state or if we own it */
if ((CurrentObject->Header.SignalState > 0) ||
(Thread == CurrentObject->OwnerThread))
{
/* Just unwait this guy and exit */
if (CurrentObject->Header.SignalState != (LONG)MINLONG)
{
/* It has a normal signal state. Unwait and return */
//KiSatisfyMutantWait 负责把目标对象已经收到的信号消耗掉,它主要用于互斥量消耗,
//互斥量收到信号后 SignalState 会加 1, 那么所谓消耗掉,就是把 SignalState 减 1
//因为互斥量是线程可重入,所以它会对 SignalState 进行加减计算,重入时是加 1
KiSatisfyMutantWait(CurrentObject, Thread);//包含Mutant结构的信号消耗
WaitStatus = (NTSTATUS)Thread->WaitStatus;//唤醒原因
goto DontWait;//退出循环,函数准备返回
}
else
{
/* Raise an exception */
//这里就是 if (CurrentObject->Header.SignalState == (LONG)MINLONG) 条件的处理代码
//SignalState 到达 MINLONG 表示正整数溢出,变为负数,意味着这个时候锁计数已经太多了。
KiReleaseDispatcherLock(Thread->WaitIrql); //先执行 执行所有Pending中的内核APC
//重入次数已经太多了,这里只能抛出一个异常
ExRaiseStatus(STATUS_MUTANT_LIMIT_EXCEEDED);
}
}
}
}
哦,原来当 SignalState 大于 0,或者 OwnerThread 为当前线程,WaitForSingleObject 函可以被唤醒,而被唤醒时,还会执行 KiSatisfyMutantWait 这个函数,额,准确来说,它是一个宏,我们来看看定义:
//
// Satisfies the wait of a mutant dispatcher object
//
#define KiSatisfyMutantWait(Object, Thread) \
{ \
/* Decrease the Signal State */ \
(Object)->Header.SignalState--; \
\
/* Check if it's now non-signaled */ \
if (!(Object)->Header.SignalState) \
{ \
/* Set the Owner Thread */ \
(Object)->OwnerThread = Thread; \
\
/* Disable APCs if needed */ \
Thread->KernelApcDisable = Thread->KernelApcDisable - \
(Object)->ApcDisable; \
\
/* Check if it's abandoned */ \
if ((Object)->Abandoned) \
{ \
/* Unabandon it */ \
(Object)->Abandoned = FALSE; \
\
/* Return Status */ \
Thread->WaitStatus = STATUS_ABANDONED; \
} \
\
/* Insert it into the Mutant List */ \
InsertHeadList(Thread->MutantListHead.Blink, \
&(Object)->MutantListEntry); \
} \
}
原来 WaitForSingleObject 对互斥门的递减,就是在 KiSatisfyMutantWait 这个宏里面完成的,而当 SignalState 由 1 变 0 时,意味着它被一个新的线程占用,所以 OwnerThread 被设置为新线程,而互斥门也被加入到线程的 MutantListHead 队列。之所以需要加入 MutantListHead 队列,之前也说过了,是为了让线程结束时,系统能够知道哪些互斥门还没有被正确释放,以便系统帮我们调用 KeReleaseMutant,而系统调用 KeReleaseMutant 函数时, Abandon 参数为 TRUE,这个时候互斥门内部的 Abandoned 也会被设置为TRUE,表示在此之前,发生了至少一次不愉快的事情(一个或多个线程没有正确释放互斥锁)。而当我们察觉到发生不愉快的事情后,会把内核的错误码设置为 STATUS_ABANDONED (转化到应用层为 WAIT_ABANDONED ),再把这件不愉快的事情抹去(把互斥门的 Abandoned 重置为 FALSE)。
疑问解答:
对于互斥量的实现源码,就介绍到这里了。接下来我们来解释一下文章开始时提出的疑问:
a1. 为什么互斥量比临界区效率要低
其实说互斥量的效率完全比临界区要低这个说法肯定是错误的,无论是临界区还是互斥量,他们陷入等待的方式都是调用 WaitForSingleObject 函数,虽然在 WaitForSingleObject 内部处理逻辑稍有差异,但肯定是难以判断出性能差距的。互斥量在锁碰撞时(至少两个线程在抢锁),是直接陷入内核等待,而临界区是用户层自旋4000次抢锁,用户层抢锁失败再陷入内核等待。
很明显,当你发现同样的代码,用临界区加锁比互斥量要快时,证明了临界区处于自旋状态时就把锁使用权拿到手了。如果临界区在自旋状态没能够把锁拿到手呢?那就意味着,他将跟互斥量一样陷入内核等待。由于临界区的等待过程比互斥量还多了自旋这个操作,也就是说,如果临界区不能在自旋状态把锁抢到手,那么它的效率必将比互斥量还要低。
如果是锁碰撞十分频繁且加锁粒度较大时(指代码很多或时间开销很大),可以考虑使用互斥量,说不定效率会更高。
a2. 为什么互斥量能跨进程而临界区不能跨进程
如果你问出这个问题,就要理解一下用户空间和内核空间的概念了。简单来说,用户空间是进程独占的,内核空间是所有进程共享的。也就是说,当不同的进程访问互斥量句柄,进入内核后它们最终访问的都是同一块内存地址,而不同进程的临界区,很明显他们地址不同,无法为不同进程提供同一个对象,也就无法实现跨进程加锁的动作。
那如果我把 CRITICAL_SECTION 结构放到内核层,并且以句柄的形式返回应用层呢? 答案是可以的,这个时候你的临界区将支持跨进程访问。但这样做有意义吗?
这个我没测试过,但看上去意义不大,也不能说这样的临界区性能是否比互斥量要好。因为内核临界区需要进入内核,查找对象,释放对象,退出内核等操作。这些动作的时间消耗,将会加大另外一个线程,在临界区自旋期间不能抢锁成功的概率,也就是这种场景下自旋锁可能会变成一个累赘,让内核临界区比内核互斥门更慢。嗯,理论上是这样的,实际上大家需要测试一下才知道实际效果。
a3. 互斥量如何解决死锁
在临界区的文章我们提及到,临界区解决死锁,可以自行添加一个链表,然后通过链路检查来判断死锁。死锁的判断条件有了,也是通用的。那么问题是当判断出产生死锁以后,如何解决死锁呢?
因为临界区中 unlock 函数并没有判断调用线程是否就是拥有锁的线程,所以在哪个线程解锁都没关系,然而互斥锁的 unlock 大家也看到了,它是会判断当前调用锁的线程是否就是锁的拥有者,这就意味着不能在其他线程调用互斥锁的 unlock 了。联想拥有互斥锁的线程在结束时没有释放锁权限的话,系统会帮其释放锁权限的特性。我们可以得出两个解决方案:
一是在抢锁线程判断出自己将要发生死锁,放弃自己已经抢到手的锁(死锁产生的条件,主要都是因为两个以上的锁没有保证调用顺序,一般都是在第二个或往后的锁才能判断产生了死锁),当然了,连带放弃的,还有本次线程需要执行的任务。
二是死锁产生后,在其他线程判断出来,将产生死锁的一条或多条线程终结掉,让系统帮我们释放互斥锁。
a4. 线程真的可以强行终结吗
在一些编程规范中会明确提到:强行终结线程,会引起内存泄露或数据损坏的问题,所以不允许强行终结线程;如果想结束一条线程,请使用 return 关键字让线程主动结束。
这个规范的另外一个意思,就是说不允许强行终结线程是因为害怕出现不可控的问题。反过来说,只要我们能够保证线程可控,线程是可以强行结束的。
且看以下代码:
int thread_A(void*)
{
int a = 0;
a++;
return 0;
}
int thread_B(void*)
{
int a = 0;
void* p = malloc(100);
a++;
free(p);
return 0;
}
请问 thread_A 线程强行结束会引起不可控后果吗,答案是不会,它跟正常 return 没有任何差别。强行结束 thread_B 呢?
强行结束 thread_B 可能会引起两个问题,一个是内存泄露,另外一个是如果 thread_B 正处于 malloc 或 free 内部被结束掉,会引起其他线程将无法再调用这两个函数,因为 malloc 和 free 内部是有锁(不是互斥锁)的,如果 thread_B 被终结时正处于 malloc / free 的加锁状态,那么其他调用这两个函数的线程将会死锁。
但在我们设计的场景中,上面第二个问题真的会发生吗?答案是不会的,因为当我们检测到线程死锁,那么这些线程百分百处于 lock 函数中,不会存在于其他地方!而且我们是有能力知道线程执行到哪一步的,所以我们主要关注内存泄露问题就可以了。
关于内存泄露的处理方案,比较简单的一个方法是,提供一个 struct _ttev (线程任务环境)的结构体,将需要用到的资源都放到里面,强行终结线程后,再将对应线程的 struct _ttev 对象回收即可。(参考进程终结为什么不会引起内存泄露的原因)
其实强行终结线程,还是十分危险的操作,对开发者的要求也非常高,大家在设计强行终结线程,一定要多加思考和检测!