管理锁争用 — 大、小关键代码段 (PDF 147KB)
摘要
在多线程应用中,程序员会使用锁来同步线程进入可访问共享资源的代码区域的行为。 受这些锁保护的代码区域被称为关键代码段 (Critical Section)。 如果关键代码段中已存在一个线程,那么其它任何线程都不可进入该代码段。 由此可见,关键代码段采用序列化执行方式。 本文介绍了关键代码段大小这一概念及其对性能的影响。关键代码段大小指线程在关键代码段中花费的时间长度。
本文是“英特尔® 多线程应用开发指南”系列的一部分,该系列介绍了针对英特尔® 平台开发高效多线程应用的指导原则。
背景
关键代码段可在多个线程尝试访问共享资源时确保数据的完整性。 它们还对自身内部的代码执行进行了序列化。 线程应尽量缩短在关键代码段中花费的时间,进而减少其它线程在代码段外闲置等待获得锁的时间 — 这种状态被称之为“锁争用”。 换句话说,关键代码段越小越好。 然而,使用大量独立的小代码段会导致与获取和释放各个独立锁相关的系统开销。 本文中描述的情景阐明了什么时候最适合使用大型或小型关键代码段。
代码示例 1 中的线程函数包含两个关键代码段。 假设这两个关键代码段可保护不同数据,并且函数 DoFunc1 和 DoFunc2 中的工作是相互独立的。 与此同时,假设执行上述两个更新函数中的任意一个所花费的时间都非常短。
代码示例 1:
Begin Thread Function () Initialize () BEGIN CRITICAL SECTION 1 UpdateSharedData1 () END CRITICAL SECTION 1 DoFunc1 () BEGIN CRITICAL SECTION 2 UpdateSharedData2 () END CRITICAL SECTION 2 DoFunc2 () End Thread Function ()
关键代码段被一个 DoFunc1调用请求分离开来。 如果线程在 DoFunc1函数上只花费了很短的时间,那么同步两个关键代码段所产生的开销没有任何实际意义。 在这种情况下,更好的方案是将两个小关键代码段合并为一个稍大的关键代码段,如代码示例 2。
代码示例 2:
Begin Thread Function () Initialize () BEGIN CRITICAL SECTION 1 UpdateSharedData1 () DoFunc1 () UpdateSharedData2 () END CRITICAL SECTION 1 DoFunc2 () End Thread Function ()
如果花费在DoFunc1函数上的时间远远超过执行两个更新例程的总时间,那么该方案可能不可行。 增加的关键代码段大小可提高出现锁争用现象的可能性,而且线程数量越多越是如此。
现在让我们假设上一示例中的情况稍有改变,线程在UpdateSharedData2函数上会花费较长时间,那么结果又会如何呢? 此时,使用单个关键代码段同步到 UpdateSharedData1和 UpdateSharedData2的访问(如代码示例 2)不再是适当的解决方案,因为出现锁争用现象的几率增加了。 执行过程中,获得关键代码段访问权的线程将在代码段中花费非常长的时间,导致所有其它线程全部堵塞在外。 当占用锁的线程将锁释放后,正等待的线程中只有一条可进入关键代码段,所有其它线程还要堵塞很长一段时间。 因此,在这种情况下,代码示例 1 反而是更好的选择。
将锁关联到特定共享数据是一项不错的编程实践。 使用同一个锁保护到某共享变量的所有访问并不能阻止其它线程访问由不同锁保护的其它共享变量。 假设使用共享数据结构, 您可以为该结构中的每个元素创建一个独立的锁,或者创建单个锁来保护到整个结构的访问。 考虑到更新元素的计算成本,这两种极端的方法都有可能是切实可行的解决方案。 不过,最佳锁粒度也可能处于在这两者之间。 例如,在某个指定的共享数组中,可以创建两个锁:一个用于保护偶数编号的元素,另一个则用于保护奇数编号的元素。
如果执行 UpdateSharedData2函数需要较长时间,最佳方案是按照该例程划分工作,并创建新的关键代码段。 在代码示例 3 中,原始的UpdateSharedData2函数被分解为两个使用不同数据进行运算的函数。 这样做的原因是希望通过使用分离的关键代码段来减少锁争用。 如果 UpdateSharedData2的整个执行过程都不需要保护,您应该考虑在函数中需要访问共享数据的点插入关键代码段,而不是封闭整个函数调用。
代码示例 3:
Begin Thread Function () Initialize () BEGIN CRITICAL SECTION 1 UpdateSharedData1 () END CRITICAL SECTION 1 DoFunc1 () BEGIN CRITICAL SECTION 2 UpdateSharedData2 () END CRITICAL SECTION 2 BEGIN CRITICAL SECTION 3 UpdateSharedData3 () END CRITICAL SECTION 3 DoFunc2 () End Thread Function ()
建议
根据获取和释放锁的开销调整关键代码段的大小。 考虑整合小关键代码段,以分担锁定开销。 将锁争用现象严重的大型关键代码段划分为较小的关键代码段。 将锁关联至特定的共享数据,借以最大限度减少锁争用问题。 最佳解决方案可能处于为每个共享数据元素创建一个锁和为所有共享数据创建一个锁两种极端之间。
切记,同步操作会将执行序列化。 采用大关键代码段意味着算法本身的并发性非常低,或者线程间的数据划分并不理想。 对于前者,只能更改算法。 对于后者,可尝试为共享数据创建本地拷贝,支持线程异步访问。
之前对关键代码段大小和锁粒度的讨论并没有将环境切换所带来的影响考虑在内。 当线程堵塞在关键代码段之外,等待获取锁时,操作系统将使用活动线程交换闲置线程, 这便是所谓的环境切换。 一般来说,该行为很有用,可释放 CPU 以执行更重要的任务。 然而,对于正等待进入小关键代码段的线程来说,使用旋转等待 (spin-wait) 循环可能比环境切换操作更为有效。 但是,鉴于处于等待状态的线程在旋转等待循环中仍将占用 CPU 资源, 只有当线程在关键代码段中所花费的时间极短,不良影响低于环境切换时,才推荐使用旋转等待循环。
代码示例 4 展示了使用 Win32 线程 API 时可采用的一种较为有效的试探法。 该示例针对 Win32 CRITICAL_SECTION 对象使用了旋转等待选项。 无法进入关键代码段的线程将自旋,而不是释放 CPU 资源。 如果在旋转等待过程中 CRITICAL_SECTION 变为可用,便可避免环境切换。 自旋计数参数决定了线程在进入堵塞状态前将旋转的次数。 在单处理器系统中,自旋计数参数将被忽略。 代码示例 4 将应用中所有线程的自旋计数均设定为 1000,而允许的最大自旋计数是 8000。
代码示例 4:
int gNumThreads; CRITICAL_SECTION gCs; int main () { int spinCount = 0; ... spinCount = gNumThreads * 1000; if (spinCount > 8000) spinCount = 8000; InitializeCriticalSectionAndSpinCount (&gCs, spinCount); ... } DWORD WINAPI ThreadFunc (void *data) { ... EnterCriticalSection (&gCs); ... LeaveCriticalSection (&gCs); }
使用指南
在支持英特尔® 超线程技术(英特尔® HT 技术)的处理器中,需要对代码示例 4 中使用的自旋计数参数进行单独调整,因为旋转等待循环通常会对此类处理器的性能造成极大影响。 与具备多个物理 CPU 的真正对称多处理器 (SMP) 系统不同,英特尔 HT 技术可在同一 CPU 核心上创建两路逻辑处理器。 旋转线程和正执行有用任务的线程务必会争夺逻辑处理器资源。 显而易见,与对称多处理器系统相比,旋转线程对采用英特尔超线程技术的系统中多线程应用性能的影响更大。 在这种情况下,应将代码示例 4 中的自旋计数调低,或者根本不采用旋转等待循环。
更多资源