多线程锁详解之【临界区】

更多的锁介绍可以先看看这篇文章:多线程锁详解之【序章】

正文:
一般锁的类型可分为两种:用户态锁和内核态锁。用户态锁是指这个锁的不能够跨进程使用。而内核态锁就是指能够跨进程使用的锁。一般书中会说,windows 提供了两个用户态锁,一个是原子操作,而另外一个,正是我们要讨论的主题:临界区。

原理:
临界区作为用户态最常用的锁,它的使用方法并不复杂,就是拥有初始化和反初始化函数,一对 lock 和 unlock 函数,当然还有一个不太常用的 trylock 函数。如下:

(1)初始化临界区 InitializeCriticalSection (init_lock)
(2)进入临界区 EnterCriticalSection (lock)
(3)离开临界区 LeaveCriticalSection (unlock)
(4)删除临界区 DeleteCriticalSection (uninit_lock)
(5)尝试进入锁 TryEnterCriticalSection (try_lock)

我一般喜欢把 EnterCriticalSection 称之为 lock 函数,把 LeaveCriticalSection 称之为 unlock 函数,因为即使不同的操作系统,其多线程和锁原理都是大同小异的,熟悉锁原理后,我们不需要关心操作系统提供的锁函数原名叫什么,凡是针对于区间加解锁的操作,我都统一称之为 lock 和 unlock 。

而这些函数都依赖于 RTL_CRITICAL_SECTION 这个对象,让我们再看看这个对象的成员属性:

struct RTL_CRITICAL_SECTION
{
PRTL_CRITICAL_SECTION_DEBUG DebugInfo;
LONG LockCount;
LONG RecursionCount;
HANDLE OwningThread;
HANDLE LockSemaphore;
ULONG_PTR SpinCount;
};

以下对每个字段进行说明。

DebugInfo 这个值很明显是跟调试相关的,我们后续先不管,因为它并不影响真正的锁逻辑。

LockCount 这它被初始化为数值 -1;此数值等于或大于 0 时,表示此临界区被占用。当其不等于 -1 时,OwningThread 字段包含了拥有此临界区的线程 ID。此字段与 (RecursionCount -1) 数值之间的差值表示有多少个其他线程在等待获得该临界区。

RecursionCount 此字段包含所有者线程已经获得该临界区的次数。如果该数值为零,下一个尝试获取该临界区的线程将会成功。

OwningThread 此字段包含当前占用此临界区的线程的线程标识符。此线程 ID 与 GetCurrentThread 之类的 API 所返回的值相同。

LockSemaphore 它是一个内核对象句柄,用于通知操作系统:该临界区现在空闲。操作系统在一个线程第一次尝试获得该临界区,但被另一个已经拥有该临界区的线程所阻止时,自动创建这样一个句柄。应当调用 DeleteCriticalSection(它将发出一个调用该事件的 CloseHandle 调用,并在必要时释放该调试结构),否则将会发生资源泄漏。

SpinCount 仅用于多处理器系统。MSDN文档对此字段进行如下说明:“在多处理器系统中,如果该临界区不可用,调用线程将在对与该临界区相关的信号执行等待操作之前,旋转 dwSpinCount 次。如果该临界区在旋转操作期间变为可用,该调用线程就避免了等待操作。”旋转计数可以在多处理器计算机上提供更佳性能,其原因在于在一个循环中旋转通常要快于进入内核模式等待状态。此字段默认值为零,但可以用 InitializeCriticalSectionAndSpinCount API 将其设置为一个不同值。

由上面的解释我们可以理解到,RTL_CRITICAL_SECTION 的内核对象主要就两个,一个是 OwningThread 它是当前线程的句柄,由 GetCurrentThread 提供,不需要我们创建,而 LockSemaphore 是在有需要的时候创建,那什么时候有需要呢?就是当调用 lock 函数时,经历一段时间的自旋锁抢夺,如果自旋锁抢夺失败后,判断到 LockSemaphore 值为空,就创建 LockSemaphore 对象,并进入内核等待。如果 LockSemaphore 不为空,就跳过创建过程,直接进入等待,也就是调用 WaitForSingleObject 函数。

是的,临界区在自旋锁(自旋锁是真的全在用户态执行)抢夺锁的拥有权失败后,的的确确会陷入内核等待。至于为什么临界区是叫用户态锁,我也解释不清楚,你叫它混合锁也可以。我只能很明确地告诉大家,临界区是有几率陷入内核态的(当自旋锁抢夺失败后)。

我们不要再纠结称谓这种问题了,让我们来根据上面的描述,来推敲一下临界区的伪代码。
(注明一下,我们的推敲都是忽略 DebugInfo 这个成员变量的处理的)

init_lock 函数:既然 OwningThread 在抢锁时赋值,LockSemaphore 是抢锁时按需创建,那么init_lock 只需要执行一些简单的赋值就可以了。

void init_lock(PRTL_CRITICAL_SECTION cs)
{
	cs->LockCount = -1;
	cs->RecursionCount = 0;
	cs->OwningThread = NULL;
	cs->SpinCount = 4000; //系统默认自旋锁旋转次数
	cs->LockSemaphore = NULL;
}

uninit_lock 函数:既然临界区在使用时,可能创建 LockSemaphore 这个对象,而其他的成员变量又是简单的赋值操作,所以在 uninit_lock 函数内部,只需要判断一下 LockSemaphore 是否为空,如果不为空,就调用 CloseHandle 函数。

void uninit_lock(PRTL_CRITICAL_SECTION cs)
{
	if (cs->LockSemaphore)
	{
		CloseHandle(cs->LockSemaphore);
	}
}

再来看看 try_lock 和 unlock,这两个函数简单一点,先让大家理解一下 :

bool try_lock(PRTL_CRITICAL_SECTION cs)
{
	//因为只是尝试锁定,只要判断到没人占用临界区,或者当前线程就是拥有者,
	//这个时候就可以直接宣布抢锁成功
	if (cs->OwningThread == NULL || cs->OwningThread == GetCurrentThread())
	{
		cs->OwningThread == GetCurrentThread();
		cs->RecursionCount++;
		return true;//抢锁成功
	}

	//抢锁失败也是直接返回,这个函数是不会进入等待的
	return false; //抢锁失败
}

bool unlock(PRTL_CRITICAL_SECTION cs)
{
	if (cs->OwningThread != GetCurrentThread())
	{
		//拥有锁的线程才能释放锁
		//这种做法不一定对,大家应该根据经验有自己的思考
		return false;
	}

	//线程重入递减
	cs->RecursionCount--;

	if (0 == cs->RecursionCount) //本线程所有的锁引用都退出了
	{
		cs->LockCount--; //减少抢锁的线程计数
		cs->OwningThread = 0; //置空其他线程可以抢锁了

		if (cs->LockCount >= 1 && cs->LockSemaphore)
		{
			//至少有一条线程正在等待抢锁,且cs->LockSemaphore不为空,
			//那么我们得唤醒一下正在 WaitForSingleObject 的线程
			SetEvent(cs->LockSemaphore);
		}
	}
	return true;
}

再看看复杂一点的 lock 伪代码:

void lock(PRTL_CRITICAL_SECTION cs)
{
	cs->LockCount++;
	if (cs->LockCount == 0)
	{
		//cs->LockCount 原来是-1,锁处于空闲状态,那么直接抢锁成功
		cs->OwningThread = GetCurrentThread();
		cs->RecursionCount = 1;//设置重入计数,这里是第一层
	}
	else
	{
		if (cs->OwningThread == GetCurrentThread())
		{
			//当前线程已经拥有锁,那么直接成功了
			cs->RecursionCount++;//增加一下重入计数即可
			return;
		}
		//else 没有成功,那么先进行自旋锁抢夺控制权
		//自旋锁抢夺不成功,再陷入内核态等待。
		wait_lock(cs);
	}
}

void wait_lock(PRTL_CRITICAL_SECTION cs)
{
	//已经有其他线程拥有锁了,那么先进行自旋锁抢夺控制权
	//用户态的自旋锁抢夺不成功,再陷入内核态等待。

	for (int i = 0; cs->SpinCount > i; i++)
	{
		//其实这里肯定要进行原始操作,
		//由于是伪代码,我就忽略了
		if (cs->OwningThread == NULL) 
		{
			cs->OwningThread = GetCurrentThread();
			cs->RecursionCount = 1;
			cs->LockCount--;
			return;
		}
	}

	//自旋锁抢夺失败,进入内核等待环节
	if (cs->LockSemaphore == NULL)
	{
		cs->LockSemaphore = CreateEvent();
	}

	while (cs->OwningThread != GetCurrentThread())
	{
		WaitForSingleObject(cs->LockSemaphore, -1);

		//每次被唤醒都检查临界区是否被占用了
		if (cs->OwningThread == NULL)
		{
			cs->OwningThread = GetCurrentThread();
			cs->RecursionCount = 1;
			cs->LockCount--;
		}
	}
}

上面的伪代码,主要描述了各个锁函数的大致流程, 是不考虑多线程安全问题的,而解决多线程访问变量的问题,比较简单的办法,就是使用原子操作和自旋锁。可能有些同学还没见识过自旋锁是什么,其实自旋锁是原子锁的一种,内核提供了实现方式,应用层没有提供实现函数。但是这个锁其实十分简单,我手动给大家实现两个:

typedef struct __SPINLOCKED {
	unsigned long long Lock;
}SPINLOCKED;

//spin_lock 和 spin_unlock 是常规操作
void spin_lock(SPINLOCKED *locked)
{
	//InterlockedCompareExchange 返回值是一个参数的原子值
	//其作用等同于原子操作一下代码:
	//if (locked->Lock == FALSE)
	//{
	//	locked->Lock = TRUE;
	//}
	
	//等同于多线程安全地操作一下代码:
	//另外一条线程抢占结束后,locked->Lock 还原为 FALSE, while 就会结束
	//while (locked->Lock == TRUE); 
	//locked->Lock = TRUE; //轮到我们有机会抢占了,当前将Lock设置为TRUE

	while (FALSE == InterlockedCompareExchange(&locked->Lock, TRUE, FALSE));
}

void spin_unlock(SPINLOCKED *locked)
{
	while (TRUE == InterlockedCompareExchange(&locked->Lock, FALSE, TRUE));
}


//下面两个是特殊一点的操作
void spin_thd_lock(SPINLOCKED* locked)
{
	unsigned long long hThread = GetCurrentThreadId();
	while (NULL == InterlockedCompareExchange(&locked->Lock, hThread, NULL));
}

void spin_thd_unlock(SPINLOCKED* locked)
{
	unsigned long long hThread = GetCurrentThreadId();
	while (hThread == InterlockedCompareExchange(&locked->Lock, NULL, hThread));
}

哈哈,是不是很简单。如果你熟悉原子操作的原理就会知道,上面的抢锁代码开销是非常大的,它相当于一直占据着CPU进行 【while(Value == TRUE)】这种运算,所以自旋锁只适合小区间的代码加锁,因为它必须尽快把锁的使用权抢到手,特别是注意自旋锁加锁区间不要有IO操作!
而临界区先进行自旋抢锁,自旋抢锁失败再陷入内核等待的原因是因为,进入内核等待的开销十分巨大,临界区希望牺牲最少的性能,即可获得锁的使用权,所以它先进行原子自旋,然而长时间的原子自旋其实开销也是比内核等待要大啊,所以原子自旋都是短时间操作(windows默认是4000转),当自旋结束还是没拿到锁的使用权限,这个时候只能放弃用户态的抢锁方法,进入内核态进入等待,因为内核态等待的方式是放弃线程的执行权限和时间片,在进行漫长的锁等待过程中,内核等待的开销反而变小了。
上面的理论也等于告诉我们:加锁区间的操作尽可能小而快,减少临界区进入内核态的可能,若加锁区间代码执行的时间都必须非常长,可以考虑直接使用内核锁,或者把临界区的自旋次数设置成 1 (设置为 0 可能会变成默认的4000)。

ReactOS源码:
好了,看临界区的伪代码我们知道怎么写了,自旋锁也知道怎么一回事了,我们来看看 ReactOS 的临界区实现吧。不过说实话,ReactOS 的临界区实现跟我们推敲出来的代码,有较大的差异,而且我发现它的实现方式,跟 windows 的文档描述并不一样,而 SpinCount 这个参数,根本没有被应用。这两点差距实在是有点大,所以对于ReactOS 的代码大家作为参考就好。还有 LockSemaphore 的按需要创建,跟windows 的描述也差异。只要是有两个线程同时在抢锁,LockSemaphore 就会被创建,不过 SpinCount 这个参数ReactOS都不管了,使用这个方式创建 LockSemaphore也不奇怪。

先看看 InitializeCriticalSection 函数实现,InitializeCriticalSection 函数经过参数装换后,最终执行的是 RtlInitializeCriticalSectionAndSpinCount 函数,其中 SpinCount 参数为 0。

NTSTATUS
NTAPI
RtlInitializeCriticalSectionAndSpinCount(PRTL_CRITICAL_SECTION CriticalSection,
                                         ULONG SpinCount)
{
    PRTL_CRITICAL_SECTION_DEBUG CritcalSectionDebugData;

    /* First things first, set up the Object */
    DPRINT("Initializing Critical Section: %p\n", CriticalSection);
    CriticalSection->LockCount = -1;
    CriticalSection->RecursionCount = 0;
    CriticalSection->OwningThread = 0;
    CriticalSection->SpinCount = (NtCurrentPeb()->NumberOfProcessors > 1) ? SpinCount : 0;
    CriticalSection->LockSemaphore = 0;

    /* Allocate the Debug Data */
    CritcalSectionDebugData = RtlpAllocateDebugInfo();
    DPRINT("Allocated Debug Data: %p inside Process: %p\n",
           CritcalSectionDebugData,
           NtCurrentTeb()->ClientId.UniqueProcess);

    if (!CritcalSectionDebugData)
    {

        /* This is bad! */
        DPRINT1("Couldn't allocate Debug Data for: %p\n", CriticalSection);
        return STATUS_NO_MEMORY;
    }

    /* Set it up */
    CritcalSectionDebugData->Type = RTL_CRITSECT_TYPE;
    CritcalSectionDebugData->ContentionCount = 0;
    CritcalSectionDebugData->EntryCount = 0;
    CritcalSectionDebugData->CriticalSection = CriticalSection;
    CritcalSectionDebugData->Flags = 0;
    CriticalSection->DebugInfo = CritcalSectionDebugData;

    /*
    * Add it to the List of Critical Sections owned by the process.
    * If we've initialized the Lock, then use it. If not, then probably
    * this is the lock initialization itself, so insert it directly.
    */
    if ((CriticalSection != &RtlCriticalSectionLock) && (RtlpCritSectInitialized))
    {

        DPRINT("Securely Inserting into ProcessLocks: %p, %p, %p\n",
               &CritcalSectionDebugData->ProcessLocksList,
               CriticalSection,
               &RtlCriticalSectionList);

        /* Protect List */
        RtlEnterCriticalSection(&RtlCriticalSectionLock);

        /* Add this one */
        InsertTailList(&RtlCriticalSectionList, &CritcalSectionDebugData->ProcessLocksList);

        /* Unprotect */
        RtlLeaveCriticalSection(&RtlCriticalSectionLock);
    }
    else
    {

        DPRINT("Inserting into ProcessLocks: %p, %p, %p\n",
               &CritcalSectionDebugData->ProcessLocksList,
               CriticalSection,
               &RtlCriticalSectionList);

        /* Add it directly */
        InsertTailList(&RtlCriticalSectionList, &CritcalSectionDebugData->ProcessLocksList);
    }

    return STATUS_SUCCESS;
}

看到了吗,忽略 DebugInfo 这个变量的处理。 InitializeCriticalSection 只是做了一个简单的数据初始化。另外windows还提供了一个修改自旋锁次数的 SetCriticalSectionSpinCount 函数:

DWORD
NTAPI
RtlSetCriticalSectionSpinCount(PRTL_CRITICAL_SECTION CriticalSection,
                               ULONG SpinCount)
{
    ULONG OldCount = (ULONG)CriticalSection->SpinCount;

    /* Set to parameter if MP, or to 0 if this is Uniprocessor */
    CriticalSection->SpinCount = (NtCurrentPeb()->NumberOfProcessors > 1) ? SpinCount : 0;
    return OldCount;
}

再看看 DeleteCriticalSection ,如果忽略 DebugInfo 的处理,它确实只是简单判断了一下 LockSemaphore 是否不为空,然后CloseHandle。

NTSTATUS
NTAPI
RtlDeleteCriticalSection(PRTL_CRITICAL_SECTION CriticalSection)
{
    NTSTATUS Status = STATUS_SUCCESS;

    DPRINT("Deleting Critical Section: %p\n", CriticalSection);
    /* Close the Event Object Handle if it exists */
    if (CriticalSection->LockSemaphore)
    {

        /* In case NtClose fails, return the status */
        Status = NtClose(CriticalSection->LockSemaphore);
    }

    /* Protect List */
    RtlEnterCriticalSection(&RtlCriticalSectionLock);

    if (CriticalSection->DebugInfo)
    {
        /* Remove it from the list */
        RemoveEntryList(&CriticalSection->DebugInfo->ProcessLocksList);
#if 0 /* We need to preserve Flags for RtlpFreeDebugInfo */
        RtlZeroMemory(CriticalSection->DebugInfo, sizeof(RTL_CRITICAL_SECTION_DEBUG));
#endif
    }

    /* Unprotect */
    RtlLeaveCriticalSection(&RtlCriticalSectionLock);

    if (CriticalSection->DebugInfo)
    {
        /* Free it */
        RtlpFreeDebugInfo(CriticalSection->DebugInfo);
    }

    /* Wipe it out */
    RtlZeroMemory(CriticalSection, sizeof(RTL_CRITICAL_SECTION));

    /* Return */
    return Status;
}

TryEnterCriticalSection 函数:

BOOLEAN NTAPI RtlTryEnterCriticalSection(PRTL_CRITICAL_SECTION CriticalSection)
{
    /* Try to take control */
    if (InterlockedCompareExchange(&CriticalSection->LockCount, 0, -1) == -1)
    {
        /* It's ours */
        CriticalSection->OwningThread = NtCurrentTeb()->ClientId.UniqueThread;
        CriticalSection->RecursionCount = 1;
        return TRUE;
    }
    else if (CriticalSection->OwningThread == NtCurrentTeb()->ClientId.UniqueThread)
    {
        /* It's already ours */
        InterlockedIncrement(&CriticalSection->LockCount);
        CriticalSection->RecursionCount++;
        return TRUE;
    }

    /* It's not ours */
    return FALSE;
}

TryEnterCriticalSection 函数的逻辑,相比我们的伪代码,它只是增加了原子操作,使函数具备了多线程安全。
EnterCriticalSection 函数:

NTSTATUS NTAPI RtlEnterCriticalSection(PRTL_CRITICAL_SECTION CriticalSection)
{
    HANDLE Thread = (HANDLE)NtCurrentTeb()->ClientId.UniqueThread;

    /* Try to Lock it */
    if (InterlockedIncrement(&CriticalSection->LockCount) != 0)
    {
        /*
         * We've failed to lock it! Does this thread
         * actually own it?
         */
        if (Thread == CriticalSection->OwningThread)
        {

            /* You own it, so you'll get it when you're done with it! No need to
               use the interlocked functions as only the thread who already owns
               the lock can modify this data. */
            CriticalSection->RecursionCount++;
            return STATUS_SUCCESS;
        }

        /* NOTE - CriticalSection->OwningThread can be NULL here because changing
                  this information is not serialized. This happens when thread a
                  acquires the lock (LockCount == 0) and thread b tries to
                  acquire it as well (LockCount == 1) but thread a hasn't had a
                  chance to set the OwningThread! So it's not an error when
                  OwningThread is NULL here! */

        /* We don't own it, so we must wait for it */
        RtlpWaitForCriticalSection(CriticalSection);
    }

    /* Lock successful. Changing this information has not to be serialized because
       only one thread at a time can actually change it (the one who acquired
       the lock)! */
    //else : 这里是CriticalSection->LockCount 原始值为 -1 的处理
    //表示锁处于空闲状态,直接成功
    //当然如果是抢锁成功也会进这里处理
    CriticalSection->OwningThread = Thread;
    CriticalSection->RecursionCount = 1;
    return STATUS_SUCCESS;
}

可以看到, 当锁并不空闲(CriticalSection->LockCount 不为 -1),RtlEnterCriticalSection 函数内部也没有进行自旋锁处理,这跟微软描述的处理情况不太一样。而要进行锁抢夺的操作,很明显是在 RtlpWaitForCriticalSection 函数完成的,我们再看看 RtlpWaitForCriticalSection 函数:

NTSTATUS
NTAPI
RtlpWaitForCriticalSection(PRTL_CRITICAL_SECTION CriticalSection)
{
    NTSTATUS Status;
    EXCEPTION_RECORD ExceptionRecord;
    BOOLEAN LastChance = FALSE;

    /* Do we have an Event yet? */
    if (!CriticalSection->LockSemaphore)
    {
        RtlpCreateCriticalSectionSem(CriticalSection);
    }

    /* Increase the Debug Entry count */
    DPRINT("Waiting on Critical Section Event: %p %p\n",
           CriticalSection,
           CriticalSection->LockSemaphore);

    if (CriticalSection->DebugInfo)
        CriticalSection->DebugInfo->EntryCount++;

    for (;;)
    {

        /* Increase the number of times we've had contention */
        if (CriticalSection->DebugInfo)
            CriticalSection->DebugInfo->ContentionCount++;

        /* Check if allocating the event failed */
        if (CriticalSection->LockSemaphore == INVALID_HANDLE_VALUE)
        {
            /* Use the global keyed event (NULL as keyed event handle) */
            //Event创建失败,则等待一个全局Event,这个时候一般是资源耗尽的状态了。
            Status = NtWaitForKeyedEvent(NULL,
                                         CriticalSection,
                                         FALSE,
                                         &RtlpTimeout);
        }
        else
        {
            /* Wait on the Event */
            Status = NtWaitForSingleObject(CriticalSection->LockSemaphore,
                                           FALSE,
                                           &RtlpTimeout);
        }

        /* We have Timed out */
        if (Status == STATUS_TIMEOUT)
        {

            /* Is this the 2nd time we've timed out? */
            if (LastChance)
            {

                DPRINT1("Deadlock: %p\n", CriticalSection);

                /* Yes it is, we are raising an exception */
                ExceptionRecord.ExceptionCode = STATUS_POSSIBLE_DEADLOCK;
                ExceptionRecord.ExceptionFlags = 0;
                ExceptionRecord.ExceptionRecord = NULL;
                ExceptionRecord.ExceptionAddress = RtlRaiseException;
                ExceptionRecord.NumberParameters = 1;
                ExceptionRecord.ExceptionInformation[0] = (ULONG_PTR)CriticalSection;
                RtlRaiseException(&ExceptionRecord);//抛出异常
            }

            /* One more try */
            LastChance = TRUE;
        }
        else
        {

            /* If we are here, everything went fine */
            return STATUS_SUCCESS;
        }
    }
}

可以看到,RtlpWaitForCriticalSection 主要有两步动作,一是判断LockSemaphore是否为空,为空则创建一个Event;另外一步是在循环中判断锁等待是否轮到自己抢到了锁权限。哎,还是没有自旋锁处理的动作,看来真的跟微软的实现有差别。
再看看 RtlpCreateCriticalSectionSem 函数:

_At_(CriticalSection->LockSemaphore, _Post_notnull_)
    VOID
    NTAPI
    RtlpCreateCriticalSectionSem(PRTL_CRITICAL_SECTION CriticalSection)
{
    HANDLE hEvent = CriticalSection->LockSemaphore;
    HANDLE hNewEvent;
    NTSTATUS Status;

    /* Check if we have an event */
    if (!hEvent)
    {

        /* No, so create it */
        Status = NtCreateEvent(&hNewEvent,
                               EVENT_ALL_ACCESS,
                               NULL,
                               SynchronizationEvent, //自动重置
                               FALSE);
        if (!NT_SUCCESS(Status))
        {
            DPRINT1("Failed to Create Event!\n");

            /* Use INVALID_HANDLE_VALUE (-1) to signal that the global
                   keyed event must be used */
            hNewEvent = INVALID_HANDLE_VALUE;
        }

        DPRINT("Created Event: %p \n", hNewEvent);

        /* Exchange the LockSemaphore field with the new handle, if it is still 0 */
        if (InterlockedCompareExchangePointer((PVOID *)&CriticalSection->LockSemaphore,
                                              (PVOID)hNewEvent,
                                              NULL) != NULL)
        {
            /* Someone else just created an event */
            if (hEvent != INVALID_HANDLE_VALUE)
            {
                DPRINT("Closing already created event: %p\n", hNewEvent);
                NtClose(hNewEvent);
            }
        }
    }

    return;
}

RtlpCreateCriticalSectionSem 可以简化为 CreateEvent 函数,它特殊的地方不过是在于 PRTL_CRITICAL_SECTION 对象的访问时,如何保证了数据是独占的,安全的。

最后是 LeaveCriticalSection 函数:

NTSTATUS NTAPI RtlLeaveCriticalSection(PRTL_CRITICAL_SECTION CriticalSection)
{
#if DBG
    HANDLE Thread = (HANDLE)NtCurrentTeb()->ClientId.UniqueThread;

    /* In win this case isn't checked. However it's a valid check so it should only
       be performed in debug builds! */
    if (Thread != CriticalSection->OwningThread)
    {
        DPRINT1("Releasing critical section not owned!\n");
        return STATUS_INVALID_PARAMETER;
    }
#endif

    /* Decrease the Recursion Count. No need to do this atomically because only
       the thread who holds the lock can call this function (unless the program
       is totally screwed... */
    if (--CriticalSection->RecursionCount)
    {
        /* Someone still owns us, but we are free. This needs to be done atomically. */
        InterlockedDecrement(&CriticalSection->LockCount);
    }
    else /*if(0 == CriticalSection->RecursionCount)*/
    {
        /* Nobody owns us anymore. No need to do this atomically. See comment
            above. */
        CriticalSection->OwningThread = 0;

        /* Was someone wanting us? This needs to be done atomically. */
        if (-1 != InterlockedDecrement(&CriticalSection->LockCount))
        {
            /* Let him have us */
            RtlpUnWaitCriticalSection(CriticalSection);
        }
    }

    /* Sucessful! */
    return STATUS_SUCCESS;
}

VOID NTAPI RtlpUnWaitCriticalSection(PRTL_CRITICAL_SECTION CriticalSection)
{
    NTSTATUS Status;

    /* Do we have an Event yet? */
    if (!CriticalSection->LockSemaphore)
    {
        RtlpCreateCriticalSectionSem(CriticalSection);
    }

    /* Signal the Event */
    DPRINT("Signaling Critical Section Event: %p, %p\n",
           CriticalSection,
           CriticalSection->LockSemaphore);

    /* Check if this critical section needs to use the keyed event */
    if (CriticalSection->LockSemaphore == INVALID_HANDLE_VALUE)
    {
        /* Release keyed event */
        Status = NtReleaseKeyedEvent(NULL, CriticalSection, FALSE, &RtlpTimeout);
    }
    else
    {
        /* Set the event */
        Status = NtSetEvent(CriticalSection->LockSemaphore, NULL);
    }

    if (!NT_SUCCESS(Status))
    {

        /* We've failed */
        DPRINT1("Signaling Failed for: %p, %p, 0x%08lx\n",
                CriticalSection,
                CriticalSection->LockSemaphore,
                Status);
        RtlRaiseStatus(Status);//抛出异常
    }
}

LeaveCriticalSection 函数逻辑跟我们的伪代码也差不多了,它里面还包含一个 RtlpUnWaitCriticalSection函数,我直接把它们的代码贴在一起了。可以到 RtlpUnWaitCriticalSection 也是简单地为 LockSemaphore 创建了一个内核对象,跟微软说的自旋抢夺失败再创建有一定的差别。而另外一个是跟我们伪代码伪代码不一样的地方,就是我们伪代码是只有拥有锁的线程才能成功释放锁,而ReactOS 和 windows 的实现特性是任何线程都可以释放锁,到底谁对谁错呢?这个东西真不好判断,大家还是根据经验来分析吧!

虽然本章没有提供和windows逻辑一致的临界区代码,但是ReactOS的临界区实现还是值得参考的,我希望大家有能力通过上面的伪代码和ReactOS示例,来实现一个属于自己的临界区。
windows 已经提供临界区了,干嘛还要自己实现?
其实这样建议是为了让大家更加深入地理解临界区。你别看临界区使用简单,但它却包含了很多锁封装的必备常识,比如锁的lock 函数应该尽快完成,那么临界区是如何尽快让 lock 函数完成的呢?为什么 LockCount 成员要用原子操作访问?RecursionCount 和 OwningThread 不需要原子操作吗?还有LockSemaphore变量,如果同时有多个线程进行抢锁,而在自旋阶段抢锁失败的线程,会不会同时为 LockSemaphore 创建对象?如果会,那么情况要如何处理?如果不会,那么保证数据一致性的同时,会不会造成性能的下降?trylock适用于哪些场景?
哈哈,看来问题真是一大堆,所以这个锁对于每一个开发者都是必须深入理解的,对其日常开发非常有帮助!

另外通过我之前对 WaitForSingleObject 函数原理的讲解,再看看临界区的封装,大家应该能够意识到,互斥量,事件对象,原子操作,这三者可以说是锁封装的基类,不需要支持跨进程的锁,可以优先考虑用事件对象和原子操作封装,而需要支持跨进程的锁,可以优先考虑用互斥量去封装。简单的一点封装需求,比如实现条件变量,读写锁,乐观锁等,复杂一点的锁封装,比如死锁检测,如何知道自己产生了死锁?知道了以后应用又如何能够主动解决死锁问题?如果没有一些锁原理的知识点作为支撑,很多人根本无法封装上面所说的简易锁和复杂锁。

问题解答:
a1. 临界区如何让 lock 函数尽快完成?
这个问题的答案已经提过了,就是先利用自旋锁在应用层进行短时间的等待,期望尽快拿到锁的使用权,当应用层抢夺使用权失败后,再陷入开销较大的内核态等待。如果临界区不曾进入内核等待,那么PRTL_CRITICAL_SECTION只是一个简单的空壳而且,由于没有内核对象,这个时候不仅仅是性能,内存的开销也是非常小的。

a2. 为什么 LockCount 成员要用原子操作访问?RecursionCount 和 OwningThread 不需要原子操作吗?
LockCount 要用原子递增递减,是因为它会被多个线程访问,而ReactOS代码中 RecursionCount 和 OwningThread 不需要原子操作,是因为它们不会被多线程访问,因为拥有锁权限的线才能访问这两个变量,而拥有锁权限的线程只能有一条。另外还有注意这三个变量处理的先后顺序,在 lock 函数中,是先处理 LockCount 变量,再处理 RecursionCount 和 OwningThread 的。而在 unlock 函数中,是先处理 RecursionCount 和 OwningThread 变量,再处理 LockCount 变量,这个顺序十分重要。
上面加的解答中,我特意加上了【ReactOS代码】这个字眼,windows实际的中操作,是否可能会对 RecursionCount 和OwningThread 进行原子操作呢?大家回顾一下 wait_lock 函数的伪代码,深入思考就会发现,抢锁时需要修改 OwningThread 变量,由于ReactOS 是直接使用内核锁,抢锁时只有一条线程被激活,所以它没对 OwningThread 进行原子操作也是可以的,但 windows 说自己里面可是加入了自旋锁的,自旋时可能多个线程在同时修改 OwningThread 为自己线程的Handle,而OwningThread 是只有当它原始值为 NULL 的时候才能被修改成功的,所以在存在自旋锁的情况下,OwningThread 还是要进行原子操作,才能保证只有一个线程将 OwningThread 修改为自己的线程。而对于 RecursionCount ,虽然我们期望只有拥有锁权限的线程才能修改它,但期望归期望,现实归现实。假设一个没调用 lock 函数的线程,直接调用了 unlock 函数,大家思考一下,我们的伪代码是怎么处理的?ReactOS的代码是怎么处理的?
如果像我们伪代码一样,unlock 中提前加入了拥有者权限判断,就不会产生多线程访问 RecursionCount 的问题。像ReactOS发生上述假设的情况,RecursionCount 就可能发生脏操作,由于RecursionCount 值的不正确,将会让整个临界区变得不稳定,产生一些乱七八糟的行为。解决方法是像我们伪代码一样加入拥有者权限判断,又或者对 RecursionCount 进行原子操作。这两个方法哪一种好呢?这个真的是见仁见智,假设我要设计一个能够自动检测死锁(原理是链表环路检查),并且能够解决死锁(链表产生回路后,设置死锁标记,再调用unlock)的锁封装,由于抢锁的线程已经被挂起了,它无法再执行unlock,那么unlock只能交由另外一个线程执行,如果unlock附带了拥有者权限判断的话,那它就无法用来封装这么一个锁了。但话又说回来,除了做数据库底层开发的人外,没几个人需要这样的锁,所以加入了拥有者判断貌似也不碍事,还能防止一些新手犯错。

a3. 如果同时有多个线程进行抢锁,会不会同时为 LockSemaphore 创建对象?
答案是会的,所以多个线程抢锁的,有可能造成频繁调用CreateEvent 函数,这个性能开销的是很大的。造成RTL_CRITICAL_SECTION对象由用户对象转变为内核对象的原因有三:一是临界区加锁区间的代码执行时间太长,大家根据业务酌情优化;二是抢锁线程太多,假设有两个线程抢锁,那么第二个获得锁的时间就是第一个线程互斥动作完成的时间,如果有三个线程抢锁,那么第三个线程获得锁的时间,就是前面两个锁,互斥动作叠加完成的时间,抢锁时间越久,明显越容易产生内核对象,可以想办法减少抢锁的线程数量,或者减少锁碰撞的几率;第三个原因比较隐蔽,就是线程数量太多,这个线程数量说的可不是第二个原因中的抢锁线程,而是一些跟锁无关的活跃线程,因为线程太多,导致频繁的上下文切换,每条线程执行时间可能减少,而执行时间间距增加(线程的代码是通过不断的上下文切换断断续续执行的,并不是连续执行的),大家试想,两个线程抢锁,1号线程只执行一句 a++ 就进入 unlock 了,但在unlock 中时间片超时到达,锁还没完全释放线程就被置换出了cpu,等到1秒后(夸张说法)再被置换回cpu执行,那么2号线程基本就会产生内核锁了,另外这里大家要留意到一个问题:既然1号线在unlock函数中产生了被置换出去1秒的情况,其实2号线程也可能在 lock 函数发生了被置换出去1秒的情况,这个时候Lock函数的等待时间也有几率与unlock函数的释放时间对冲。
从ReactOS的源码中我们可以看到,如果是多个线程同时创建了Event 对象,只有一个能够赋值到 LockSemaphore 变量,因为它使用的是原子操作,而其他赋值失败的Event对象,都会被CloseHandle。
我还看到过另外一个处理办法:进程启动时,先给进程创建一个全局的互斥锁,而LockSemaphore 对象是互斥创建的,既保证了LockSemaphore 一致性,又不会产生多个Event对象。到底那一种方法更优,大家可以测试一下。

a4. trylock适用于哪些场景?
通篇文章对trylock 的介绍比较少,是因为trylock函数不太重要吗?对,这函数还真的是很鸡肋(至少我是这样认为的),trylock适用的场景我确实暂时没见到过,不适用 trylock 的场景却用了 trylock 的我却见过几次。trylock 函数乱用,往往会带来一些你没考虑完善的逻辑问题,而用 lock 函数代替后,这个问题往往能够解决,所以用过 trylock 的同学最好回去重新捋一捋自己的代码。
而且临界区犯错,有一个很有意思的现象:其他的技术错误,开发者往往能够感受到自己没有思考出更好解决方案,而临界区的应用方法错误,开发者往往觉得自己的方案得很好了。就是觉得自己的方法合情合理,再review一遍也没有问题,但别人告诉你答案后,你会发现确实有这么一个问题。比如下面的摘自 libevent 的源代码,它的作用很简单,就是取 buffer 的长度

//libevent 源码:
size_t evbuffer_get_length(const struct evbuffer *buffer)
{
	size_t length;
	EVBUFFER_LOCK(buffer); //加锁
	length= (buffer->total_len);
	EVBUFFER_UNLOCK(buffer); //开锁
	return length; 
}

粗略一看貌似没什么问题,但是深入思考一下,改成下面这样,它不会有影响,还提高了性能

//修改后的代码:
size_t evbuffer_get_length(const struct evbuffer *buffer)
{
	//上面源代码,执行到return语句时,length的值就可能跟total_len的值不一样了,
	//反正都是可能不一样,我还不如直接返回呢
	return (buffer->total_len);//直接返回
}

我当然不是质疑libevent开发者的能力,我只是想告诉大家,临界区应用错误的场景,总是那么的措不及防。好比 trylock,看上去这函数合情合理,但深入思考你会发现,这函数还真有点一无是处。它好像除了加深代码逻辑的复杂度外,很难说出它在实际开发中有哪些优点。

a5. 每个等待临界区的线程,抢到锁的几率都是一样的吗?
临界区的抢锁原理我们已经说的很清楚了,先从自旋锁抢,再陷入内核等待,被内核唤醒后再抢,失败后再陷入内核等待。假设有如下情况:
A线程正在准备调用 unlock 释放锁
B线程正在准备调用 lock 函数
C已经在 lock 函数的内部,并且陷入了内核等待
那么 B 和 C 谁会更大几率得到锁的使用权呢?假设现在 A 线程已经进入了 unlock 函数内部,释放锁的使用权后,通过 LockCount 变量判断到有两个线程正在等待抢夺锁的使用权(明显就是B和C了),然后 A 在 unlock 内部调用 SetEvent 函数唤醒其中一条正在等待锁的线程。注意,只能唤醒一条,因为事件对象是信号自动重置的,而且即时唤醒再多的线程,最终抢到锁的线程也是只有一条,结果抢不到的还是要重新沉睡,唤醒多条只会浪费效率。
再回到 unlock 内部调用 SetEvent 这个动作,明显它将会唤醒 C 线程,因为 B 线程正准备调用 lock 函数呢,证明 B 还没陷入沉睡。那么在这个时候,最终往往是 B 线程抢到了锁,而 C 线程继续陷入沉睡,因为抢锁只需要进行一些简单的原子操作,而线程从内核中被唤醒,而从 A 线程调用 unlock 到 C 线程醒来这段时间,相对于原子操作,可是一个十分耗时的过程。
这个细节大家要记得,在多个线程同时处于 lock 函数之中时,正在运行的线程,比已经陷入沉睡的线程更容易抢到锁,这就是优的更优,劣的更劣法则。所以在临界区使用时,减少锁碰撞(多个线程同时调用 lock 函数)的几率,还有共同抢锁的线程,十分有必要。

说在最后
临界区的细节问题,说起来真的是一大堆,一篇小小的文章实在无法为大家一一详尽。很多人只是简单地用来临界区来保证了数据的正确性(有时候甚至只是看上去保证了,比如上述的libevent代码),但性能问题,以及复杂场景的死锁问题,以及锁本质的应用问题(这个业务是否要加锁?不加锁行不行?单线程行不行?这时候用来自旋锁,临界区,读写锁,互斥量,哪个锁会更好?)等等,其实根本没进行有意识的处理。
所以大家千万不要轻视这个锁,需要好好深入学习其原理,以及在复杂场景的封装设计。

  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值