Windows Vista 新增的同步原语

来源:互联网 发布:医学影像工作站软件 编辑:程序博客网 时间:2024/06/10 01:21
并发操作
Windows Vista 新增的同步原语
Robert Saccone and Alexander Taskov
代码下载位置: VistaSynchronization2007_06.exe(174 KB)
Browse the Code Online

本文讨论:
  • 条件变量
  • Slim 读取器锁/写入器锁
  • 一次性初始化
  • 仔细查过锁定
本文使用了以下技术:
Windows Vista, C++
条件变量
Slim 读取器锁/写入器锁
一次性初始化
结束语
Windows Vista 附带了一系列面向开发人员的令人振奋的新技术,包括 Windows® Presentation Foundation、Windows Communication Foundation 和 Windows Workflow Foundation。事实上,Windows Vista™ 已引入太多新的 .NET 友好技术,因此我们很容易忽略本机 C/C++ 开发人员可用于其应用程序的所有新特性和功能。
在本文中,我们将讨论影响本机 C/C++ 开发人员的一些 Windows Vista 新功能。我们将重点介绍新操作系统中引入的几种新线程同步功能:条件变量、Slim 读取器锁/写入器锁和一次性初始化。

条件变量
条件变量在其他线程库中已经存在一段时间了,只是很遗憾地被 Windows SDK 遗漏了。条件变量主要用来根据一些条件测试的结果同步化一组线程。尽管可通过使用现有同步结构组合来做到这一点,但是条件变量具有释放已获取的锁,并通过一个原子操作进入休眠状态的能力。它还提供了一种更清楚且又少出错的方法,以用于实现所需的行为。
Windows SDK for Windows Vista(可供下载)公开了 CONDITION_VARIABLE 结构的条件变量。您可用 InitializeConditionVariable 函数创建该结构。没有用来清理或销毁 CONDITION_VARIABLE 结构的函数,因为底层实现用不着它。
通过使用函数 SleepConditionVariableCS(使用关键节时)或 SleepConditionVariableSRW(使用 Slim 读取器锁/写入器锁时),您可让线程等待条件变量。当另一条线程调用 WakeConditionVariable 或 WakeAllConditionVariable 时,这些休眠线程将被释放,这取决于调用线程是想要释放等待条件变量的一条线程还是所有线程。
常见的生产者/使用者问题代表了可使用条件变量的情况。这一典型示例是指生产者生成数据并将其置入缓冲区,而使用者则从缓冲区抓取待处理的数据片段的情形。该问题指出了一种需求,即保证生产者不会试图向填满的缓冲区添加数据,而使用者不会试图从空缓冲区抓取数据。我们将分析本情形,以向您说明条件变量是如何帮助解决问题的。
针对此例,我们将创建一个向共享队列传送数值数据的单一生产者线程。我们然后创建五个使用者线程。每个使用者线程将从队列中转移一个项目并进行处理。当处理完当前数据段后,使用者线程将循环,无限重复该过程。
在早期版本的 Windows 中,可用 Win32 事件和关键节组合来解决生产者/使用者问题。当资源可供使用者使用时,关键节会保护共享资源、避免出现并发性访问和事件信号。
在我们首次尝试解决这个问题时,我们将标准模板库 (Standard Template Library, STL) 整数列表用作共享资源。由于列表会动态扩展,我们无需使用事件以信号形式通知列表何时是未填满的,我们只需了解它何时不是空的,这样使用者就能知道其中有内容可供使用。(如果您打算使用固定大小的数组来容纳共享队列,则需要一个未满事件,以确保您不会向缓冲区写入过多内容。)我们随后声明并初始化 CRITICAL_SECTION 对象以及用于说明列表何时不为空的自动重置事件。
图 1 所示的生产者线程将首先尝试获取关键节,并且如果获取成功,将随后在共享列表的末尾插入一个整数值。该线程然后释放关键节并设置非空事件。因为我们在使用一个自动重置事件,因此只释放一个等待本事件的线程。图 1 所示的使用者线程将查看队列是否为空。如果队列不为空,该线程将转移一个项目并释放关键节。如果队列为空,使用者线程将返回休眠状态,继续等待非空事件。在第一个使用者线程忙于处理它从队列转移的项目时,生产者将唤醒另一个使用者线程拾取下一段工作以确保队列处于移动状态。

生产者
unsigned _stdcall ProducerFunc(void *pParam){    for (unsigned int i = 0;i < g_uiIterations;i++)    {        EnterCriticalSection(&g_csLock);        // Produce work        g_listWork.push_back(i++);        LeaveCriticalSection(&g_csLock);        SetEvent(g_hNotEmpty);        Sleep(g_uiProducerDelay); // Simulating work    }    return 0;}
使用者
while (true){    EnterCriticalSection(&g_csLock);    if (g_listWork.empty())    {        LeaveCriticalSection(&g_csLock);        WaitForSingleObject(g_hNotEmpty,INFINITE);    }    else    {        i = g_listWork.front();        g_listWork.pop_front();        LeaveCriticalSection(&g_csLock);        wcout << L"Thread " << iThread << L" Consumed: " << i << endl;        Sleep(g_uiConsumerDelay); // Simulating work    }}
尽管本解决方案通常是可行的,但对本实现也有一些局限。比如说,正确初始化同步对象就是个难题。例如,我们必须决定在将数据推入列表时,生产者应该只唤醒一个使用者还是唤醒所有使用者。这可通过我们初始化事件的方法来控制。如果我们使用自动重置事件,就只能释放一个线程。但如果我们想要唤醒所有线程,我们将使用手动重置事件,并且必须记住在正确的时间调用 ResetEvent。
必须确保当队列为空时,使用者线程在等待非空事件之前释放关键节。我们还必须确保不要错误使用生产者线程中的 PulseEvent 来发送非空事件信号,因为这样会导致争用状况。如果在使用者线程刚刚释放关键节、尚未调用 WaitForSingleObject 时被抢占,并且生产者随即调用 PulseEvent,就会出现这个问题。PulseEvent 并非“粘滞的”(sticky),它将只释放目前正在等待事件的那些线程。当被抢占的线程恢复时,将不会发送事件信号,并且将丢失唤醒。
使用条件变量将更容易获得正确的解决方案。本方法仍然使用关键节,但它用条件变量取代非空事件。我们通过调用指向我们的 CONDITION_VARIABLE 结构的 InitializeConditionVariable,来初始化主函数内的条件变量。
图 2 所示使用者线程进入关键节并查看队列是否为空。如果为空,则调用 SleepConditionVariableCS。此函数会释放关键节,并通过一个原子操作让此线程进入休眠状态,从而避免在两个任务间隙使用者被抢占时可能出现的争用情况。SleepConditionVariableCS 函数还接受一个超时参数,从而让我们在不想等得太久时,可以做别的工作。

生产者
for (unsigned int i = 0;i < g_uiIterations;i++){    EnterCriticalSection(&g_csLock);    // Produce work    g_listWork.push_back(i);    LeaveCriticalSection(&g_csLock);    WakeConditionVariable(&g_condNotEmpty);    Sleep(g_uiProducerDelay);}
使用者
while (true){    EnterCriticalSection(&g_csLock);    while (g_listWork.empty())    {        SleepConditionVariableCS(&g_condNotEmpty,&g_csLock,INFINITE);    }    i = g_listWork.front();    g_listWork.pop_front();    LeaveCriticalSection(&g_csLock);    wcout << L"Thread " << iThread << L" Consumed: " << i << endl;    Sleep(g_uiConsumerDelay); // Simulating work}
当得到条件变量信号时,该线程醒来并在 SleepConditionVariableCS 函数返回之前再次自动锁定关键节。使用者线程然后往回循环,并再次查看队列是否为空。当线程被释放后,再次检测状态非常重要,因为其状态可能在使用者被释放之前已被另一个线程更改;另外,条件变量还可能被骗醒 (spurious wakeup),这样它可能会在条件发生变化之前运行。我们随后从队列中转移工作项并释放关键节。
图 2 所示生产者线程通过进入关键节启动。一旦有了锁,生产者线程就会将一个新工作项推入队列,然后释放关键节。现在它可通过调用 WakeConditionVariable 释放使用者线程。此函数仅释放一个线程,这与不使用条件变量的解决方案一样。如果我们想释放所有使用者线程,我们将调用 WakeAllConditionVariable 函数。条件变量语法可以清楚显示出发送线程的过程,因为函数名称说明了将要做的事情。使用事件会使这一过程更容易出错,原因是所需行为并不是用事件信号函数而是初始化事件的时间来指定的。
如果生产者线程在它刚刚释放关键节但尚未调用 Wake 函数之前被抢占,就为另一个线程在使用者线程被释放之前修改队列状态提供了缺口。这正是我们说的释放条件时,使用者线程必须对其进行再次检查的原因。
条件变量在某些情况中也可能更高效。SleepConditionVariableCS 和 SleepConditionVariableSRW API 会尽可能候试图避免进入内核模式的行程。但不使用条件变量的示例在调用 WaitForSingleObject 时,总是会发生至少一次内核模式往返。
这样,与自己开发的实现 (homegrown implementation) 相比,本机条件变量实现可带来诸多好处,并且不会降低灵活性。生成的代码更简单、更容易阅读,并且性能也可提高。这大大降低了导致许多难以发现的细微错误的可能性。

Slim 读取器锁/写入器锁
读取器锁/写入器锁用于保护您想允许多个读取器并发访问,但在更新时只允许写入器访问的一段数据。通常,这些锁最适用于需要频繁读取和更新数据的情形。恰当使用读取器锁/写入器锁有利于增加可伸缩性。
读取器锁/写入器锁出现已有一段时间了。但在 Windows Vista 发布之前,如果您在 Windows 中编写本机用户模式代码并且需要读取器锁/写入器锁,您唯一的选择是编写自己的锁或修改教科书上的实现。Windows Vista 包含了一种称为 Slim 读取器锁/写入器锁 (SRW) 的读取器锁/写入器锁。让我们来看看这个新同步原语的功能集,测试一下可用来使用它的 API,并与用 Win32® 关键节、semaphore 和事件生成的另一种实现对比一下性能。
SRW 锁的设计初衷是快速、小型(如其名),同时仍能保持使用时的资源效率。它以 Windows 内核键控事件机制为基础生成,引入此机制是为了解决应用程序使用大量 CRITICAL_SECTION 对象时可能发生的资源匮乏问题。有些读取器锁/写入器锁旨在使读取器优先于写入器,反之亦然。但 SRW 锁旨在不偏向任何一方。也就是说,如果您的应用程序要求数据更新的优先级高于数据读取,则最好考虑选用偏向写入器的另一种读取器锁/写入器锁。但在编写自己的锁之前,您最好先试试 SRW 锁,看看它在您的应用程序环境中表现如何。
读取器锁/写入器锁有时支持的其他两项功能分别是递归获得锁,以及升级(或降级)线程被授予的锁访问权。首先,关于递归获得:如果您为应用程序设计的锁策略要求递归获得同步目标,很可能出现一个红色标志,提示您重新检查锁策略以清除递归。这是我们的看法,是由于多次执行锁获得和释放代码所带来的额外系统开销而造成的,或许还有一个更重要的原因,那就是确保锁释放和锁获得之间的平衡通常是很难证明是正确的。
SRW 锁不支持递归获得。此支持会造成额外系统开销,原因是为了维持准确性需进行逐线程的计数。SRW 锁也不支持从共享访问升级到独占访问,反之也不支持从独占访问降级(较少见)到共享访问。支持升级能力可能会造成难以接受的复杂性和额外系统开销,这种开销甚至会影响锁内共享和独占获得代码的常见情况。它还要求定义关于如何选择等待中的读取器、等待中的写入器和等待升级的读取器的策略,这又将与无偏向的基本设计目标相抵触。
使用 Slim 读取器锁/写入器锁的第一步是声明 SRWLOCK 结构并用 InitializeSRWLock 进行初始化:
VOID WINAPI InitializeSRWLock(PSRWLOCK SRWLock);
SRW 锁摆脱了每个对象均有一个初始化和清理函数的 Win32 惯例模式。当您结束使用 SRW 锁时,无需调用清理函数。
一旦被初始化,立即可用的基本 API 有:
VOID WINAPI AcquireSRWLockExclusive(PSRWLOCK SRWLock);VOID WINAPI ReleaseSRWLockExclusive(PSRWLOCK SRWLock);VOID WINAPI AcquireSRWLockShared(PSRWLOCK SRWLock);VOID WINAPI ReleaseSRWLockShared(PSRWLOCK SRWLock);
顾名思义,AcquireSRWLockExclusive 函数用于获得供调用方独占访问的锁。一旦被授予独占访问权,所有要求任一访问类型的其他线程均将被阻止,直到用 ReleaseSRWLockExclusive 互补函数释放该锁。相对的,AcquireSRWLockShared 会以共享访问权获得该锁。此时,如果锁是无主的或者已被其他具有共享访问权的线程获得,同样要求共享访问的其他线程则无需等待。
Slim 读取器锁/写入器锁可与使用 SleepConditionVariableSRW 函数的条件变量结合使用:
BOOL WINAPI SleepConditionVariableSRW(  PCONDITION_VARIABLE ConditionVariable,  PSRWLOCK SRWLock,  DWORD dwMilliseconds,  ULONG Flags);
SleepConditionVariableSRW 释放 SRW 锁,并作为原子操作等待特定条件变量。在发送条件变量信号或 dwMilliseconds 内指定的超时结束之前,此函数不会返回。如果 dwMilliseconds 为 INFINITE,该函数将永不超时。如果 Flags 参数指定 CONDITION_VARIABLE_LOCKMODE_SHARED,此函数认为 SRW 锁具有共享访问权;否则将认为是独占访问,并在成功返回后,该锁将以指定的访问权被重新获得。
在试验这些 API 的过程中,我们发现了一些编码时需格外注意的问题。请注意,没有任何锁获得或释放 API 被定义为返回结果的类型。如果 SRW 锁当前没有为一个线程所有,并发生了对 ReleaseSRWLockShared 或 ReleaseSRWLockExclusive 的调用,将会引发一个 STATUS_RESOURCE_NOT_OWNED 结构的异常。这并非坏事,因为开发人员可以明显地看出错误。
我们以前提到,不支持递归锁获得。再次尝试递归获得独占访问权的结果是,对 AcquireSRWExclusive 的第二次调用将永不返回,原因是它将自动发生死锁,使线程被阻止在内部,只有等待锁被释放。您可能需要附加一个调试器到此过程中,以查看具体情形并了解到底发生了什么问题。如果另一个线程已经尝试在两次 AcquireSRWShared 调用间隙以独占方式获得锁,那么从已获得共享访问权的线程调用 AcquireSRWShared 也将导致死锁。
在确保使用正确的获得与释放函数对时也要格外注意 — 错误地将 AcquireSRWLockExclusive 与 ReleaseSRWLockShared 配对(或反之亦然)不会引发任何异常。如果已引发针对这些出错情形的异常将很有帮助,但检测错误可能会产生不必要的资源或性能开销。
本文中的示例代码包括称为 ReaderWriterExample 的程序源代码,它允许针对不同读取器锁/写入器锁实现进行试验。该程序支持的锁类型是 SRW 锁、我们实现的读取器锁/写入器锁的两个变量和关键节。自定义读取器锁/写入器锁是用 Win32 关键节生成的,以保护锁的数据、向等待中的读取器发送信号的事件和用于写入器的信号灯。两种自定义锁的区别在于,一种偏向写入器,另一种既不偏向读取器也不偏向写入器。
所有这些新奇的测试都是在双核 Intel Xeon 3.20 GHz 处理器系统、64 位的 Windows Vista 平台上进行的。为进行这些测试,系统被配置成禁用超线程。
测试 1 包含每个线程的 2,000,000 个迭代,其中有锁时所做的工作最少。因此,结果可反映出锁获得和释放的实际消耗。总结的结果如图 3 所示。

锁类型1 个读取器1 个写入器2 个读取器/2 个写入器3 个读取器/1 个写入器4 个读取器4 个写入器SRW 锁0.1260.1190.5890.6670.8710.517自定义读/写锁 - 不倾向于任一个1.2380.25727.0954.0762.46653.567自定义读/写锁 - 倾向于写入器1.2280.26031.3066.0832.30753.567关键节0.1340.1331.0841.0211.0361.009
其中有一些有趣的发现。首先,在只使用一个读取器线程或一个写入器线程(结果的前两列)时,未出现锁竞争,并且 SRW 锁的性能非常接近关键节的性能。在使用四个写入器线程时,所有线程都竞争对锁的独占访问权,SRW 锁的用时仅约占关键节方法所用时间的一半。使用独占模式锁的 SRW 锁的性能似乎优于关键节,并且值得考虑用作替代品。请注意,在标有 2 个读取器/2 个写入器和 3 个读取器/1 个写入器的栏中,SRW 锁比使用关键节的锁速度快很多。这说明了允许数据读取器并行工作的读取器锁/写入器锁的优点。
关于我们自己开发的读取器锁/写入器锁,我们该作何评价呢?与两个内置 Windows 锁相比,它们的性能似乎很差,尤其是在读取器和写入器争用的重负载下运行时。但请看看 2 个读取器/2 个写入器和 3 个读取器/1 个写入器的结果。请注意,锁策略确实会影响总体行为和性能。偏向写入器的锁速度慢于既不偏向读取器也不偏向写入器的锁。这是因为在写入器等待更新时,并行机制被迫向写入器提供优先权。当然,这么做通常是为了确保读取器能看到最新数据,因此使用偏向写入器的锁在本例中是正确的选择。
当四个写入器竞争锁时,会出现自定义锁的另一个问题。在出现对独占访问权的许多竞争时,性能为何变得如此之差?问题在于每个线程都在竞争保护内部数据的关键节的访问权。对关键节的竞争导致进入内核模式的行程,线程在其中被置于事件休眠状态。一旦进入关键节,就会检查锁的状态,以查看任何读取器是否具有共享访问权或另一个写入器是否已经具有独占访问权。在任何一种情况下,想得到独占访问权的线程必须离开关键节,然后等待信号灯。要线程具有独占访问权并可继续运行时,将发送上述信号灯。如您可看到的,必须频繁等待多个同步目标以获得独占访问权确实降低了性能。
ReaderWriterExample 应用程序包括多个命令行开关,这样就可定制它的行为以允许通过使用不同类型的读取器锁/写入器锁来试验不同的方案。受锁保护的数据属于简单的 LONG 类型。然而,应用程序在有锁时允许指定待完成的额外数量的工作,以便模拟访问或更新更复杂的数据结构。可为读取器和写入器单独指定额外工作参数,从而允许数据结构模拟,该结构会因为更新而不是读取而带来更多负担。还有用来指定读取器/写入器在每个锁访问间隙的工作量的参数。通过固定执行计算的循环来模拟工作。
我们都知道,自己开发的锁在读取器和写入器频繁争用而处于重负载下的性能不是很好。另外,SRW 锁在第一个示例中的所有情况下则都运行良好。这是否意味着既然有了 SRW 锁,您便没必要再构建自己的读取器锁/写入器锁呢?不一定。我们来看看,将锁用于更接近应用程序实际应用方案时会发生什么。
在这些方案中,读取器的共享访问次数是 1,000,000,而写入器的独占访问次数是 150,000。这与我们前面所说的相符 — 共享访问对独占访问的比例较高时,读取器锁/写入器锁才有意义。另外,读取器拥有对 2000 个工作单元的锁以模拟读取请求,而写入器则拥有 3000 个工作单元的锁以模拟升级数据所带来的额外成本。一个读取器线程在每次访问锁的间隙执行 100 个工作单元,这样访问的时间间隔较短,而写入器在尝试独占访问之前执行 10,000 个工作单元,这样更新的时间间隔就会延长。本方法可减少对锁的整体争用。
我们使用 2 个读取器/ 2 个写入器和 3 个读取器/1 个写入器来进行本测试。总结的结果如图 4图 5所示。表中的数字是每个线程完成其工作所需的时间(以秒为单位)。对于读取器线程,第二个数字是读取器观察到的数据更新次数。

3 个读取器/1 个写入器方案Slim 读/写关键节自定义读/写锁 - 不倾向于任一个自定义读/写锁 - 倾向于写入器读取器线程 12.345(761 个更新)7.650(21,266 个更新)6.313(97,997 个更新)8.365(132,290 个更新)读取器线程 22.282(631 个更新)7.486(11,466 个更新)6.267(102,102 个更新)8.170(140,144 个更新)读取器线程 32.324(633 个更新)7.581(20,013 个更新)6.321(98,100 个更新)8.134(126,568 个更新)写入器线程 17.97011.9908.0108.446总执行时间7.97011.9908.0108.446

2 个读取器/2 个写入器方案Slim 读/写关键节自定义读/写锁 - 不倾向于任一个自定义读/写锁 - 倾向于写入器读取器线程 11.892(1,185 个更新)5.044(19,689 个更新)1.868(7,728 个更新)5.664(133,215 个更新)读取器线程 21.920(1,177 个更新)3.906(16,223 个更新)1.861(7,402 个更新)5.559(139,283 个更新)写入器线程 17.5759.9967.3727.321写入器线程 27.57410.2507.3787.317总执行时间7.57510.2507.3787.321
这些结果显示我们自己开发的锁和 SRW 锁存在更多的相似性。请注意不同的策略是如何影响结果的。偏向写入器的锁会让读取器等待而允许写入器访问,这意味着更新被赋予更高的优先级并相应地允许读取器看到更多更新。在应用程序中赋予更新优先权有时非常重要,因此了解预期负载下的锁策略及其性能特征是需要考虑的重要因素。
SRW 锁是 Windows 平台上杰出的新同步原语。Windows 首次向本机系统程序员提供内置读取器锁/写入器锁。它在许多不同的环境下均运行良好,并应成为您使用的首选读取器锁/写入器锁。您的应用程序有时可受益于不同的锁策略,但正如我们所演示的,建立适用于许多方案的读取器锁/写入器锁并不那么容易。

一次性初始化
在建立多线程系统时,时不时会出现如何确保正确初始化供多个线程共享的对象或资源的问题。C 和 C++ 并未提供解决这一问题的帮助,原因是语言标准未提及多线程支持。假设这样一个示例,该示例包含一个用于记录消息日志的 Logger 对象实例,其中一个要求是按需进行对象实例化,而不是在开始执行程序时创建对象。当有多个线程在 GetLogger 函数内部同时执行以访问我们系统中的 Logger 对象时,图 6 所示的单线程初始化代码将无法正确执行。

单线程初始化
Logger* GetLogger(){    // C++ will execute the Logger’s constructor    // only the first time this method is called.    static Logger logger;    return &logger;}
线程安全初始化
Logger* GetLogger(){    static Logger* pLogger = 0;    EnterCriticalSection(&cs);    if (pLogger == 0)    {        try        {            pLogger = new Logger();        }        catch (...)        {            // Something went wrong.        }    }    LeaveCriticalSection(&cs);    return pLogger;}
静态 Logger 对象的初始化实际上不止发生一次,原因是编译器并未在其结构上加入任何同步。这可能会产生损坏的 Logger 对象,当使用该对象时,即使处于最佳情形,也会产生异常 — 但不能保证错误很明显,并且系统可能运行了很长时间后才会有人发现此问题。解决此问题的一种办法是重新编写函数并引入同步功能,这样它就可利用图 6 所示的线程安全初始化代码,在存在多个线程的情况下正确工作。
现在,进入 GetLogger 函数的每个线程都将试图进入关键节,即意味着每次只允许一个线程进入受保护的代码块。在关键节执行的线程会检查 pLogger 的值,只有值为 NULL 时才会创建一个 Logger 对象的实例。此操作仅在第一个线程进入关键节后会发生一次。随后进入的其他所有线程将发现 pLogger 不是 NULL,然后会退出关键节而不再执行任何工作。到达返回语句时,pLogger 值将是非空值,并可被返回给调用方。
线程安全初始化代码表面上看似合理的解决方案。但在此必须打个折扣,尤其是当许多线程结束时同时调用 GetLogger 函数的情况下。一旦第一个线程已经完成分配、构建和设置 pLogger 指针,实际上根本不再需要后面的线程进入关键节对象。该指针始终保持有效。这种认识带来了一个称为仔细查过锁定模式的 C++ 编程设计模式。
图 7 显示了使用仔细查过锁定模式的 GetLogger 实现。此模式规定 pLogger 变量要被检查两次。第一次是检查变量,在关键节之外进行。如果发现 pLogger 为 NULL,线程随后将进入关键节。一旦进入,它将在实例化和设置 pLogger 变量之前再次检查 pLogger 是否为空,原因是等待关键节时由于受另一线程的竞争,该线程可能被阻止。
Logger* GetLogger(){    volatile static Logger* pLogger = 0;    if (pLogger == NULL)    {        EnterCriticalSection(&cs);        if (pLogger == NULL)        {            try            {                pLogger = new Logger();            }            catch (...)            {                // Something went wrong.            }        }        LeaveCriticalSection(&cs);    }    return pLogger;}
看起来仔细查过锁定版本好像能提供世界上最好的解决方案。只有少数几个在对象被实例化之前进入 GetLogger 的线程将被强行同步;以后到达的线程根本无需进入关键节。我们还想再要什么?
仔细查过锁定模式尽管在概念上很简单,但事实证明,到目前为止,仍然难以正确编码。这是因为 C++ 标准没有定义线程模型。它假定只有一个执行线程,并且没有定义可供开发人员表示相关指令排序的约束的方式,这使得编译器可以自由重新排序内存的读取和写入。在多线程环境中,重新排序可能会导致线程在实际执行源代码中位置比它更靠前的所有语句之前就观察内存的写入。对于仔细查过锁定代码,可以在执行 Logger 构造函数之前,用分配给 Logger 对象的内存地址更新 pLogger 变量。观察到非空值的第二个线程将不再进入关键节,并返回未完全构造完成的对象的地址。(您可参见 Vance Morrison 撰写的关于托管代码的类似问题的文章“解读多线程应用程序中 Low-Lock 技术的影响”。)在旧版 Visual Studio® C++ 编译器中,即使对变量 pLogger 使用 volatile 限制符也不能完全保证多线程方案中的正确行为。但在 Visual Studio 2005 中,只要用关键字 volatile 限定变量 pLogger,就能使仔细查过锁定模式在 Windows 平台上可靠执行。
尽管 Visual Studio 2005 能正确实现仔细查过锁定模式,但还是很容易因为忽略包含实例指针的变量上的 volatile 限制符,或忽略关键节内的检查而使实现出错;而程序仍在编译并且似乎仍然有效。建立保证有效的机制将是对 C++ 有重要的补充。Windows Vista 没有坐等对标准主体的修订,而是提供了一个专门针对这一问题,称为一次性初始化的工具。不管您在哪一种硬件平台上使用 Windows,都能保证有效。一次性初始化既允许同步初始化,也允许异步初始化。让我们先来看看同步初始化。
在概念上,同步初始化的工作模式与仔细查过锁定模式一样。在第一批 n 个试图同步执行初始化的线程中,实际上只有一个线程将实例化资源 — 其余线程将一直被阻止,直到初始化完成。一旦完成初始化,将不再阻止后面试图访问资源的线程;只会返回存储的资源。
需要完成的第一件事情是声明并初始化 INIT_ONCE 结构的实例。您可使用 InitOnceInitialize 函数来执行初始化。对同步一次性初始化和异步一次性初始化而言,这是必须完成的,并且必须在任何其他一次性初始化函数使用结构之前完成。其定义如下:
VOID WINAPI InitOnceInitialize(  PINIT_ONCE InitOnce);
同时,以下函数将被用来执行同步一次性初始化:
BOOL WINAPI InitOnceExecuteOnce(  PINIT_ONCE InitOnce,  PINIT_ONCE_FN InitFn,  PVOID Parameter,  LPVOID* Context);
第一个参数 InitOnce 是指向 INIT_ONCE 结构实例的指针。初始化时,所有传送同一个 INIT_STRUCTURE 地址的线程都将被相互同步化。第二个参数 InitFn 是指向开发人员所编写函数的指针;此函数执行实际初始化。第三个参数(暂且命名为 Parameter)属于可选值,被调用时将被传送回 InitFn。您可在此指定执行初始化所需的任何信息。最后一个参数 Context 是指向 void 指针的指针。这是函数运行成功时存储已初始化对象的地址。请注意,我是在使用“对象”一词来描述一次性初始化的结果。它不必非得是指向 C++ 对象的指针。这可以是符合执行所在平台上的指针大小值的任何东西。InitOnceExecuteOnce 如果成功则返回 TRUE,反之返回 FALSE。
现在我们来看看 InitFn 所指向的函数。本函数的定义具有以下签名:
BOOL CALLBACK InitOnceCallback(  PINIT_ONCE InitOnce,  PVOID Parameter,  PVOID* Context);
该参数和 InitOnceExecuteOnce 函数里的一样。InitOnceCallback 函数原本是用于存储 Context 内已初始化对象的地址。图 8 包含了为使用同步一次性初始化而重新编写的 GetLogger 函数。请注意,回调函数通过返回 FALSE 来表示失败。通过从回调函数返回 FALSE,系统允许另一个试图初始化的进程执行回调函数以期获得成功。此过程将一直持续,直到没有更多的初始化尝试或回调函数返回 TRUE。
BOOL WINAPI InitLoggerFunction(PINIT_ONCE intOncePtr,                               PVOID Parameter,                               PVOID* contextPtr){    try    {        Logger* pLogger = new Logger();        *contextPtr = pLogger;        return TRUE;    }    catch (...)    {        // Something went wrong.        return FALSE;    }}Logger* GetLogger(){    static INIT_ONCE initOnce;    PVOID contextPtr;    BOOL status;    status = InitOnceExecuteOnce(&initOnce,                                 InitLoggerFunction,                                 NULL,                                 &contextPtr);    if (status)    {        return (Logger*)contextPtr;    }    return NULL;}
异步一次性初始化比同步操作更复杂些,但它允许一次性初始化时不会造成任何执行初始化的线程在等待初始化完成时被阻止。因此,初始化完成之前,允许所有尝试初始化的线程并行执行并争用初始化函数,其中,每个线程都初始化自身的对象专有副本,并随后尝试将对象注册为唯一一个已初始化的对象。其中只有一个线程将成为所谓的对象注册获胜者,其他线程(失败者)必须销毁各自的专有对象实例,然后向系统查询获胜对象。
这两个用于异步一次性初始化的 API 是:
BOOL WINAPI InitOnceBeginInitialize(  LPINIT_ONCE InitOnce,  DWORD dwFlags,  PBOOL fPending,  LPVOID* Context);BOOL WINAPI InitOnceComplete(  LPINIT_ONCE lpInitOnce,  DWORD dwFlags,  LPVOID lpContext);
图 9 显示了上面使用的同一个 GetLogger 函数,但现在它被重新编写,以使用异步一次性初始化。我们将逐步解释本例程。线程做的第一件事是调用 InitOnceBeginInitialize。该线程必须提供指向待使用的 INIT_ONCE 结构的指针,并且 dwFlags 参数应设置为 INIT_ONCE_ASYNC,以指定此线程正尝试开始异步一次性初始化。通过使用其余两个函数,fPending 和 Context,将初始化状态传回给调用方,上述两个参数均被指定为指针以便函数可以更新它们。如果成功,InitOnceBeginInitialize 返回 TRUE,否则返回 FALSE,表示有些地方已经出错,初始化无法继续。必须检查 fPending 以确认其他线程是否已完成初始化。如果 fPending 是 FALSE,则初始化已经完成,并且已将初始化对象存入 Context 参数。在此例中,GetLogger 剩下的唯一工作就是将 Context 转换为 Logger* 并将它返回给调用方。
Logger* GetLogger(){    static INIT_ONCE initOnce;    PVOID contextPtr;    BOOL    fStatus;    BOOL    fPending;    fStatus = InitOnceBeginInitialize(&initOnce,INIT_ONCE_ASYNC,         &fPending, &contextPtr);    // Failed?    if (!fStatus)    {        return NULL;    }    // Initialization already completed?    if (!fPending)    {        // Pointer to the logger is contained context pointer.        return (Logger*)contextPtr;    }    // Reaching this point means that the logger needs to be created.    try    {        Logger* pLogger = new Logger();        fStatus = InitOnceComplete(            &initOnce,INIT_ONCE_ASYNC,(PVOID)pLogger);        if (fStatus)        {            // The Logger that was created was successfully             // set as the logger instance to be used.            return pLogger;        }        // Reaching this point means fStatus is FALSE and the object this        // thread created is not the sole instance.         // Clean up the object this thread created.        delete pLogger;    }    catch (...)     {        // Instantiating the logger failed.        // Fall through and see if any of         // the other threads were successful.    }    // Call again but this time ask only for     // the result of one-time initialization.    fStatus = InitOnceBeginInitialize(&initOnce,INIT_ONCE_CHECK_ONLY,         &fPending,contextPtr);    // Function succeeded and initialization is complete.    if (fStatus)    {        // The pointer to the logger is in the contextPtr.        return (Logger*) contextPtr;    }    // InitOnceBeginInitialize failed, return NULL.    return NULL;}
当 fPending 携带 TRUE 值返回时,事情变得更有趣。这表明初始化应当继续,并且同时可能还有其他线程作为调用方的线程在运行初始化代码。在图 9 中,这导致创建新的 Logger 对象,并随后尝试将该实例设置为唯一一个起源于异步一次性初始化的实例结果。可从新语句之后发生的 InitOnceComplete 调用中看到这一点。第一个参数指定了指向以前使用的同一个 INIT_ONCE 结构的指针,INIT_ONCE_ASYNC 标志被作为第二个参数传递,而指向记录器实例的指针则通过第三个参数提供。如果 InitOnceComplete 返回 TRUE,则本实例将被返回给 GetLogger 的所有调用方。
假如 InitOnceComplete 返回 FALSE,则另一个线程创建的记录器被存储,调用方应销毁它创建的实例,这样它使用的所有资源才不会被搁置。此后,InitOnceBeginInitialize 再次被调用,但这次不用 INIT_ONCE_ASYNC 标志,而是使用 INIT_ONCE_CHECK_ONLY 标志。INIT_ONCE_CHECK_ONLY 标志用于查看初始化是否已完成。如果是,被存储的初始化值则被复制到提供的 Context 指针参数中。如果返回的是有效的初始化值,该函数将返回 TRUE,并且 fPending 参数将被设置为 FALSE。
因此,您如何在同步和异步一次性初始化之间作出选择?在单一处理器系统中,使用同步很有意义,因为一次只能执行一个线程。在多处理器或多核系统中,如果创建了多对象实例,您将需要尝试量化因此可能产生的成本。成本可包括时间、内存(或其他稀缺资源类型),以及通常在对象被成功初始化之前并发执行初始化代码的线程的数量。如果构造对象需要花费不短的时间,或对逐个对象或可能同时创建的所有对象合计将使用大量内存,则同步初始化可能是更好的选择。另外,如果对象创建过程可能导致执行初始化的线程受阻,则同步初始化是更好的选择,因为并发的机会随即会消失,使用异步初始化好处并不大。
本文章的源代码包含一个称为 OneTimeInit.exe 的示例,用于演示共享事件句柄的同步一次性初始化和异步一次性初始化。本程序使用许多用于运行的线程和一个指明是否执行同步或异步初始化的标志。指定的线程数量是生成的,并且每个线程均试图检索用来信号通知何时终止线程的 Win32 事件对象句柄。每个线程的进度会被发送至 stdout,让您可以准确了解初始化的进度。每个线程会完成一些设计好的工作,直到主线程发出终止事件信号。要获得程序使用信息,只需不带任何参数运行程序即可。
关于一次性初始化,还有几个值得一提的遗留问题。我们发现,使用同步初始化,回调函数将被继续调用,直到成功。假定您的应用程序方案规定只尝试一次初始化,如果失败,则不再尝试。要实现这一点的一个方法是让回调函数仍然返回 TRUE,但在 lpContext 设置一个其他线程可检查的、表明出错的特殊值。另一种机制是使用 InitiOnceBeginInitialize/InitOnceComplete API 代替 InitOnceExecuteOnce API 来进行同步初始化。要做到这一点,请在 dwFlags 中省略 INIT_ONCE_ASYNC 标志,并将初始化代码置于 InitOnceBeginInitialize 调用和 InitOnceComplete API 之间,而不是置于单独的回调函数内。如果出错,则会使用 INIT_ONCE_INIT_FAILED 标志,并用 InitOnceComplete API 表明不会出现更多的初始化尝试。可通过使用其他线程能检查的 lpContext 来设置其他可选出错值。
第二个遗留问题是一次性初始化并不能帮助清理已创建的对象。有许多解决这一问题的方法,但没有一种方法是万能的。如果对象属于操作系统在进程终止后清理的对象,请考虑将它泄漏。但要注意,如果您使用监视程序是否泄漏资源的工具,这可能会成为一个问题。如果对象需调用其清理函数以确保正确行为,您别无选择,只有在所有使用该对象的线程结束后协调此函数的调用。但您也可使用类似示例所用的技术方法。该示例使用已初始化的 HANDLE 注册的静态对象,这样在所有线程结束后,它将在程序执行结束时被自动销毁。

结束语
在本文章中,我们向您介绍了 Windows Vista 中许多适于本机 C/C++ 开发人员的线程同步原语的重要增强功能。这些增强功能可让开发人员更容易地解决线程同步问题。但本文只涉及了一些皮毛。我们鼓励您更深入地研究它们以及其他更改。对于初学者,请先试着了解线程池 API 增强功能。很遗憾,我们没有机会在此讨论线程池 API 增强功能了,但它们确实是值得思考的宝贵的新增功能。
在此,我们要特别感谢 Neill Clift 和 Arun Kishan 解答我们的问题并提供了很有见地的反馈。此外,还要感谢 Jerry 审阅文章内容并为我们提供宝贵的意见。

Robert Saccone是 Forefront Server Security 小组的一名架构师。他感兴趣的领域是大型软件设计、分布式系统和操作系统实现。

Alexander Taskov是微软 Forefront Server Security 小组的高级软件开发工程师。他负责设计和开发 Forefront Server Security 产品的安全功能。
0 0
原创粉丝点击