临界区
临界区(Critical Section)是一段被保护的代码块,在任意时刻,最多只能有一个线程进入该代码块,即对临界区的访问是原子的、排它的,因此常把一些需要同步的受保护资源放在临界区中。临界区的原子性,是指在临界区中线程不能并发执行。当然,系统仍然可以中断当前线程的执行并调度其它线程,但是系统不会调度那些请求进入该临界区的线程,直到当前线程离开临界区。
下面是一段可能会产生问题的代码:
const int COUNT = 1000;
int g_nSum = 0;
DWORD WINAPI FirstThread(PVOID pvParam) {
g_nSum = 0;
for(int n = 1; n <= COUNT; n++) {
g_nSum += n;
}
return g_nSum;
}
DWORD WINAPI SecondThread(PVOID pvParam) {
g_nSum = 0;
for(int n=1; n <= COUNT; ++n){
g_nSum += n;
}
return g_nSum;
}
单独执行时,两个函数会产生同样的结果。但如果两个线程同时执行,每个线程会在另一线程不知情的情况下更改共享变量g_nSum,导致不可预测的结果。我们可以使用临界区来避免这种问题:
const int COUNT = 10;
int g_nSum = 0;
CRITICAL_SECTION g_cs;
DWORD WINAPI FirstThread(PVOID pvParam){
EnterCriticalSection(&g_cs);
g_nSum = 0;
for(int n = 1; n <= COUNT; ++n)
g_nSum += n;
LeaveCriticalSection(&g_cs);
return g_nSum;
}
DWORD WINAPI SecondThread(PVOID pvParam){
EnterCriticalSection(&g_cs);
g_nSum = 0;
for(int n = 1; n <= COUNT; ++n)
g_nSum += n;
LeaveCriticalSection(&g_cs);
return g_nSum;
}
上面的代码定义了临界区变量CRITICAL_SECTION g_cs,然后将所有访问共享变量的代码置于EnterCriticalSection和LeaveCriticalSection之间。这就是使用临界区的方法。当需要在多线程环境下访问共享资源时,你应该创建CRITICAL_SECTION结构的变量,并在要访问资源的代码前调用EnterCriticalSection。EnterCriticalSection会检查是否有其它线程已经进入同一CRITICAL_SECTION变量标识的临界区,如果有,当前线程会一直等待直到其它线程调用LeaveCriticalSection离开该临界区,否则当前线程将执行EnterCriticalSection之后的代码。当线程不再需要访问共享资源时,应该调用LeaveCriticalSection退出临界区,这样操作系统就可以调度正在等待同一临界区的其它线程,否则其它线程将永远处于等待状态。
当使用Interlocked函数族无法解决同步问题时,你应该尝试临界区。临界区的优点是易于使用,且因为内部使用了Interlocked函数,因此执行速度很快,不足之处在于临界区仅能用于同一进程内的线程同步。
临界区细则
你应该已经明白临界区的作用以及它如何保证对共享资源的排它访问了。现在我们来讨论临界区的工作原理,从CRITICAL_SECTION结构谈起。我们在Platform SDK文档中查不到该结构的任何信息,因为微软认为开发人员无需了解临界区的实现细节。然而,你可以在Windows头文件中找到该结构的声明,CRITICAL_SECTION在WinBase.h中被声明为RTL_CRITICAL_SECTION,RTL_CRITICAL_SECTION在WinNT.h中定义。使用CRITICAL_SECTION结构时,你应该总是调用相应的Windows函数以确保结构的一致性,不要尝试自己更改其成员变量的状态。下面就让我们看看和CRITICAL_SECTION结构相关的一些Windows函数。
CRITICAL_SECTION类型的变量一般声明为全局变量以便各个线程引用,当然,也可以将其声明为局部变量或动态创建,或者将其作为类的私有成员变量来使用。在任何线程使用CRITICAL_SECTION变量之前,应该将其初始化,这是通过调用函数
VOID InitialiseCriticalSection(PCRITICAL_SECTION pcs)
完成的,该调用必须在所有EnterCriticalSection调用之前,Platform SDK文档指出使用没有初始化的CRITICAL_SECTION变量会导致未知的结果。
当不再需要使用临界区变量时,应该调用
VOID DeleteCriticalSection(PCRITICAL_SECTION pcs)
将其清除。DeleteCriticalSection会重置pcs指向的CRITICAL_SECTION结构的状态。使用重置后的临界区变量会导致未知的结果。
进入临界区时,应该调用
VOID EnterCriticalSection(PCRITICAL_SECTION pcs)
该函数会检查pcs指向的临界区的状态,并做出相应的选择:
- 假如没有任何线程在pcs指向的临界区内,EnterCriticalSection会更改该临界区的状态以表示当前线程已进入临界区中执行,其它要进入同一临界区的线程必须等待。然后EnterCriticalSection返回,线程继续执行
- 假如当前线程已经位于pcs指向的临界区中,EnterCriticalSection会更改临界区的状态以记录当前线程进入临界区的次数,然后返回线程继续执行。这种情形比较罕见,通常在线程连续多次调用了EnterCriticalSection,但没有调用LeaveCriticalSection时发生
- 假如已经有别的线程进入了pcs指向的临界区,EnterCriticalSection会使用事件(Event)对象将当前线程挂起,直到临界区内的线程调用LeaveCriticalSection退出,此时当前线程将重新被调度,EnterCriticalSection重新检查临界区状态
EnterCriticalSection并不复杂,它仅仅做了一些条件判断,该函数的价值在于它的执行是原子的。在多处理器环境中,假如位于不同CPU核心上的两个线程在同一时刻调用EnterCriticalSection想进入同一临界区,函数依然会正确的执行,最终只会有一个线程进入临界区,而另一个线程将处于等待状态。处于等待状态的线程可能会因为优先级的缘故长时间得不到调度,甚至一直处于等待状态,此时,我们说这个线程处于“饥饿”(starved)状态。
你可以使用
BOOL TryEnterCriticalSection(PCRITICAL_SECTION pcs)
代替EnterCriticalSection,当pcs指向的临界区暂时无法进入时,TryEnterCriticalSection会立刻返回FALSE而不是等待,其它情况下TryEnterCriticalSection返回TRUE,此时它会设置临界区的状态以表示着当前线程已经进入临界区。和EnterCriticalSection类似,每一个返回TRUE的TryEnterCriticalSection调用必须和一个LeaveCriticalSection调用匹配。
当你访问完共享资源后,应该退出临界区:
VOID LeaveCriticalSection(PCRITICAL_SECTION pcs)
CRITICAL_SECTION结构中有一个变量counter用来记录当前线程进入临界区的净次数(指没有LeaveCriticalSection匹配的EnterCriticalSection/TryEnterCriticalSection的调用次数),LeaveCriticalSection会把当前线程对应的counter减1,假如counter此时仍大于0,LeaveCriticalSection只是返回,否则LeaveCriticalSection更改临界区的状态以表示已经没有线程位于临界区中,这样操作系统方能调度其它等待该临界区的线程。和EnterCriticalSection/TryEnterCriticalSection类似,LeaveCriticalSection也是原子操作。
临界区和自旋锁
当调用EnterCriticalSection试图进入已被别的线程占用的临界区时,线程将被切换为等待状态。这意味着线程会从用户模式切换到内核模式,这种切换代价较高,大概需要1000个时钟期。试想一下,在多处理器环境下,假如占用临界区的线程在当前线程切换的过程中退出临界区的话,这1000多个时钟周期就被白白浪费了。为了避免这种情况,微软提供了一种将临界区和自旋锁结合使用的方法,EnterCriticalSection首先会进入自旋锁,在指定的自旋锁循环次数之内假如当前线程可以进入临界区的话,就不用再进入内核模式等待了。使用这种方法时,你应该调用InitializeCriticalSectionAndSpinCount初始化临界区:
BOOL InitializeCriticalSectionAndSpinCount(
PCRITICAL_SECTION pcs,
DWORD dwSpinCount);
InitializeCriticalSectionAndSpinCount的参数pcs是要进入的临界区的地址,参数dwSpinCount指定了当线程无法进入临界区时自旋锁的循环次数,dwSpinCount的取值范围是0~0x00FFFFFF。在单核心CPU的系统中,dwSpinCount的值将被忽略,因为在单处理器中使用自旋锁是毫无意义的:假如线程一直占用CPU执行自旋锁,其它占用临界区的线程将无法退出。
可以调用函数SetCriticalSectionSpinCount改变指定临界区的自旋锁循环次数:
DWORD SetCriticalSectionSpinCount(
PCRITICAL_SECTION pcs,
DWORD dwSpinCount);
再强调一次,在单核心处理器的系统中dwSpinCount参数将被忽略。
在我看来,配合使用自旋锁和临界区总是个好意义,这样可以提升应用的潜在性能且不会带来任何损失。问题在于如何确定dwSpinCount的值,这个要视情况而定,比如保护进程堆的临界区的dwSpinCount大约是4000。
临界区和错误处理
InitializeCriticalSection调用有时会失败,因为它会请求操作系统分配一些内存以存储内部调试信息,假如内存分配不成功,系统会抛出STATUS_NO_MEMORY异常,你可以使用结构化异常处理框架捕捉该异常,更好的处理方法是用InitializeCriticalSectionAndSpinCount代替InitializeCriticalSection,InitializeCriticalSectionAndSpinCount同样会请求系统为其分配内存,假如请求失败,它会返回FALSE。
使用临界区的另一个问题是,在xp之前的系统中,临界区对象的所需的内存并不是InitializeCriticalSection(AndSpinCount)分配的,而由第一次使用该对象的EnterCriticalSection分配,当内存分配失败时,系统抛出EXCEPTION_INVALID_HANDLE异常,此时,你可以有两种选择:第一,使用结构化异常处理框架捕捉该异常;第二,使用InitializeCriticalSectionAndSpinCount并将dwSpinCount参数的高位设置为1(具体设置哪一位、设置多少位作者没说,msdn中也查不到),此时临界区对象的内存分配工作将由InitializeCriticalSectionAndSpinCount来做,分配失败时它会返回FALSE。这样使用EnterCriticalSection就不用担心有任何异常了。
这儿作者简单描述了一种从XP开始出现的名为keyed event的内核对象,它主要用来解决在资源紧张的情况下内核对象申请内存失败的问题,由于keyed event并没有文档说明,也没有相应的api调用,此处不再翻译。