正文:
一般锁的类型可分为两种:用户态锁和内核态锁。用户态锁是指这个锁的代码只依赖于用户态,不会陷入内核态执行。而内核态锁很明显就是哪些会陷入内核态执行的锁。一般书中会说,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 所返回的 ID 相同。
LockSemaphore 它是一个内核对象句柄,用于通知操作系统:该临界区现在空闲。操作系统在一个线程第一次尝试获得该临界区,但被另一个已经拥有该临界区的线程所阻止时,自动创建这样一个句柄。应当调用 DeleteCriticalSection(它将发出一个调用该事件的 CloseHandle 调用,并在必要时释放该调试结构),否则将会发生资源泄漏。
SpinCount 仅用于多处理器系统。MSDN文档对此字段进行如下说明:“在多处理器系统中,如果该临界区不可用,调用线程将在对与该临界区相关的信号执行等待操作之前,旋转 dwSpinCount 次。如果该临界区在旋转操作期间变为可用,该调用线程就避免了等待操作。”旋转计数可以在多处理器计算机上提供更佳性能,其原因在于在一个循环中旋转通常要快于进入内核模式等待状态。此字段默认值为零,但可以用 InitializeCriticalSectionAndSpinCount API 将其设置为一个不同值。
由上面的解释我们可以理解到,RTL_CRITICAL_SECTION 的内核对象主要就两个,一个是 OwningThread 它是当前线程的句柄,
由 GetCurrentThread 提供,不需要我们创建,而 LockSemaphore 是在有需要的时候创建,那什么时候有需要呢?就是当调用 lock 函数时,经历一段时间的自旋锁抢夺,如果自旋锁抢夺失败后,判断到 LockSemaphore 值为空,就创建 LockSemaphore 对象,并进入内核等待。如果 LockSemaphore 不为空,就跳过创建过程,直接进入等待,也就是调用 WaitForSingleObject 函数。
等等,前面不是说好了临界区是用户态锁,不会陷入内核态等待的吗?这个调用 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; //抢锁失败
}
void unlock(PRTL_CRITICAL_SECTION cs)
{
if (cs->OwningThread != GetCurrentThread())
{
//肯定是拥有锁的线程才能释放锁啊
return;
}
cs->RecursionCount--;
if (0 == cs->RecursionCount)
{
//本线程所有的锁引用都退出了
cs->LockCount--; //减少抢锁的线程计数
cs->OwningThread = 0; //置空其他线程可以抢锁了
if (cs->LockCount >= 1 && cs->LockSemaphore)
{
//至少有一条线程正在等待抢锁,且cs->LockSemaphore不为空,
//那么我们得唤醒一下正在 WaitForSingleObject 的线程
SetEvent(cs->LockSemaphore);
}
}
}
再看看复杂一点的 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--;
}
}
}
上面的伪代码,是不考虑多线程安全问题的,而 PRTL_CRITICAL_SECTION 里面的成员,除了 RecursionCount 以外,其他成员都会有多线程访问的情况。RecursionCount 没有被多线程访问,是因为它是线程重入计数,只有拥有锁的线程才会访问它。而其他成员变量,解决多线程访问的问题,比较简单的办法,就是使用原子操作和自旋锁。
可能有些同学还没见识过自旋锁是什么,其实自旋锁是原子锁的一种,内核提供了实现方式,应用层没有提供实现函数。但是这个锁其实十分简单,我手动给大家实现两个:
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));
}
哈哈,是不是很简单。
好了,看完一大堆伪代码,我们来看看ReactOS的临界区实现吧。不过说实话,ReactOS的临界区实现跟我们推敲出来的代码,有一点的差异,而且我发现它的实现方式,跟windows的文档描述并不一样,比如 LockSemaphore 并不是按需要创建的,即时 lock 函数没有马上创建 LockSemaphore 对象,在 unlock 函数中也一定会被创建。而 SpinCount 这个参数,根本没有被应用。这两点差距实在是有点大,所以对于ReactOS 的代码大家作为参考就好。
用法经验分享:



