`

多线程中的 WaitForSingleObject 与 EnterCriticalSection 性能比较

 
阅读更多
转:http://apps.hi.baidu.com/share/detail/16243424

摘要
在 Microsoft Windows 平台上有几种以原子方式锁定代码和数据的不同方法。此白皮书的主要目的是向开发人员简要介绍 Windows 中进行锁定的不同方法以及与这些锁定有关的相应性能开销。因为未来架构将是多核架构,因此此信息非常适用。

简介

多线程软件应用对于提升英特尔内核架构的性能至关重要。锁定代码通常是多线程应用中运行最频繁的代 码。确定要使用的锁定方法与确定应用中并行处理一样重要。此白皮书的主要目的是向开发人员简要介绍 Windows 中进行锁定的不同方法以及与这些锁定有关的相应性能开销。Window 的某些锁定 API 可能会跳转至操作系统内核。此白皮书将详细说明跳转至内核的 API 以及相应的加入条件。

使用两种不同的锁定内核说明表示不同粒度的锁定的影响。第一种锁定内核模拟锁定和取消锁定动态链接列表(通常用于通过内存管理器维护一个空闲列表)的场 景。对于多线程应用,首先需要锁定此列表,线程才能尝试分配或释放内存。第二种锁定内核表示更加精细地锁定,因为它仅获取已获得锁定的线程的 ThreadID,更新全局变量,然后释放锁定。通过采用 1 至 64 的线程数,对高、低争用场景下不同锁定的性能进行测试。每一线程获取执行 10,000,000 次某一运算的锁定,然后释放此锁定。对于这些实验,Windows XP 操作系统计时从 10 毫秒更改为 1 毫秒。
WaitForSingleObject 和 EnterCriticalSection
Microsoft Windows 平台中两种最常用的锁定方法为 WaitForSingleObject 和 EnterCriticalSection。WaitForSingleObject 是一个过载 Microsoft API,可用于检查和修改许多不同对象(如事件、作业、互斥体、进程、信号、线程或计时器)的状态。WaitForSingleObject 的一个不足之处是它会始终获取内核的锁定,因此无论是否获得锁定,它都会进入特权模式 (环路 0)。此 API 还进入 Windows 内核,即使指定的超时为 0,亦如此。此锁定方法的另一不足之处在于,它一次只能处理 64 个尝试对某个对象进行锁定的线程。WaitForSingleObject 的优点是它可以全局进行处理,这使得此 API 能够用于进程间的同步。它还具有为操作系统提供锁定对象信息的优势,从而可以实现公平性及优先级倒置。

通过对关键代码段实施 EnterCriticalSection 和 LeaveCriticalSection API 调用,可以使用 EnterCriticalSection。此 API 具有 WaitForSingleObject 所不具备的优点,因为只有存在锁定争用时,才会进入内核。如果不存在锁定争用,则此 API 会获取用户空间锁定,并且在未进入特权模式的情况下返回。如果存在争用,则此 API 在内核中所采用的路径将与 WaitForSingleObject 极其相似。在低争用的情况下,由于 EnterCriticalSection 不进入内核,因此锁定开销非常低。

不足之处是 EnterCriticalSection 无法进行全局处理,因此无法为线程获取锁定的顺序提供任何保证。EnterCriticalSection 是一种阻塞调用,意味着只有线程获得对此关键区段的访问权限时,该调用才会返回。Windows 引入了 TryEnterCriticalSection,TryEnterCriticalSection 是一种非阻塞调用,无论获得锁定与否都会立即返回。此外,EnterCriticalSection 还允许开发人员使用自旋计数对关键区段进行初始化,在回退前线程会按此自旋计数尝试获取锁定。通过使用 API InitializeCriticalSectionAndSpinCount,完成初始化。自旋计数可以在此调用中进行设置,也可以在注册表中进行设 置,以根据不同操作系统及其相应的线程量程对自旋进行更改。

如果存在锁定争用,则 EnterCriticalSection 和 WaitForSingleObject 都会进入内核。如果实现程度过高,从用户模式到特权模式的转换开销将会非常大。

EnterCriticalSection 和 WaitForSingleObject API 调用在对使用数千个周期的运算进行锁定时,通常不会影响性能。在这些情况下,锁定调用本身的开销不会如此突出。会导致性能降低的情况是粒度锁定,获得和释 放此锁定要花费数百个周期。在这些情况下,使用用户级别锁定则非常有益。

为了说明在低争用的情况下 WaitForSingleObject 调用与 EnterCriticalSection 调用的开销情况,我们分别在 1 个和 2 个线程上运行了内存管理锁定内核。在低争用的情况下,存在加速比 (WaitForSingleObject_Time / EnterCriticalSection_Time) 大约为 5 倍的性能之差。在 2 个线程持续争用的情况下,使用 EnterCriticalSection 和使用 WaitForSingleObject 之间的差别最小。在低争用的情况下存在性能差距的原因如下:WaitForSingleObject 在每次调用时都进入内核,而 EnterCriticalSection 只有当存在锁定争用时,才进入内核。

线程数 量 EnterCriticalSection 时间(毫秒) WaitForSingleObject 时间(毫秒) 加速比
1 个线程(无争用) 1781 9187 5.2
2 个线程(争用) 53594 58156 1.1

图 1:显示了在具有 1 个和 2 个线程的情况下,EnterCriticalSection 和 WaitForSingleObject 所对应的内存管理内核情况。EnterCriticalSection 在 1 个线程(无争用)的情况下速度较快,因为如果获得锁定,EnterCriticalSection 不会跳转至内核(特权模式)。

下图 2 说明了 EnterCriticalSection 和 WaitForSingleObject 在高争用时的开销情况,所采用的线程数介于 1 至 64 之间。在本实验中,我们在向动态链接列表中推入值和从中弹出值的同时,锁定和取消锁定该列表。目的是模拟内存分配器空闲列表,为了分配或释放内存,需频繁 锁定该列表。内核会主动根据环境切换争用锁定的线程,因此在两次实验中,CPU 平均负载都是 22%。很明显,在高争用情况下,利用 EnterCriticalSection 和 WaitForSingleObject 的开销并无太大差别。



图 2:显示了在具有 1 到 64 条线程的情况下,EnterCriticalSection 和 WaitForSingleObject 所对应的内存管理内核情况。在高争用情况下,与 WaitForSingleObject 和 EnterCriticalSection 所关联的开销并无太大差别。

利用英特尔® VTune 分析器通过基于事件的采样来收集时钟滴答事件,这对于确定 EnterCriticalSection 和 WaitForSingleObject 的争用情况将有所帮助。在进行该实验之前,开发人员需确保已下载了正确的内核符号。有关如何下载内核符号的说明,可从 Microsoft 开发人员网络 MSDN 上获取。

在利用 EnterCriticalSection 时,如果内核(ntoskrnl.exe 或 ntkrnlpa.exe)中花费了时间,则表明发生了争用。不存在争用时,EnterCriticalSection 调用和 LeaveCriticalSection 调用会分别将大部分时间花费在 RtlEnterCriticalSection 函数和 RtlLeaveCriticalSection 函数的 ntdll.dll 中。NTDLL.DLL 是在处理器的环路 3(非特权模式)级别上运行的动态链接库。此 NTDLL.DLL 库包含许多某个应用使用的运行时库(RTL)代码,不应与操作系统内核相混淆。EnterCriticalSection 遇到争用时,它所采取的路径将与 WaitForSingleObject 极其相似。通过查看 hal.dll、ntdll.dll 和 ntkrnlpa.exe(或 ntoskrnl.exe)中包含的函数,便可能了解是否发生 EnterCriticalSection 高争用的情况,而无需了解这些内核函数的详细信息,如图 3 中所示。



图 3: 显示了在发生 EnterCriticalSection 和 WaitForSingleObject 高争用的情况下,Windows 操作系统内核、ntdll.dll 和 hal.dll 中的热门函数情况。

WaitForSingleObject 将始终跳转至 Windows 操作系统内核,但仍可以确定是否发生了针对利用此调用的锁定的高争用。当锁定发生高争用时,WaitForSingleObject 在内核、ntdll.dll 和 hal.dll 中将分别采取不同的路径。这些路径与因线程无法获得所需的锁定而导致内核根据环境切换线程有关。特别是 KiDispatchInterrupt(操作系统内核)、ZwYieldExecution(操作系统内核)、 KiDispatchInterrupt(操作系统内核)、HalRequestlpi (hal.dll) 和 HalClearSoftwareInterrupt (hal.dll) 等都是能够很好地监视内核内 WaitForSingleObject 或 EnterCriticalSection 争用的函数。

图 4: 显示了在 WaitForSingleObject 调用的无争用和高争用情况下,Windows 操作系统内核、ntdll.dll 和 hal.dll 中的热门函数情况。

如果存在锁定争用,EnterCriticalSection 和 WaitForSingleObject 都将进入内核,对于操作粒度性较高的锁定和高争用的锁定,用户级锁定是最佳选择。
用户级原子锁定
用户级锁定涉及利用处理器的原子操作指令以原子方式更新内存空间。原子操作指令涉及利用指令的锁定前 缀,并将目标操作数指定给某个内存地址。在目前的英特尔处理器上,使用锁定前缀以原子方式运行以下指令:ADD、ADC、AND、BTC、BTR、 BTS、CMPXCHG、CMPXCH8B、DEC、INC、NEG、NOT、OR、SBB、SUB、XOR、XADD 和 XCHG。在跳转至内核之前,EnterCriticalSection 会利用原子操作指令尝试获取用户空间锁定。大多数指令必须明确使用锁定前缀,但 xchg 和 cmpxchg 指令例外,如果这两个指令中包含内存地址,则暗示着锁定前缀的存在。

在英特尔 486 处理器时代,锁定前缀用于在总线上声明一个锁定,这通常会带来较大的性能损失。从英特尔高能奔腾架构开始,总线锁定已转变为缓存锁定。在大多数现代架构 中,如果锁定驻留在无法缓存的内存中,或锁定超出了分割缓存线的缓存线边界,则仍会在总线上声明锁定。这两种情况都不大可能出现,因此大多数锁定前缀都将 转变为开销低廉的缓存锁定。

图 3 包含一个采用程序集中的一些行编写的简单锁定,以说明利用带有锁定前缀的原子操作指令获取锁定的方法。在本例中,代码仅仅测试指向的内存空间,以尝试获取 某个锁定。如果该内存空间包含一个 1,则表明另一个线程已获得了该锁定。如果内存空间为 0,则表明该锁定是可用的。原子操作指令 xchg 用于尝试与内存空间交换 1。如果在执行 xchg 指令后,eax 包含 0,则表明当前线程获得了该锁定。如果在执行原子操作指令 xchg 后,eax 包含 1,则表明另一个线程已获得了该锁定。
注释:edx 注册包含锁定变量的地址// 将 1 移至 eax 注册mov eax, 1// 使用解除参考的 edx 中包含的值 xchg 1lock xchg DWORD PTR[edx],eax// 如果为零,则进行测试test eax,eax// 如果不是零,则跳过jne Target
            


图 5: 显示了一个简单互斥锁定的程序集。

要利用用户空间锁定(使用锁定前缀),不一定要编写程序集。通过 InterlockedExchange、InterlockedIncrement、InterlockedDecrement、 InterlockedCompareExchange 和 InterlockedExchangeAdd 等“Interlocked”API,Microsoft 提供了对用于同步的最常用原子操作指令的访问。这些 API 全部驻留在 kernel32.dll 中,ring3 级别(非特权模式)的应用程序将加载该 kernel32.dll。由于名称易于混淆,许多开发人员误以为 kernel32.dll 时间为内核时间,但此 dll 完全在 ring 3 级别(用户模式)上运行。它确实是用作 API 跳转至 Windows 内核的网关。Interlocked 函数根本不可能跳转至 Windows 内核(特权模式)。

为说明利用 WaitForSingleObject 和用户级锁定等开销高昂的锁定的潜在开销,使用简单的具有回退功能的用户级自旋等待锁定运行内存管理内核。在高争用和低争用情况下,用户级自旋等待的开销 要低几个数量级。因此,用户级锁定便成为频繁调用的粒度锁定的首选。



图 6: 内联用户级自旋等待锁定和 WaitForSingleObject 在内存管理锁定内核上的开销。
用户级原子锁定上的自旋等待循环
利用用户模式锁定的一个不足之处在于,内核不能用于提供自旋等待。这意味着,尝试使用 xchg 或 cmpxchg 获取锁定的应用程序将必须转而执行其他操作(如果未获得锁定)或针对该锁定自旋。如果程序员想要以用户模式实现自旋等待循环,则利用具有初始化自旋计数的 TryEnterCriticalSection 或使用诸如英特尔线程构建模块中提供的第三方锁定库将是最佳选择。从理想的角度来看,当无法获得锁定时,使线程转而执行其他操作(而非使用任何自旋等待) 始终是最佳选择。

为了更好地了解自旋等待循环的构造,针对锁定内核创建了一个循环。在自旋等待循环中,首先使用原子操作指令尝试获取锁定(通常使用具有锁定前缀的 cmpxchg 或 xchg)。如果未获得锁定,代码将针对锁定内存空间的读取进行自旋,从而尝试确定其他线程释放该锁定的时间。该读取称为“可变读 取”,因为它涉及 C 编程语言中的一种可变类型。可变类型具有几条与之相关联的规则,包括它们无法在注册表中更新,以及必须利用它们的内存地址来操作。在下例中,我们首先尝试 获取锁定。在本例中,获得的互斥锁定将显示 0 作为 xchg 操作的结果。

如果未获得锁定,则代码将针对可变数据类型的脏读进行自旋。然后会执行测试以确定该变量为 0 还是锁定已打开。如果锁定结果为 0,则线程将再次尝试以原子方式获取锁定,并跳转至要执行的受保护代码。
if (GETLOCK(lock) != 0){While (VARIOUS_CRITERIA) {_asm pause;  // 自旋循环中的暂停指令if ((volatile int*)lock) == 0) // 针对脏读(而非锁定)进行自旋   {if (GETLOCK(lock)) == 0)     {goto PROTECTED_CODE;     }   }}BACK_OFF_LOCK(lock); // 回退锁定或执行其他操作}PROTECTED_CODE:
            


图 6: 用于原子锁定的有效自旋等待循环。请注意回退代码以及针对可变读取(而非锁定)的自旋。
通过自旋等待循环回退锁定
由于以下几个原因,利用用户空间自旋等待循环是很危险的。操作系统无法了解到处理器在自旋循环中并未 执行有用的操作。因此它会允许线程继续自旋,直至用尽其量程。量程是指分配给处理器上运行的每个线程的时间片。此问题在线程量程较高的 Windows 服务器平台上显得尤为严重。这不仅会大大增加 CPU 的使用率和处理器浪费的能源,而且还会给应用程序的性能带来负面影响,这是因为占用锁定的线程无法获取处理器以释放锁定。在设计自旋等待循环时,使每个线 程具有回退锁定的功能是很重要的。这确保了不会有太多的线程同时主动获取同一个锁定。

回退某个锁定可通过不同的方式实现。最简单的方法是获取计数锁定,使用该方法,应用程序可以在尝试获取某一锁定达到特定次数后,通过请求环境切换回退该锁 定。该尝试计数易于调整,此方法也确实常常根据硬件线程数的不同而有所调整。自旋的挂钟时间也将随处理器频率发生变化,这使得此代码难于维护。锁定的指数 回退首先将不断尝试获取锁定,但会将较长时间的等待视作线程不大可能获得此锁定。指数回退自旋等待具有更强的可伸缩性。在 Windows 网络 [1] 中通常使用第二种方法。

图 7 显示了某个内联程序集 xchg 锁定在其自旋等待循环中具有回退功能和不具有回退功能时的效果。请注意具有回退功能的锁定的性能远远高于不具有回退功能的锁定的性能。这通常是因偶尔出现 以下情况所致:某个占用锁定的线程根据环境被切换出去。该线程必须等待,直至获取处理器后才能释放锁定。



图 7: ThreadID 锁定内核上具有回退功能的锁定和不具有回退功能的锁定。
针对可变读取的自旋和针对锁定尝试的自旋
开发人员在开发其自己的自旋等待循环时常犯的一个错误是试图针对原子操作指令(而非可变读取)进行自 旋。针对脏读进行自旋(而不是尝试获取锁定)会消耗较少的时间和资源。这使得应用程序仅在锁定可用时才尝试获取锁定。

图 8 显示了对于 xchg 而言,针对锁定自旋和针对可变读取自旋所用时间的差异。



图 8: threadID 锁定内核上具有回退功能的锁定和不具有回退功能的锁定。

一个常见的误区是利用 cmpxchg 指令的锁定的开销低于利用 xchg 指令的锁定的开销。由于首先运行 cmp,cmpxchg 不会尝试以独占模式获取锁定,因此造成了此误解。图 9 显示了 cmpxchg 指令与 xchg 指定的开销并无太大差别。



图 9: 在 ThreadID 锁定内核上比较 xchg 和 cmpxchg。
结论
针对各种不同的情况利用适当的锁定对于确保 Microsoft Windows 环境中的性能和可伸缩性至关重要。即使不存在锁定争用的情况下,利用 WaitForSingleObject 对于 Windows 内核而言,仍是开销高昂的调用。如果将会频繁调用该锁定,并且只存在少量针对该锁定的争用,则应避免此类型的锁定。因此,对于粒度锁 定,WaitForSingleObject 可能很危险。EnterCriticalSection 会首先尝试获取用户级锁定,如果存在锁定争用,则会跳转至内核。为帮助开发人员省去编写自己的自旋等待循环的麻烦,Microsoft 添加了 TryEnterCriticalSection 调用,该调用在跳转至内核之前会尝试获取某个自旋中的用户级锁定。

为了了解用户级自旋等待循环,我们在此白皮书中提供了基本的构造和性能建议。回退功能和针对可变读取的自旋对于自旋等待性能至关重要。
参考信息
[1] http://www.microsoft.com/technet/itsolutions/network/deploy/depovg/tcpip2k.mspx*

[2] Chynoweth Michael针对英特尔® EM64T 或 32 位英特尔® 架构实现可伸缩的原子锁定。
蜗牛园艺|蜗牛食堂



分享到:
评论

相关推荐

    vc6多线程 多线程进度百分比

    在实际项目中,编写多线程代码时,除了关注功能实现,还需要考虑异常处理、资源释放以及性能优化。对于VC6这样的老版本开发环境,虽然某些高级特性可能受限,但基本的多线程编程仍然是可行的,只是需要注意兼容性...

    深入浅出Win32多线程编程

    多线程编程是现代软件开发中的重要组成部分,尤其是在处理高性能计算、并发操作和实时系统时,其优势尤为显著。本篇将详细介绍Win32 API中与多线程相关的概念、函数和最佳实践。 首先,要理解什么是线程。线程是...

    3种多线程实现同步方法

    在实际开发中,开发者应根据具体需求选择合适的同步策略,以确保多线程程序的正确性和性能。在设计多线程程序时,不仅要考虑同步问题,还要注意减少线程间的上下文切换,避免死锁和饥饿现象,确保程序的健壮性和可...

    windows下多核多线程编程

    在Windows操作系统中,多核多线程编程是提高程序并行性和效率的重要手段。通过创建和管理多个线程,可以在多核处理器上并发执行任务,从而充分利用硬件资源,提升程序性能。本文将深入探讨如何在Windows环境下进行多...

    编写多线程程序

    多线程编程是指在一个程序中同时运行多个线程,以提高程序的执行效率和响应速度。在 Windows 平台上,多线程编程通常使用 WINAPI 提供的线程控制函数来实现。 在多线程程序中,需要使用线程建立函数 CreateThread ...

    Delphi多线程的安全问题分析及解决

    在Windows操作系统中,多线程技术因其高效性和灵活性而被广泛应用于软件开发之中。然而,随着多线程应用的普及,线程间的同步与资源管理问题逐渐凸显,尤其是在共享资源访问控制方面的问题尤为突出。本文重点分析了...

    win32多线程vc6.0

    6. **调试与测试**:VC6.0提供了调试工具,可以在多线程环境中设置断点、单步执行,检查线程状态和同步对象的状态,确保程序的正确运行。 通过这个“Win32多线程程序设计(源代码)”压缩包,你可以学习到如何在VC...

    《Win32多线程程序设计》配套代码

    配套代码包含了书中各个章节的关键示例,帮助读者通过实践加深对多线程编程的理解。 在Win32 API中,多线程的创建主要通过`CreateThread`函数实现。这个函数允许程序员在进程中创建一个新的执行线程,并指定线程...

    MFC多线程编程实例

    在这个名为"MFC多线程编程实例"的压缩包中,包含了9个具体示例,涵盖了多线程编程的各个方面,特别是针对Windows 32位系统(Wind32)的多线程处理。这些实例可以帮助开发者深入理解如何在MFC环境中创建和管理线程,...

    VC++多线程同步基本示例

    本示例着重讲解了VC++中的多线程同步,这是多线程编程中确保数据安全和正确性的重要概念。我们将深入探讨临界区、互斥量、事件和信号量这四种多线程同步机制。 1. **临界区(Critical Section)**:临界区是多线程...

    vc多线程

    在VC++编程环境中,多线程技术是一种提升应用程序性能的重要手段。它允许程序同时执行多个任务,从而充分利用多核处理器的资源。本篇文章将详细探讨VC++中的多线程实现,包括信号量、全局锁和线程锁等相关概念。 ...

    windows多线程的同步和互斥

    在Windows操作系统中,多线程编程是实现并发执行任务的有效方式。当一个进程中存在多个执行流,即多个线程,它们可以共享同一地址空间,从而提高资源利用率和程序的响应速度。然而,多线程环境下可能会出现数据竞争...

    windows中多线程编程实例

    在Windows操作系统中,多线程编程是实现并发执行任务的有效方式,它可以充分利用多核处理器的计算能力,提高程序的响应速度和效率。本实例将深入探讨四种不同的同步机制:临界区、事件、信号量和互斥量,这些都是在...

    多线程日志记录源码

    在IT行业中,多线程日志记录是一项至关重要的任务,特别是在大型系统或高并发环境中,确保日志的正确性和线程安全性是系统稳定运行的基础。本文将深入探讨使用VC++实现多线程日志记录的源码,以及如何保证线程安全。...

    VC多线程实例

    在VC++编程环境中,多线程技术是一种关键的性能优化手段,它允许程序同时执行多个任务,提升程序的效率和响应性。本实例将深入探讨如何在Visual C++(简称VC)中创建和管理多线程。 首先,理解多线程的概念是至关...

    开发基于多线程的Windows应用程序。

    在开发基于多线程的Windows应用程序时,程序员通常会利用多线程技术来提升软件的性能和响应性。本文将详细讲解如何使用Visual C++ 6.0这一经典开发环境来构建这样的应用,并以"Race"为例,模拟一场多线程的百米赛跑...

    VC++多线程管理器_authornop_Vc_

    在VC++编程环境中,多线程...通过源代码,你可以学习到如何在多线程环境中实现并发和同步,这对于编写高性能的多任务应用程序至关重要。通过深入研究和实践这个项目,开发者将能够更好地理解和应用VC++中的多线程技术。

    win32多线程程序设计源码_侯捷

    这个资源可能包括了一系列的示例代码、讲解文档,旨在帮助开发者深入理解和实践win32 API中的多线程编程技术。 在Windows操作系统中,多线程编程是并发执行任务的一种方式,可以提高系统资源的利用率和程序的响应...

    Win32多线程编程源代码.rar

    在Windows操作系统中,Win32 API提供了一套丰富的接口用于创建和管理多线程应用程序。多线程编程是现代软件开发中的重要技术,它允许多个任务在同一时间执行,提高程序的并发性和效率。"Win32多线程编程源代码.rar...

Global site tag (gtag.js) - Google Analytics