深入研究重要的临界区例程

来源:互联网 发布:javascript隐藏按钮 编辑:程序博客网 时间:2024/05/16 12:36
此最后一节是为那些希望理解临界区实现内幕的勇敢读者提供的。对 NTDLL 进行仔细研究后可以为这些例程及其支持子例程创建伪码(见下载中的 NTDLL(CriticalSections).cpp)。以下 KERNEL32 API 组成临界区的公共接口:
InitializeCriticalSection
InitializeCriticalSectionAndSpinCount
DeleteCriticalSection
TryEnterCriticalSection
EnterCriticalSection
LeaveCriticalSection
前两个 API 只是分别围绕 NTDLL API RtlInitializeCriticalSection 和 RtlInitializeCriticalSectionAndSpinCount 的瘦包装。所有剩余例程都被提交给 NTDLL 中的函数。另外,对 RtlInitializeCriticalSection 的调用是另一个围绕 RtlInitializeCriticalSectionAndSpinCount 调用的瘦包装,其旋转数的值为 0。使用临界区的时候实际上是在幕后使用以下 NTDLL API:
RtlInitializeCriticalSectionAndSpinCount
RtlEnterCriticalSection
RtlTryEnterCriticalSection
RtlLeaveCriticalSection
RtlDeleteCriticalSection
在这一讨论中,我们采用 Kernel32 名称,因为大多数 Win32 程序员对它们更为熟悉。
InitializeCriticalSectionAndSpinCount 对临界区的初始化非常简单。RTL_CRITICAL_SECTION 结构中的字段被赋予其起始值。与此类似,分配 RTL_CRITICAL_SECTION_DEBUG 结构并对其进行初始化,将 RtlLogStackBackTraces 调用中的返回值赋予 CreatorBackTraceIndex,并建立到前面临界区的链接。
顺便说一声,CreatorBackTraceIndex 一般接收到的值为 0。但是,如果有 Gflags 和 Umdh 实用工具,可以输入以下命令:
Gflags /i MyProgram.exe +ust
Gflags /i MyProgram.exe /tracedb 24
这些命令使得 MyProgram 的“Image File Execution Options”下添加了注册表项。在下一次执行 MyProgram 时会看到此字段接收到一个非 0 数值。有关更多信息,参阅知识库文章 Q268343“Umdhtools.exe:How to Use Umdh.exe to Find Memory Leaks”。临界区初始化中另一个需要注意的问题是:前 64 个 RTL_CRITICAL_SECTION_DEBUG 结构不是由进程堆中分配,而是来自位于 NTDLL 内的 .data 节的一个数组。
在完成临界区的使用之后,对 DeleteCriticalSection(其命名不当,因为它只删除 RTL_CRITICAL_SECTION_ DEBUG)的调用遍历一个同样可理解的路径。如果由于线程在尝试获得临界区时被阻止而创建了一个事件,将通过调用 ZwClose 来销毁该事件。接下来,在通过 RtlCriticalSectionLock 获得保护之后(NTDLL 以一个临界区保护它自己的内部临界区列表 — 您猜对了),将调试信息从链中清除,对该临界区链表进行更新,以反映对该信息的清除操作。该内存由空值填充,并且如果其存储区是由进程堆中获得,则调用 RtlFreeHeap 将使得其内存被释放。最后,以零填充 RTL_CRITICAL_SECTION。
有两个 API 要获得受临界区保护的资源 — TryEnterCriticalSection 和 EnterCriticalSection。如果一个线程需要进入一个临界区,但在等待被阻止资源变为可用的同时,可执行有用的工作,那么 TryEnterCriticalSection 正是您需要的 API。此例程测试此临界区是否可用;如果该临界区被占用,该代码将返回值 FALSE,为该线程提供继续执行另一任务的机会。否则,其作用只是相当于 EnterCriticalSection。
如果该线程在继续进行之前确实需要拥有该资源,则使用 EnterCriticalSection。此时,取消用于多处理器计算机的 SpinCount 测试。这一例程与 TryEnterCriticalSection 类似,无论该临界区是空闲的或已经被该线程所拥有,都调整对该临界区的簿记。注意,最重要的 LockCount 递增是由 x86“lock”前缀完成的,这一点非常重要。这确保了在某一时间内只有一个 CPU 可以修改该 LockCount 字段。(事实上,Win32 InterlockedIncrement API 只是一个具有相同锁定前缀的 ADD 指令。)
如果调用线程无法立即获得该临界区,则调用 RtlpWaitForCriticalSection 将该线程置于等待状态。在多处理器系统中,EnterCriticalSection 旋转 SpinCount 所指定的次数,并在每次循环访问中测试该临界区的可用性。如果此临界区在循环期间变为空闲,该线程获得该临界区,并继续执行。
RtlpWaitForCriticalSection 可能是这里所给的所有过程中最为复杂、最为重要的一个。这并不值得大惊小怪,因为如果存在一个死锁并涉及临界区,则利用调试器进入该进程就可能显示出 RtlpWaitForCriticalSection 内 ZwWaitForSingleObject 调用中的至少一个线程。
如伪码中所显示,在 RtlpWaitForCriticalSection 中有一点簿记工作,如递增 EntryCount 和 ContentionCount 字段。但更重要的是:发出对 LockSemaphore 的等待,以及对等待结果的处理。默认情况是将一个空指针作为第三个参数传递给 ZwWaitForSingleObject 调用,请求该等待永远不要超时。如果允许超时,将生成调试消息字符串,并再次开始等待。如果不能从等待中成功返回,就会产生中止该进程的错误。最后,在从 ZwWaitForSingleObject 调用中成功返回时,则执行从 RtlpWaitForCriticalSection 返回,该线程现在拥有该临界区。
RtlpWaitForCriticalSection 必须认识到的一个临界条件是该进程正在被关闭,并且正在等待加载程序锁定 (LdrpLoaderLock) 临界区。RtlpWaitForCriticalSection 一定不能 允许该线程被阻止,但是必须跳过该等待,并允许继续进行关闭操作。
LeaveCriticalSection 不像 EnterCriticalSection 那样复杂。如果在递减 RecursionCount 之后,结果不为 0(意味着该线程仍然拥有该临界区),则该例程将以 ERROR_SUCCESS 状态返回。这就是为什么需要用适当数目的 Leave 调用来平衡 Enter 调用。如果该计数为 0,则 OwningThread 字段被清零,LockCount 被递减。如果还有其他线程在等待,例如 LockCount 大于或等于 0,则调用 RtlpUnWaitCriticalSection。此帮助器例程创建 LockSemaphore(如果其尚未存在),并发出该信号提醒操作系统:该线程已经释放该临界区。作为通知的一部分,等待线程之一退出等待状态,为运行做好准备。
最后要说明的一点是,MyCriticalSections 程序如何确定临界区链的起始呢?如果有权访问 NTDLL 的正确调试符号,则对该列表的查找和遍历非常简单。首先,定位符号 RtlCriticalSectionList,清空其内容(它指向第一个 RTL_CRITICAL_SECTION_DEBUG 结构),并开始遍历。但是,并不是所有的系统都有调试符号,RtlCriticalSectionList 变量的地址会随 Windows 的各个版本而发生变化。为了提供一种对所有版本都能正常工作的解决方案,我们设计了以下试探性方案。观察启动一个进程时所采取的步骤,会看到是以以下顺序对 NTDLL 中的临界区进行初始化的(这些名称取自 NTDLL 的调试符号):
RtlCriticalSectionLock
DeferedCriticalSection (this is the actual spelling!)
LoaderLock
FastPebLock
RtlpCalloutEntryLock
PMCritSect
UMLogCritSect
RtlpProcessHeapsListLock
因为检查进程环境块 (PEB) 中偏移量 0xA0 处的地址就可以找到加载程序锁,所以对该链起始位置的定位就变得比较简单。我们读取有关加载程序锁的调试信息,然后沿着链向后遍历两个链接,使我们定位于 RtlCriticalSectionLock 项,在该点得到该链的第一个临界区。有关其方法的说明,请参见图 4
     
图 4 初始化顺序
小结
几乎所有的多线程程序均使用临界区。您迟早都会遇到一个使代码死锁的临界区,并且会难以确定是如何进入当前状态的。如果能够更深入地了解临界区的工作原理,则这一情形的出现就不会像首次出现时那样的令人沮丧。您可以研究一个看来非常含糊的临界区,并确定是谁拥有它,以及其他有用细节。如果您愿意将我们的库加入您的链接器行,则可以容易地获得有关您程序临界区使用的大量信息。通过利用临界区结构中的一些未用字段,我们的代码可以仅隔离并命名您的模块所用的临界区,并告知其准确状态。
有魄力的读者可以很容易地对我们的代码进行扩展,以完成更为异乎寻常的工作。例如,采用与 InitializeCriticalSection 挂钩相类似的方式截获 EnterCriticalSection 和 LeaveCriticalSection,可以存储最后一次成功获得和释放该临界区的位置。与此类似,CritSect DLL 拥有一个易于调用的 API,用于枚举您自己的代码中的临界区。利用 .NET Framework 中的 Windows 窗体,可以相对容易地创建一个 GUI 版本的 MyCriticalSections。对我们代码进行扩展的可能性非常大,我们非常乐意看到其他人员所发现和创造的创新性办法。