构建一个更好的捕鼠器
在我们学习临界区的工作方式时,非常偶然地得到一些重要发现,利用这些发现可以得到一个非常好的实用工具。第一个发现是 ProcessLocksList LIST_ENTRY 字段的出现,这使我们想到进程的临界区可能是可枚举的。另一个重大发现是我们知道了如何找出临界区列表的头。还有一个重要发现是可以在没有任何损失的情况下写 RTL_CRITICAL_SECTION 的 Spare 字段(至少在我们的所有测试中如此)。我们还发现可以很容易地重写系统的一些临界区例程,而不需要对源文件进行任何修改。
最初,我们由一个简单的程序开始,其检查一个进程中的所有临界区,并列出其当前状态,以查看是否拥有这些临界区。如果拥有,则找出由哪个线程拥有,以及该临界区阻止了多少个线程?这种做法对于 OS 的狂热者们比较适合,但对于只是希望有助于理解其程序的典型的程序员就不是非常有用了。
即使是在最简单的控制台模式“Hello World”程序中也存在许多临界区。其中大部分是由 USER32 或 GDI32 之类的系统 DLL 创建,而这些 DLL 很少会导致死锁或性能问题。我们希望有一种方法能滤除这些临界区,而只留下代码中所关心的那些临界区。RTL_CRITICAL_SECTION_DEBUG 结构中的 Spare 字段可以很好地完成这一工作。可以使用其中的一个或两个来指示:这些临界区是来自用户编写的代码,而不是来自 OS。
于是,下一个逻辑问题就变为如何确定哪些临界区是来自您编写的代码。有些读者可能还记得 Matt Pietrek 2001 年 1 月的 Under The Hood 专栏中的 LIBCTINY.LIB,传世私服。LIBCTINY 所采用的一个技巧是一个 LIB 文件,它重写了关键 Visual C++ 运行时例程的标准实现。将 LIBCTINY.LIB 文件置于链接器行的其他 LIB 之前,链接器将使用这一实现,而不是使用 Microsoft 所提供的导入库中的同名后续版本。
为对临界区应用类似技巧,我们创建 InitializeCriticalSection 的一个替代版本及其相关导入库。将此 LIB 文件置于 KERNEL32.LIB 之前,网通1.76复古传奇,链接器将链接我们的版本,而不是 KERNEL32 中的版本。对 InitializeCriticalSection 的实现显示在图 2 中。此代码在概念上非常简单。它首先调用 KERNEL32.DLL 中的实际 InitializeCriticalSection。接下来,它获得调用 InitializeCriticalSection 的代码地址,并将其贴至 RTL_CRITICAL_SECTION_DEBUG 结构的备用字段之一。我们的代码如何确定调用代码的地址呢?x86 CALL 指令将返回地址置于堆栈中。CriticalSectionHelper 代码知道该返回地址位于堆栈帧中一个已知的固定位置。
实际结果是:与 CriticalSectionHelper.lib 正确链接的任何 EXE 或 DLL 都将导入我们的 DLL (CriticalSectionHelper.DLL),并占用应用了备用字段的临界区。这样就使事情简单了许多。现在我们的实用工具可以简单地遍历进程中的所有临界区,并且只显示具有正确填充的备用字段的临界区信息。那么需要为这一实用工具付出什么代价呢?请稍等,还有更多的内容!
因为您的所有临界区现在都包含对其进行初始化时的地址,实用工具可以通过提供其初始化地址来识别各个临界区。原始代码地址本身没有那么有用。幸运的是,DBGHELP.DLL 使代码地址向源文件、行号和函数名称的转换变得非常容易。即使一个临界区中没有您在其中的签名,也可以将其地址提交给 DBGHELP.DLL。如果将其声明为一个全局变量,并且如果符号可用,则您就可以在原始源代码中确定临界区的名称。顺便说明一下,如果通过设置 _NT_SYMBOL_PATH 环境变量,并设置 DbgHelp 以使用其 Symbol Server 下载功能,从而使 DbgHelp 发挥其效用,则会得到非常好的结果。
MyCriticalSections 实用工具
我们将所有这些思想结合起来,提出了 MyCriticalSections 程序。MyCriticalSections 是一个命令行程序,在不使用参数运行该程序时可以看到一些选项:
Syntax: MyCriticalSections <PID> [options]
Options:
/a = all critical sections
/e = show only entered critical sections
/v = verbose
唯一需要的参数是 Program ID 或 PID(十进制形式)。可以用多种方法获得 PID,但最简单的方法可能就是通过 Task Manager。在没有其他选项时,MyCriticalSections 列出了来自代码模块的所有临界区状态,您已经将 CriticalSectionHelper.DLL 链接至这些代码模块。如果有可用于这一(些)模块的符号,代码将尝试提供该临界区的名称,以及对其进行初始化的位置。
要查看 MyCriticalSections 是如何起作用的,请运行 Demo.EXE 程序,该程序包含在下载文件中。Demo.EXE 只是初始化两个临界区,并由一对线程进入这两个临界区。图 3 显示运行“MyCriticalSections 2040”的结果(其中 2040 为 Demo.EXE 的 PID)。
在该图中,列出了两个临界区。在本例中,它们被命名为 csMain 和 yetAnotherCriticalSection。每个“Address:”行显示了 CRITICAL_SECTION 的地址及其名称。“Initialized in”行包含了在其中初始化 CRITICAL_SECTION 的函数名。代码的“Initialized at”行显示了源文件和初始化函数中的行号。
对于 csMain 临界区,您将看到锁定数为 0、递归数为 1,表示一个已经被一线程获得的临界区,并且没有其他线程在等待该临界区。因为从来没有线程被阻止于该临界区,所以 Entry Count 字段为 0。
现在来看 yetAnotherCriticalSection,会发现其递归数为 3。快速浏览 Demo 代码可以看出:主线程调用 EnterCriticalSection 三次,所以事情的发生与预期一致。但是,还有一个第二线程试图获得该临界区,并且已经被阻止。同样,LockCount 字段也为 3。此输出显示有一个等待线程。
MyCriticalSections 拥有一些选项,使其对于更为勇敢的探索者非常有用。/v 开关显示每个临界区的更多信息。旋转数与锁定信号字段尤为重要。您经常会看到 NTDLL 和其他 DLL 拥有一些旋转数非零的临界区。如果一个线程在获得临界区的过程中曾被锁定,则锁定信号字段为非零值。/v 开关还显示了 RTL_CRITICAL_SECTION_DEBUG 结构中备用字段的内容。
/a 开关显示进程中的所有临界区,即使其中没有 CriticalSectionHelper.DLL 签名也会显示。如果使用 /a,则请做好有大量输出的准备。真正的黑客希望同时使用 /a 和 /v,以显示进程中全部内容的最多细节。使用 /a 的一个小小的好处是会看到 NTDLL 中的LdrpLoaderLock 临界区。此临界区在 DllMain 调用和其他一些重要时间内被占用。LdrpLoaderLock 是许多不太明显、表面上难以解释的死锁的形成原因之一。(为使 MyCriticalSection 能够正确标记 LdrpLoaderLock 实例,需要用于 NTDLL 的 PDB 文件可供使用。)
/e 开关使程序仅显示当前被占用的临界区。未使用 /a 开关时,只显示代码中被占用的临界区(如备用字段中的签名所指示)。采用 /a 开关时,将显示进程中的全部被占用临界区,而不考虑其来源。
那么,希望什么时候运行 MyCriticalSections 呢?一个很明确的时间是在程序被死锁时。检查被占用的临界区,以查看是否有什么使您惊讶的事情。即使被死锁的程序正运行于调试器的控制之下,也可以使用 MyCriticalSections。
另一种使用 MyCriticalSections 的时机是在对有大量多线程的程序进行性能调整时。在阻塞于调试器中的一个使用频繁、非重入函数时,最新传奇发布网,运行 MyCriticalSections,查看在该时刻占用了哪些临界区。如果有很多线程都执行相同任务,就非常容易导致一种情形:一个线程的大部分时间被消耗在等待获得一个使用频繁的临界区上。如果有多个使用频繁的临界区,这造成的后果就像花园的浇水软管打了结一样。解决一个争用问题只是将问题转移到下一个容易造成阻塞的临界区。
一个查看哪些临界区最容易导致争用的好方法是在接近程序结尾处设置一个断点。在遇到断点时,运行 MyCriticalSections 并查找具有最大 Entry Count 值的临界区。正是这些临界区导致了大多数阻塞和线程转换。
尽管 MyCriticalSections 运行于 Windows 2000 及更新版本,但您仍需要一个比较新的 DbgHelp.DLL 版本 - 5.1 版或更新版本。Windows XP 中提供这一版本。也可以由其他使用 DbgHelp 的工具中获得该版本。例如,Debugging Tools For Windows 下载中通常拥有最新的 DbgHelp.DLL。
深入研究重要的临界区例程
此最后一节是为那些希望理解临界区实现内幕的勇敢读者提供的。对 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。对我们代码进行扩展的可能性非常大,我们非常乐意看到其他人员所发现和创造的创新性办法。