用户代码: void func(void) { static int s_nMyVar = MyCalcAlgorithm(); // ... }
编译器生成的代码: void func(void) { static int bCompilerInitFlag; // 初始化标记 static int s_nMyVar; if (FALSE == bCompilerInitFlag) { bCompilerInitFlag = TRUE; s_nMyVar = MyCalcAlgorithm(); } // ... } |
由以上例子可知,如果 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 类型,问题则更加复杂:
用户代码: void func(void) { static CMyClass s_iMyObj; // ... }
编译器生成的代码: void DestroyMyObj(void) // 调用析构函数 { `void func(void)::'s_iMyObj.~CMyClass(); }
void func(void) { static int bCompilerInitFlag; // 初始化标记 static CMyClass s_iMyObj; if (FALSE == bCompilerInitFlag) { bCompilerInitFlag = TRUE; s_iMyObj.CMyClass(&s_iMyObj); // 调用构造函数 atexit(&DestroyMyObj); // 在退出进程时调用析构函数 } // ... } |
可以看出,此时除了前文提到的各种竞态条件外,还可能出现 s_iMyObj 的构造函数和析构函数被多次调用的问题。
如果并发性对程序不重要,我们就可以使用一种简单的方式来保证多线程安全性:
void func(void) { CFastSessionLock flk(fmxLock); // 每次访问函数都上锁
static int s_nMyVar1 = MyCalcAlgorithm1(); static int s_nMyVar2 = MyCalcAlgorithm2(); // ... } |
其中 fmxLock 可以看做是一个互斥量对象,而 CFastSessionLock 则是一个满足 RAII 语义的 Sentry 类(在构造时加锁,析构时解锁)。 或者,如果初始化不必非要延迟到用户第一次调用此函数时才进行。我们就可以直接将局部静态变量定义成全局变量(静态或非静态均可)。如果需要控制变量的作用域,可以使用一个全局哨位来完成进程启动时的初始化动作:
namespace { class CLocalStaticInitializer { public: CLocalStaticInitializer() { func(); // 确保在进程启动时完成 func 函数中本地静态变量的初始化 } }; const CLocalStaticInitializer sg_initLocalStatics; } |
前文已经提过,与局部静态变量不同,C++ 保证全局变量在进程启动的时候被依次(按照编译单元内的定义顺序)地初始化。
此外,使用字面常量初始化一个本地静态 POD 数据是线程安全的。实际上,这类初始化并不是在程序第一次执行到该变量所在语句块时才进行的,而是在程序启动时就直接从映像文件内的数据段中加载了。但对于一个非 POD 对象,无论是否使用编译时已知的常量对其进行初始化,编译器都需要为其生成调用构造、析构函数的代码和初始化标志变量。例如:
void func(void) { static int s_nMyVar1 = 15; // OK static int s_nMyVar2 = MyCalcAlgorithm(); // 不行,不是编译时已知的常量
static BYTE s_gbMyArray[3] = {1, 2, 3}; // OK
struct MY_POD_TYPE { int a, b; const char* c; }; static MY_POD_TYPE iMyPodData = { 10, 20, "30" }; // OK
static std::string s_str = "123"; // 不行!即使使用字面常量初始化一个非 POD 变 // 量, 编译器仍然需要生成初始化代码和相应标 // 志变量 // ... } |
对于 POD 类型,一种更好的解决方法是:充分利用操作系统加载进程时,对数据段做全零初始化的特性:C++ 标准中明确规定了,所有静态成员(即进程数据段)在进程加载时都必须 "zero-initialized"。由于数据段清零动作是在操作系统加载进程映像时就完成的,此时连主线程都还没有被创建,任何用户代码都没有开始执行,所以不存在多线程安全性问题。例如:
int func(void) { static int s_nMyVar; // s_nMyVar 在进程映像加载时已被初始化为 0, 编译器 // 不需要为其生成任何封装代码和标志变量 // ... return s_nMyVar; } |
实际上,前文提到的,编译器自动生成的 "bCompilerInitFlag" 标记变量就是利用这个特性来完成初始化的。
利用进程数据段在映像加载时清零的特性,配合使用一个互斥量,我们就可以在几乎不损失并发性的前提下保证任意本地静态 POD 变量初始化时的线程安全性:
int func(void) { static int s_nMyVar; if (0 == s_nMyVar) { CFastSessionLock flk(sg_fmxLocalStaticVarInit); // 上互斥锁
if (0 == s_nMyVar) // 判断在等待互斥锁时,其他线程是否已完成初始化 { s_nMyVar = MyCalcAlgorithm(); } } return s_nMyVar; } |
以上例子保证了 s_nMyVar 初始化时的多线程安全性,同时只在 s_nMyVar 尚未完整初始化时发生了并发调用才会上互斥锁,最大限度地保证了并发效率。
非 POD 类型的情况则相对复杂。 因为编译器总是会生成调用构造函数并将析构函数压入进程退出节(atexit)的代码,所以想要在维持并发性的同时保证其初始化时的线程安全性,这个非 POD 对象就必须满足以下条件:
-
能够利用进程加载时的数据段清零特性(即:这个类和他的所有基类都能够正确地识别和处理所有数据成员均为全 0 值时的情形);
-
保证其构造函数被编译器生成的初始化代码并发地重复调用时不会产生任何副作用(通常需要依赖第一条实现);
-
保证其析构函数在进程退出节被序列地(非并发)重复调用时不会产生任何副作用;
-
其初始化动作相对于检测初始化是否完成的操作来说,是一个原子操作。意即:在下例中的第一个 if 语句返回 false 时,s_thMyVar 必须保证已经初始化完毕,而不能是正初始化到一半的状态。
遗憾的是,大部分有意义的类都无法同时满足以上 4 个条件。为此,我们可以使用一个句柄类来进行辅助,例如:
const CMyClass& func(void) { typedef CTmpHandle< CMyClass > THMYCLASS;
static THMYCLASS s_thMyObj(DontInit); // DontInit 占位符:不初始化成员 if (!s_thMyObj) { CFastSessionLock flk(sg_fmxLocalStaticVarInit); // 上互斥锁
if (!s_thMyVar) // 判断在等待互斥锁时,其他线程是否已完成初始化 { s_thMyObj = new CMyClass; } } return *s_thMyObj; } |
在这里可以简单的认为 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' 完全等效。所以上例也满足以上第四条所描述的“其初始化动作相对于检测初始化是否完成的操作来说,是一个原子操作”。应当注意到,此处的“原子操作”与本文第一节所描述的硬件级别的并不是一个概念。