多线程同步-临界区(深入理解CRITICAL_SECTION)

更新时间:2023-05-09 16:53:54 阅读: 评论:0

多线程同步-临界区(深⼊理解CRITICAL_SECTION)
临界区是⼀种防⽌多个线程同时执⾏⼀个特定代码节的机制,这⼀主题并没有引起太多关注,因⽽⼈们未能对其深刻理解。在需要跟踪代码中的多线程处理的性能时,对
Windows
中临界区的深刻理解⾮常有⽤。本⽂深⼊研究临界区的原理,以揭⽰在查找死锁和确认性能问题过程中的有⽤信息。它还包含⼀个便利的实⽤⼯具程序,可以显⽰所有临界区及其当前状态。
在我们许多年的编程实践中,对于 Win32 临界区没有受到⾮常多的“under the
hood”关注⽽感到⾮常奇怪。当然,您可能了解有关临界区初始化与使⽤的基础知识,但您是否曾经花费时间来深⼊研究 WINNT.H 中所定义的
CRITICAL_SECTION
结构呢?在这⼀结构中有⼀些⾮常有意义的好东西被长期忽略。我们将对此进⾏补充,并向您介绍⼀些很有意义的技巧,这些技巧对于跟踪那些难以察觉的多线程处理错误⾮常有⽤。更重要的是,使⽤我们的
MyCriticalSections 实⽤⼯具,可以明⽩如何对 CRITICAL_SECTION
进⾏微⼩地扩展,以提供⾮常有⽤的特性,这些特性可⽤于调试和性能调整(要下载完整代码,参见本⽂顶部的链接)。
⽼实说,作者们经常忽略 CRITICAL_SECTION 结构的部分原因在于它在以下两个主要 Win32 代码库中的实现有很⼤不同:Microsoft Windows 95 和 Windows NTH嗣侵勒饬街执肟⾇家丫⒄钩龃罅亢笮姹荆ㄆ渥钚掳姹痉直鹞 Windows Me 和 Windows
XP),但没有必要在此处将其⼀⼀列出。关键在于 Windows XP 现在已经发展得⾮常完善,开发商可能很快就会停⽌对 Windows 95
系列操作系统的⽀持。我们在本⽂中就是这么做的。
诚然,当今最受关注的是 Microsoft Framework,但是良好的旧式 Win32 编程不会很快消失。如果您拥有采⽤了临界区的现有
Win32 代码,您会发现我们的⼯具以及对临界区的说明都⾮常有⽤。但是请注意,我们只讨论 Windows NT 及其后续版本,⽽没有涉及与
相关的任何内容,这⼀点⾮常重要。
临界区:简述
如果您⾮常熟悉临界区,并可以不假思索地进⾏应⽤,那就可以略过本节。否则,请向下阅读,以对这些内容进⾏快速回顾。如果您不熟悉这些基础内容,则本节之后的内容就没有太⼤意义。
临界区是⼀种轻量级机制,在某⼀时间内只允许⼀个线程执⾏某个给定代码段。通常在修改全局数据(如集合类)时会使⽤临界区。事件、多⽤户终端执⾏程序和信号量也⽤于多线程同步,但临界区与它们不同,它并不总是执⾏向内核模式的控制转换,这⼀转换成本昂贵。稍后将会看到,要获得⼀个未占⽤临界区,事实上只需要对内存做出很少的修改,其速度⾮常快。只有在尝试获得已占⽤临界区时,它才会跳⾄内核模式。这⼀轻量级特性的缺点在于临界区只能⽤于对同⼀进程内的线程进⾏同步。
临界区由 WINNT.H 中所定义的 RTL_CRITICAL_SECTION 结构表⽰。因为您的 C++ 代码通常声明⼀个
CRITICAL_SECTION 类型的变量,所以您可能对此并不了解。研究 WINBASE.H 后您会发现:
typedef RTL_CRITICAL_SECTION CRITICAL_SECTION;
我们将在短时间内揭⽰ RTL_CRITICAL_SECTION 结构的实质。此时,重要问题在于 CRITICAL_SE
CTION(也称作
RTL_CRITICAL_SECTION)只是⼀个拥有易访问字段的结构,这些字段可以由 KERNEL32 API 操作。
在将临界区传递给 InitializeCriticalSection
时(或者更准确地说,是在传递其地址时),临界区即开始存在。初始化之后,代码即将临界区传递给 EnterCriticalSection 和LeaveCriticalSection API。⼀个线程⾃ EnterCriticalSection 中返回后,所有其他调⽤
EnterCriticalSection 的线程都将被阻⽌,直到第⼀个线程调⽤ LeaveCriticalSection
为⽌。最后,当不再需要该临界区时,⼀种良好的编码习惯是将其传递给 DeleteCriticalSection。
在临界区未被使⽤的理想情况中,对 EnterCriticalSection
的调⽤⾮常快速,因为它只是读取和修改⽤户模式内存中的内存位置。否则(在后⽂将会遇到⼀种例外情况),阻⽌于临界区的线程有效地完成这⼀⼯作,⽽不需要消耗额外的
CPU
周期。所阻⽌的线程以内核模式等待,在该临界区的所有者将其释放之前,不能对这些线程进⾏调度。如果有多个线程被阻⽌于⼀个临界区中,当另⼀线程释放该临界区时,只有⼀个线程获得该临界区。
深⼊研究:RTL_CRITICAL_SECTION 结构
即使您已经在⽇常⼯作中使⽤过临界区,您也⾮常可能并没有真正了解超出⽂档之外的内容。事实上存在着很多⾮常容易掌握的内容。例如,⼈们很少知道⼀个进程的临界区是保存于⼀个链表中,并且可以对其进⾏枚举。实际上,WINDBG
⽀持 !locks
命令,这⼀命令可以列出⽬标进程中的所有临界区。我们稍后将要谈到的实⽤⼯具也应⽤了临界区这⼀鲜为⼈知的特征。为了真正理解这⼀实⽤⼯具如何⼯作,有必要真正掌握临界区的内部结构。记着这⼀点,现在开始研究
RTL_CRITICAL_SECTION 结构。为⽅便起见,将此结构列出如下:
struct RTL_CRITICAL_SECTION
{
PRTL_CRITICAL_SECTION_DEBUG DebugInfo;
LONG LockCount;
LONG RecursionCount;
HANDLE OwningThread;
HANDLE LockSemaphore;
ULONG_PTR SpinCount;
};
以下各段对每个字段进⾏说明。
DebugInfo 此字段包含⼀个指针,指向系统分配的伴随结构,该结构的类型为
RTL_CRITICAL_SECTION_DEBUG。这⼀结构中包含更多极有价值的信息,也定义于 WINNT.H 中。我们稍后将对其进⾏更深⼊地研究。
LockCount 这是临界区中最重要的⼀个字段。它被初始化为数值 -1;此数值等于或⼤于 0
时,表⽰此临界区被占⽤。当其不等于 -1 时,OwningThread 字段(此字段被错误地定义于 WINNT.H 中 — 应当是 DWORD ⽽不是HANDLE)包含了拥有此临界区的线程 ID。此字段与 (RecursionCount -1) 数值之间的差值表⽰有多少个其他线程在等待获得该临界区。
RecursionCount
此字段包含所有者线程已经获得该临界区的次数。如果该数值为零,下⼀个尝试获取该临界区的线程将会成功。
OwningThread 此字段包含当前占⽤此临界区的线程的线程标识符。此线程 ID 与
GetCurrentThreadId 之类的 API 所返回的 ID 相同。
LockSemaphore
此字段的命名不恰当,它实际上是⼀个⾃复位事件,⽽不是⼀个信号。它是⼀个内核对象句柄,⽤于通知操作系统:该临界区现在空闲。操作系统在⼀个线程第⼀次尝试获得该临界区,但被另⼀个已经拥有该临界区的线程所阻⽌时,⾃动创建这样⼀个句柄。应当调⽤DeleteCriticalSection(它将发出⼀个调⽤该事件的 CloHandle 调⽤,并在必要时释放该调试结构),否则将会发⽣资源泄漏。
SpinCount 仅⽤于多处理器系统。MSDN
⽂档对此字段进⾏如下说明:“在多处理器系统中,如果该临界区不可⽤,调⽤线程将在对与该临界区相关的信号执⾏等待操作之前,旋转dwSpinCount
次。如果该临界区在旋转操作期间变为可⽤,该调⽤线程就避免了等待操作。”旋转计数可以在多处理器计算机上提供更佳性能,其原因在于在⼀个循环中旋转通常要快于进⼊内核模式等待状态。此字段默认值为零,但可以⽤
InitializeCriticalSectionAndSpinCount API 将其设置为⼀个不同值。
RTL_CRITICAL_SECTION_DEBUG 结构
前⾯我们注意到,在 RTL_CRITICAL_SECTION 结构内,DebugInfo 字段指向⼀个
RTL_CRITICAL_SECTION_DEBUG 结构,该结构给出如下:
struct _RTL_CRITICAL_SECTION_DEBUG
{
WORD  Type;
WORD  CreatorBackTraceIndex;
RTL_CRITICAL_SECTION *CriticalSection;
LIST_ENTRY ProcessLocksList;
DWORD EntryCount;
DWORD ContentionCount;
DWORD Spare[ 2 ];
}
这⼀结构由 InitializeCriticalSection 分配和初始化。它既可以由 NTDLL
内的预分配数组分配,也可以由进程堆分配。RTL_CRITICAL_SECTION
的这⼀伴随结构包含⼀组匹配字段,具有迥然不同的⾓⾊:有两个难以理解,随后两个提供了理解这⼀临界区链结构的关键,两个是重复设置的,最后两个未使⽤。
下⾯是对 RTL_CRITICAL_SECTION 字段的说明。
Type 此字段未使⽤,被初始化为数值 0。
CreatorBackTraceIndex 此字段仅⽤于诊断情形中。在注册表项
HKLM\Software\Microsoft\Windows NT\CurrentVersion\Image File Execution
Options\YourProgram 之下是 keyfield、GlobalFlag 和 StackTraceDatabaSizeInMb
值。注意,只有在运⾏稍后说明的 Gflags 命令时才会显⽰这些值。这些注册表值的设置正确时,CreatorBackTraceIndex
字段将由堆栈跟踪中所⽤的⼀个索引值填充。在中搜索 GFlags ⽂档中的短语“create ur mode stack trace
databa”和“enlarging the ur-mode stack trace databa”,可以找到有关这⼀内容的更多信息。
CriticalSection 指向与此结构相关的 RTL_CRITICAL_SECTION。图
1 说明该基础结构以及 RTL_CRITICAL_SECTION、RTL_CRITICAL_SECTION_DEBUG
和事件链中其他参与者之间的关系。
图 1 临界区处理流程
ProcessLocksList LIST_ENTRY 是⽤于表⽰双向链表中节点的标准 Windows
数据结构。RTL_CRITICAL_SECTION_DEBUG 包含了链表的⼀部分,允许向前和向后遍历该临界区。本⽂后⾯给出的实⽤⼯具说明如何使⽤
Flink(前向链接)和 Blink(后向链接)字段在链表中的成员之间移动。任何从事过设备驱动程序或者研究过 Windows
内核的⼈都会⾮常熟悉这⼀数据结构。
EntryCount/ContentionCount
这些字段在相同的时间、出于相同的原因被递增。这是那些因为不能马上获得临界区⽽进⼊等待状态的线程的数⽬。与 LockCount 和RecursionCount
字段不同,这些字段永远都不会递减。
Spares
这两个字段未使⽤,甚⾄未被初始化(尽管在删除临界区结构时将这些字段进⾏了清零)。后⾯将会说明,可以⽤这些未被使⽤的字段来保存有⽤的诊断值。
即使 RTL_CRITICAL_SECTION_DEBUG
中包含多个字段,它也是常规临界区结构的必要成分。事实上,如果系统恰巧不能由进程堆中获得这⼀结构的存储
区,InitializeCriticalSection
将返回为 STATUS_NO_MEMORY 的 LastError 结果,然后返回处于不完整状态的临界区结构。
临界区状态
当程序执⾏、进⼊与离开临界区时,RTL_CRITICAL_SECTION 和 RTL_CRITICAL_SECTION_DEBUG
结构中的字段会根据临界区所处的状态变化。这些字段由临界区 API
中的簿记代码更新,在后⾯将会看到这⼀点。如果程序为多线程,并且其线程访问是由临界区保护的公⽤资源,则这些状态就更有意义。
但是,不管代码的线程使⽤情况如何,有两种状态都会出现。第⼀种情况,如果 LockCount 字段有⼀个不等于 -1
的数值,此临界区被占⽤,OwningThread 字段包含拥有该临界区的线程的线程标识符。在多线程程序中,LockCount 与 RecursionCount 联合表明当前有多少线程被阻⽌于该临界区。第⼆种情况,如果 RecursionCount 是⼀个⼤于 1
的数值,其告知您所有者线程已经重新获得该临界区多少次(也许不必要),该临界区既可以通过调⽤ EnterCriticalSection、也可以通过调⽤
TryEnterCriticalSection 获得。⼤于 1 的任何数值都表⽰代码的效率可能较低或者可能在以后发⽣错误。例如,访问公共资源的任何 C++ 类⽅法可能会不必要地重新进⼊该临界区。
注意,在⼤多数时间⾥,LockCount 与 RecursionCount 字段中分别包含其初始值 -1 和
0,这⼀点⾮常重要。事实上,对于单线程程序,不能仅通过检查这些字段来判断是否曾获得过临界区。但是,多线程程序留下了⼀些标记,可以⽤来判断是否有两个或多个线程试图同时拥有同⼀临界区。
您可以找到的标记之⼀是即使在该临界区未被占⽤时 LockSemaphore 字段中仍包含⼀个⾮零值。这
表⽰:在某⼀时间,此临界区阻⽌了⼀个或多个线程 —
事件句柄⽤于通知该临界区已被释放,等待该临界区的线程之⼀现在可以获得该临界区并继续执⾏。因为 OS
在临界区阻⽌另⼀个线程时⾃动分配事件句柄,所以如果您在不再需要临界区时忘记将其删除,LockSemaphore 字段可能会导致程序中发⽣资源泄漏。
在多线程程序中可能遇到的另⼀状态是 EntryCount 和 ContentionCount
字段包含⼀个⼤于零的数值。这两个字段保存有临界区对⼀个线程进⾏阻⽌的次数。在每次发⽣这⼀事件时,这两个字段被递增,但在临界区存在期间不会被递减。这些字段可⽤于间接确定程序的执⾏路径和特性。例如,EntryCount
⾮常⾼时则意味着该临界区经历着⼤量争⽤,可能会成为代码执⾏过程中的⼀个潜在瓶颈。
在研究⼀个死锁程序时,还会发现⼀种似乎⽆法进⾏逻辑解释的状态。⼀个使⽤⾮常频繁的临界区的 LockCount 字段中包含⼀个⼤于 -1
的数值,也就是说它被线程所拥有,但是 OwningThread
字段为零(这样就⽆法找出是哪个线程导致问题)。测试程序是多线程的,在单处理器计算机和多处理器计算机中都会出现这种情况。尽管LockCount
和其他值在每次运⾏中都不同,但此程序总是死锁于同⼀临界区。我们⾮常希望知道是否有任何其他开发⼈员也遇到了导致这⼀状态的 API 调⽤序列。
构建⼀个更好的捕⿏器
在我们学习临界区的⼯作⽅式时,⾮常偶然地得到⼀些重要发现,利⽤这些发现可以得到⼀个⾮常好的实⽤⼯具。第⼀个发现是ProcessLocksList
LIST_ENTRY
字段的出现,这使我们想到进程的临界区可能是可枚举的。另⼀个重⼤发现是我们知道了如何找出临界区列表的头。还有⼀个重要发现是可以在没有任何损失的情况下写
RTL_CRITICAL_SECTION 的 Spare
字段(⾄少在我们的所有测试中如此)。我们还发现可以很容易地重写系统的⼀些临界区例程,⽽不需要对源⽂件进⾏任何修改。
最初,我们由⼀个简单的程序开始,其检查⼀个进程中的所有临界区,并列出其当前状态,以查看是否拥有这些临界区。如果拥有,则找出由哪个线程拥有,以及该临界区阻⽌了多少个线程?这种做法对于
OS 的狂热者们⽐较适合,但对于只是希望有助于理解其程序的典型的程序员就不是⾮常有⽤了。
即使是在最简单的控制台模式“Hello World”程序中也存在许多临界区。其中⼤部分是由 USER32 或 GDI32 之类的系统 DLL 创建,⽽这些DLL 很少会导致死锁或性能问题。我们希望有⼀种⽅法能滤除这些临界区,⽽只留下代码中所关⼼的那些临界区。
RTL_CRITICAL_SECTION_DEBUG
结构中的 Spare 字段可以很好地完成这⼀⼯作。可以使⽤其中的⼀个或两个来指⽰:这些临界区是来⾃⽤户编写的代码,⽽不是来⾃ OS。
于是,下⼀个逻辑问题就变为如何确定哪些临界区是来⾃您编写的代码。有些读者可能还记得 Matt Pietrek 2001 年 1 ⽉的专栏中的LIBCTINY.LIB。LIBCTINY 所采⽤的⼀个技巧是⼀个 LIB
⽂件,它重写了关键 Visual C++ 运⾏时例程的标准实现。将 LIBCTINY.LIB ⽂件置于链接器⾏的其他 LIB
之前,链接器将使⽤这⼀实现,⽽不是使⽤ Microsoft 所提供的导⼊库中的同名后续版本。

本文发布于:2023-05-09 16:53:54,感谢您对本站的认可!

本文链接:https://www.wtabcd.cn/fanwen/fan/82/565745.html

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。

标签:线程   结构   字段
相关文章
留言与评论(共有 0 条评论)
   
验证码:
推荐文章
排行榜
Copyright ©2019-2022 Comsenz Inc.Powered by © 专利检索| 网站地图