多处理器环境和线程同步的高级话题
本文主要阐述与 C/C++ 语言相关性比较紧密的,SMP 环境下的多线程同步问题。之所以称之为《高级话题》是因为本文预期的读者已经具备了信号量、互斥量、条件变量、原子操作和竞态条件等方面的背景知识。我们将以此为基础开始讨论三个相对高级一点的问题:
原子操作和 volatile 关键字全局对象初始化时的线程安全性和相互依赖性问题局部静态对象初始化时的线程安全性问题原子操作和 volatile 关键字
编译器仅保证在生成目标码时不调整 volatile 变量的访问顺序,但通常并不保证该变量不受处理器的 out-of-order 特性影响。目前唯一一个已知的特例是安腾(IA64)处理器版的 VC:在生成 IA64 Target 时,VC 会自动在所有 volatile 访问之间添加内存屏障(详见下文)以保证访问顺序。但 ISO 标准并未要求编译器实现类似机制。实际上,其它编译器(或是面向其它平台的 VC)也都没有类似保证。也就是说,通常认为 volatile 并不保证代码在处理器上的执行顺序,如果需要类似的保证,程序员应当自己使用内存屏障操作。
可见,使用 volitale 关键字并不足以保证操作的原子语义。volitale 关键字的主要设计目的是支持 C/C++ 程序与内存映射设备间的通信。但这并不是说 volitale 关键字对原子操作没有任何帮助:
对于一个被声明为 volitale 类型的原子量来说,如果所有写操作都是原子的,并且完成了必要的 cache 同步,那么读取该原子量时就不必再次对总线上锁。
这是因为 volitale 关键字保证了对该变量的读操作起码是在当前 CPU cache 中完成的(即:该变量不会被优化到寄存器中)。与此同时,对该变量的所有写操作都已保证原子地完成了所有 CPU 间的 cache 同步以及主存同步操作。所以读操作不管在主存还是当前系统中任意一个 CPU 的 cache 上发生,读到的都是一致的数据。
在这里,volitale 关键字配合原子量的写入操作一起实现了一个典型的读者/写者同步模型:所有写操作和 cache 同步都保证被原子、互斥地完成;读操作则可以被并发地实现。这也是 Windows API 中没有提供类似 'AtomicLoad' 式语义操作的原因。
当然,这里还有一个隐含的附加条件,就是 CPU 必须能够在一个操作中读入这个原子量。这个条件通常可以忽略,因为超出 CPU 位宽的数据类型通常无法被实现成原子量类型。
全屏障语义适用范围最广,任何使用其它三种操作的场合都可以安全替换为全屏障操作。但是由于最大限度的禁用了处理器的乱序执行能力,全屏障语义也是效率最低的操作。Acquire 操作保证后续内存访问一定在当前操作完毕后进行,主要用于实现互斥量、信号量的上锁操作。Release 保证前导内存访问一定在操作开始前执行完毕。主要用于实现互斥量、信号量的解锁操作。无屏蔽语义完全不禁用 CPU 的乱序执行优化,效率最高,可广泛适用于简单的引用计数等操作。
参考资料:
http://en.wikipedia.org/wiki/Memory_barrierhttp://en.wikipedia.org/wiki/Atomic_operationhttp://msdn.microsoft.com/en-us/library/aa490209.aspxhttp://www.niallryan.com/node/138http://blogs.msdn.com/kangsu/archive/2007/07/16/volatile-acquire-release-memory-fences-and-vc2005.aspxhttp://netbsd.gw.com/cgi-bin/man-cgi?mb++NetBSD-currenthttp://www.freebsd.org/cgi/man.cgi?query=atomic&apropos=0&sektion=0&manpath=FreeBSD+7.2-RELEASE&format=html顺带提一下多处理器环境中的临界区内数据可见性问题:
在由互斥量、信号量和条件变量等互斥手段保护的临界区内访问的数据,通常不需要添加 volitale 声明或其它任何特殊操作就可以保证在不同 CPU 间的一致性。这是因为:互斥量等任何可同步对象的上锁和解锁都需要使用非只读的原子量操作来实现。而所有非只读原子量操作都隐含了一个 CPU cache 同步。所以临界区中的数据总是能够保持多 CPU 间的一致性。至于寄存器优化的问题,由于临界区是互斥进入的,所以在临界区内对读操作进行的寄存器优化不会产生一致性问题。而对于写操作,除非错误的使用了同步算法,否则在出临界区前所有可能被其他线程访问的对象一定会被从寄存器写回内存或 cache 中。所谓“可能被其他线程访问的对象”是指所有不在当前线程运行时栈上创建的对象(即:所有非 auto 型对象)。
全局对象初始化时的线程安全性和相互依赖性问题
但如果用户定义了某些在构造时会创建和运行新线程的全局对象,那么这样做也是不安全的。无论如何,在全局量构造时创建新线程是一个很不好的编程习惯。因为此时用户代码和第三方库中可能还有很多全局变量没有或正在被初始化(C++ 不保证不同编译单元内的全局量初始化顺序),新线程很可能会直接或间接地访问到未初始化或未完全初始化的全局变量。
想要确保在不同编译单元内定义的全局量,以预期的顺序初始化是一件麻烦的事情。有 VC、SUN CC 等编译器提供了 init segment 预编译选项的支持,此时在优先级较高的段内定义的全局量会比其他全局量先初始化。但是 init segment 仅提供了一种初始化顺序的粗略区分方式。而且 GCC 等很多编译器尚不支持该选项。
如果想要更精细地控制全局量的依赖关系,或是需要在不支持 init segment 概念的编译环境中控制全局量初始化顺序,就需要使用一些特别的技巧。比如:利用在同一编译单元内定义的全局量总是按照其定义顺序初始化的行为。我们可以在声明了某个全局量 'G' 的头文件 "G.h" 中定义一个静态全局量 'S' (使用 static 关键字或无名空间)。这样就为每个包含了 "G.h" 的编译单元都定义了一个静态全局量 'S'。由于 'G' 在 #include "G.h" 之后才可见,所以所有对全局量 'G' 的访问都发生在 'S' 被定义之后。也就是说,静态全局量 'S' 一定会在 'G' 被访问前构造。现在只要在 'S' 的构造函数中显式地完成对 'G' 的初始化即可确保初始化顺序间的依赖性关系被正确地建立。
更完善一些的 'S' 应该在其构造函数中维护一个整形静态变量作为引用计数,确保仅在第一个 'S' 对象被构造时完成初始化就可以了。
不过,使用这种技巧时,仍然需要注意以下几个问题:
会为每一个包含了 "G.h" 的编译单元增加一个静态对象 'S',虽然 'S' 通常不会有任何数据成员,但也在一定程度上增加了进程加载时的开销。特别是在资源受限的环境(如:嵌入式应用)或编译单元很多的场合。局部静态对象初始化时的线程安全性问题
由以上例子可知,如果 func 需要工作在多线程并发的环境中,则可能会产生以下几种竞态条件:
使用未被初始化的 s_nMyVar(线程 A 执行 bCompilerInitFlag = TRUE,但调用 MyCalcAlgorithm 尚未返回;此时线程 B 判断 FALSE == bCompilerInitFlag 为 false 所以认为 s_nMyVar 已初始化完毕)。
s_nMyVar 被重复初始化(线程 B 在线程 A 执行 bCompilerInitFlag = TRUE 前进行了 FALSE == bCompilerInitFlag 判断)。
如果 s_nMyVar 的初始化行为不是一个原子操作(例如,s_nMyVar 是一个结构体),还有可能出现未完全初始化的 s_nMyVar 被使用的情况(与第一条类似)。
对于非 POD 类型,问题则更加复杂:
用户代码:可以看出,此时除了前文提到的各种竞态条件外,还可能出现 s_iMyObj 的构造函数和析构函数被多次调用的问题。
如果并发性对程序不重要,我们就可以使用一种简单的方式来保证多线程安全性:
void func(void)其中 fmxLock 可以看做是一个互斥量对象,而 CFastSessionLock 则是一个满足 RAII 语义的 Sentry 类(在构造时加锁,析构时解锁)。 或者,如果初始化不必非要延迟到用户第一次调用此函数时才进行。我们就可以直接将局部静态变量定义成全局变量(静态或非静态均可)。如果需要控制变量的作用域,可以使用一个全局哨位来完成进程启动时的初始化动作:
namespace {前文已经提过,与局部静态变量不同,C++ 保证全局变量在进程启动的时候被依次(按照编译单元内的定义顺序)地初始化。
此外,使用字面常量初始化一个本地静态 POD 数据是线程安全的。实际上,这类初始化并不是在程序第一次执行到该变量所在语句块时才进行的,而是在程序启动时就直接从映像文件内的数据段中加载了。但对于一个非 POD 对象,无论是否使用编译时已知的常量对其进行初始化,编译器都需要为其生成调用构造、析构函数的代码和初始化标志变量。例如:
void func(void)对于 POD 类型,一种更好的解决方法是:充分利用操作系统加载进程时,对数据段做全零初始化的特性:C++ 标准中明确规定了,所有静态成员(即进程数据段)在进程加载时都必须 "zero-initialized"。由于数据段清零动作是在操作系统加载进程映像时就完成的,此时连主线程都还没有被创建,任何用户代码都没有开始执行,所以不存在多线程安全性问题。例如:
int func(void)实际上,前文提到的,编译器自动生成的 "bCompilerInitFlag" 标记变量就是利用这个特性来完成初始化的。
利用进程数据段在映像加载时清零的特性,配合使用一个互斥量,我们就可以在几乎不损失并发性的前提下保证任意本地静态 POD 变量初始化时的线程安全性:
int func(void)以上例子保证了 s_nMyVar 初始化时的多线程安全性,同时只在 s_nMyVar 尚未完整初始化时发生了并发调用才会上互斥锁,最大限度地保证了并发效率。
非 POD 类型的情况则相对复杂。 因为编译器总是会生成调用构造函数并将析构函数压入进程退出节(atexit)的代码,所以想要在维持并发性的同时保证其初始化时的线程安全性,这个非 POD 对象就必须满足以下条件:
能够利用进程加载时的数据段清零特性(即:这个类和他的所有基类都能够正确地识别和处理所有数据成员均为全 0 值时的情形);
保证其构造函数被编译器生成的初始化代码并发地重复调用时不会产生任何副作用(通常需要依赖第一条实现);
保证其析构函数在进程退出节被序列地(非并发)重复调用时不会产生任何副作用;
其初始化动作相对于检测初始化是否完成的操作来说,是一个原子操作。意即:在下例中的第一个 if 语句返回 false 时,s_thMyVar 必须保证已经初始化完毕,而不能是正初始化到一半的状态。
遗憾的是,大部分有意义的类都无法同时满足以上 4 个条件。为此,我们可以使用一个句柄类来进行辅助,例如:
const CMyClass& func(void)在这里可以简单的认为 CTmpHandle 是一个类似 "std::auto_ptr" 的智能指针模板类,它有一个指针型成员 'ptr'。"DontInit" 占位符表示构造时不做任何动作。这样,构造函数的多次并发调用仅仅相当于调用了空函数,不会产生任何不良影响。而 CTmpHandle 的析构函数看起来像这样: "delete ptr; ptr = NULL;" (为了突出主题,这里没有忽略了 delete 时析构函数抛出异常的情况)。销毁后将 'ptr' 置为 'NULL' 保证了在程序退出节序列地多次调用析构函数不会产生任何副作用。而真正的初始化代码则被一个互斥锁保护,由于只在 s_thMyObj 尚未完整初始化时发生了并发调用才会上互斥锁,所以最大限度地保证了并发效率。
顺便提一下,上例中的 's_thMyObj = new CMyClass;' 语句其实就是一个指针赋值操作('s_thMyObj.ptr = new CMyClass'),而语句 '!s_thMyObj' 则与 'NULL != s_thMyObj.ptr' 完全等效。所以上例也满足以上第四条所描述的“其初始化动作相对于检测初始化是否完成的操作来说,是一个原子操作”。应当注意到,此处的“原子操作”与本文第一节所描述的硬件级别的并不是一个概念。