在不同的同步基元间做出选择

同步技术可支持线程在不干涉其它线程的情况下执行各自任务。但是,不同基元类型之间存在着重要差别,特别是在性能影响方面,这种差别尤其突出。那么,同步线程的最佳途径是什么呢?

随着多核技术将台式机软件推向多线程并行编程领域,开发人员也遭遇到了众多编码方面的新问题。其中一个主要问题就是线程同步。一方面,我们有很多方法实现线程同步。但从另一个角度来看,并行编程的技巧又决定着使用哪种同步基元。

作为进程及线程管理的重要构建模块,同步基元通过阻止多条线程同时访问某一资源来实现保护目的。编写并行应用的大部分技巧都来源于合理使用同步基元的能力。最佳实践通常都侧重于选择一个适当的基元,并尝试在尽可能短的时间里锁定某一项目。但是,开发高效代码时人们经常会忽视一个问题,即所选基元的处理开销。在当前的多数操作系统中,同步基元开销的成本都大不相同;因此那些希望优化性能的开发人员必须要对所选基元的性能影响进行全面考虑。此外,这一因素在 Microsoft Windows* 操作系统中最为明显,因为此类系统中包含了大量的线程 API 与基元--无论线程的需要如何,都会配备一个特定的可用解决方案。

本文将对 Windows 平台(偶尔涉及 Linux*/Pthreads 相关内容)中最为常用的同步基元进行研究,还将提供一些有益推荐,帮助您决定在需要考虑性能时选择哪种基元。本文面向那些队并行基元有一定基础知识的读者。如欲了解有关 Windows 线程执行方法的更多信息,请参阅本文最后的“资源”部分。

 

代表性基元

同步基元在所需的处理开销上存在很大不同。在此我们将研究三种常见的基元,其开销量呈递减趋势:互斥体(开销最大,尤其在 Windows 系统中);关键段(开销要小得多);原子基元(开销很小)。

互斥体是最为常用的一种锁定(用于所有主要的线程接口,如 Windows 线程和 Linux/Pthread),可阻止线程访问数据项目或代码片段等资源。除了最常见的互斥体外,还有其它类似的锁定机制。旗语(semaphore)锁定能够跟踪锁定次数,从而在项目解锁时对线程访问进行管理。Windows、Linux(尽管从严格讲上并不属于 Pthreads 的一部分)以及大多数其它操作系统中都存在旗语锁定。

关键段锁定的处理开销较小。在 Windows 和 Linux/Pthreads 中,术语“关键段(critical section)”的含义有所不同。在 Linux/Pthreads 中,关键段指的是一段代码,我们可以对其访问进行锁定。而在 Windows 中,关键段即锁定本身。

最后,原子基元属于同步函数,仅限用于规模小、速度快,并可在单一操作中通过原子方式进行执行的活动中。原子基元一般用于对计数器进行增减操作,或是修改某个变量值。在 Windows 中,原子基元会通过一个被称为“互锁(interlocked)”的函数来实施。

 

详细论述

互斥体是 Windows 中的重要锁定机制。它们属于核心操作,因此需要通过系统调用来创建(和锁定),释放时还需要额外的系统调用。这种实施方式有利有弊。最大的好处是,mutex 能够在进程间保持可见,也就是说,在 Windows 的多个程序间可见(注意:Linux 下情况与此不同)。因此,如果一个 Windows 资源能被两个不同的进程访问,那么就应使用互斥体将其锁定。互斥体的缺点在于,针对核心的系统调用需要耗费大量处理资源。

当锁定严重争用时,互斥体成本也随之提高,即当多条线程都在等待它释放对某一资源的访问。具体的成本耗费请见表 1。我们可以这样总结:除非需要使用进程间的锁定,否则不应在 Windows 中应用互斥体。无论何时需要访问代码块或数据对象,都应当尽可能地使用关键段。

关键段与互斥体类似,但关键段仅运行于用户空间内。也就是说,在 Windows 中,关键段无需切换到核心模式。因此,关键段的数量级应快于互斥体,有时数量也要更多。但是,关键段将无法在进程间可见。在很多情况下,这个缺点并无影响。

但有些情况下,只需针对一个数值变量进行锁定。这时,虽然关键段的速度远远快于互斥体,但仍显过多。Windows 提供了一系列 API,可对变量值进行修改,就像发生在单一原子动作中一样。这可支持变量值得快速变化;数值更新时,Windows 将阻止其它线程访问变量。这些原子 API 或互锁函数是出色的性能选择。(如欲了解更多信息,请参阅“资源”段落以获得特定 API 的链接地址。)原子基元仅需关键段一半的时间,可谓最快的同步基元。

 

计时比较

Gerber 和 Binstock (请见“资源”段落)对 1000 万个长整数访问进行了计时,分别使用了互斥体、关键段和互锁。同时还在不采用任何锁定机制的条件下对整数访问进行了计时,以此作为参照。表 1 显示了来自双核英特尔® 至强® 处理器工作站的结果转化为毫秒。(该测试的代码位于http://pacificdataworks.com/pub/WindowsTimings.cpp

互斥体 39,880 ms.
关键段 1,889 ms.
互锁函数 944 ms.
无锁定 70 ms.

表 1. 无争用条件下的锁定机制计时

这组数字表明了互斥体的较高成本,以及关键段和互锁函数在 Windows 中的相对优势。但在实际情况中,由于上述测试是在无争用条件下进行的,因此这种差距甚至会更为明显。当锁定被多条线程争用时,运行 Windows 的不同 x86 平台将呈现表 2 中的测试结果。

争用条件下的关键段: 50 至 100 倍的互锁
争用条件下的互斥体: 100 倍以上的互锁

表 2. 冲突条件下:相对于互锁函数的性能表现

 

结果非常明确,也很惊人:尽可能使用互锁函数,并在其它情况下使用关键段,仅在需要用到进程间锁定时才考虑使用互斥体。但是,任何时候都要尽量避免在紧密循环中使用互锁。

 

写在最后

您的特定代码性能可能与本文中所描述的几种类别相差甚远。例如,微软已在 Vista* 操作系统中改变了锁定的分配方式。同以前的 Windows 系统相比,新的分配算法具有更强的性能导向性。因此,Windows 系统中的计时结果可能与 Vista 中的结果大相径庭。

线程性能对特定应用设计和相应平台十分敏感,因此,请务必亲自测试代码性能,以解决应用内的热点问题。在这些方面,英特尔 VTune 性能分析器和其它线程工具可提供特别的帮助。

表 1 中的测试结果显示,使用负载过重的锁定同样会导致严重的延迟问题。通过使用不同的锁定机制,选择最佳解决方案的工作也会变得更加轻松。这种方法也适用于算法设计:例如,为了充分发挥互锁函数的优势,您可以选择不同的数据结构或线程间通信机制。

任何时候,设计人员对同步基元成本的关注程度,都是和代码的执行速度成正比。

 

资源

Jeffrey Richter,《面向 Microsoft Windows 编程应用(Programming Applications for Microsoft Windows)》(第四版,ISBN 1-57231-996-8)被普遍认为是针对 Windows 线程化 API 的权威性介绍。从这里开始。

Richard Gerber 和 Binstock, Andrew,《使用超线程技术编程:如何为英特尔 IA-32 处理器编写多线程软件(Programming with Hyper-Threading Technology: How to Write Multithreaded Software for Intel IA-32 Processors)》,ISBN 0-9717861-4-3。本书详细介绍了超线程环境下的 Windows 和 Linux/Pthreads API,其针对线程化和同步基元的探讨同样也适用于多核环境。

MSDN,互锁变量访问,http://msdn2.microsoft.com/en-us/library/ms684122.aspx。针对 Windows API 中特定互锁函数的一篇全面概述,由微软技术人员撰写。

Srinivasan S. Muthuswamy 和 Varadarajan, Kavitha,《将 Windows IPC 应用移植到 Linux,第 3 部分:互斥体、关键段以及等待函数(Port Windows IPC apps to Linux, Part 3:Mutexes, critical sections, and wait functions)》。以下网址 http://www-128.ibm.com/developerworks/linux/library/l-ipc2lin3.html将 Windows 线程函数与 Linux 中的同类函数进行了详细对比。

作者

Andrew Binstock 现任 Pacific Data Works LLC 首席分析家,经常撰写软件开发问题方面的技术白皮书。您可以访问他的个人博客:http://binstock.blogspot.com

 

 

如需更全面地了解编译器优化,请参阅优化注意事项