这是因为 volitale 关键字保证了对该变量的读操作起码是在当前 CPU cache 中完成的(即:该变量不会被优化到寄存器中)。与此同时,对该变量的所有写操作都已保证原子地完成了所有 CPU 间的 cache 同步以及主存同步操作。所以读操作不管在主存还是当前系统中任意一个 CPU 的 cache 上发生,读到的都是一致的数据。
在这里,volitale 关键字配合原子量的写入操作一起实现了一个典型的读者/写者同步模型:所有写操作和 cache 同步都保证被原子、互斥地完成;读操作则可以被并发地实现。这也是 Windows API 中没有提供类似 'AtomicLoad' 式语义操作的原因。
当然,这里还有一个隐含的附加条件,就是 CPU 必须能够在一个操作中读入这个原子量。这个条件通常可以忽略,因为超出 CPU 位宽的数据类型通常无法被实现成原子量类型。
内存屏障和 Acquire、Release 语义
内存屏障(Memory barrier, membar) 用于消除 CPU 乱序执行优化对内存访问顺序的影响,通常用于保证多个变量交叉访问的逻辑顺序。内存屏障分为以下几种:
全屏障语义:全屏障逻辑上的操作序列为:'操作&双向同步',即:所有在该操作执行前的内存访问指令不得乱序调换到该指令后执行;所有在该操作执行后的内存访问指令也不得被乱序调换到该指令完成前执行。全屏障语义存在两种变体:读屏障和写屏障。这两种变体都是双向屏障,只不过读屏障仅限制 load 操作的执行顺序,而对于 store 操作的顺序则不做任何限制。相反,写屏障则仅限制 store 操作的执行顺序。
全屏障语义适用范围最广,任何使用其它三种操作的场合都可以安全替换为全屏障操作。但是由于最大限度的禁用了处理器的乱序执行能力,全屏障语义也是效率最低的操作。Acquire 操作保证后续内存访问一定在当前操作完毕后进行,主要用于实现互斥量、信号量的上锁操作。Release 保证前导内存访问一定在操作开始前执行完毕。主要用于实现互斥量、信号量的解锁操作。无屏蔽语义完全不禁用 CPU 的乱序执行优化,效率最高,可广泛适用于简单的引用计数等操作。
在由互斥量、信号量和条件变量等互斥手段保护的临界区内访问的数据,通常不需要添加 volitale 声明或其它任何特殊操作就可以保证在不同 CPU 间的一致性。这是因为:互斥量等任何可同步对象的上锁和解锁都需要使用非只读的原子量操作来实现。而所有非只读原子量操作都隐含了一个 CPU cache 同步。所以临界区中的数据总是能够保持多 CPU 间的一致性。
至于寄存器优化的问题,由于临界区是互斥进入的,所以在临界区内对读操作进行的寄存器优化不会产生一致性问题。而对于写操作,除非错误的使用了同步算法,否则在出临界区前所有可能被其他线程访问的对象一定会被从寄存器写回内存或 cache 中。所谓“可能被其他线程访问的对象”是指所有不在当前线程运行时栈上创建的对象(即:所有非 auto 型对象)。
无法解决多重依赖关系,例如:全局对象 A 初始化时依赖在另一个编译单元中定义的全局量 B,而 B 在初始化时又依赖了全局量 C。那么这种技巧只能保证初始化顺序部分正确,例如:可以保证 B 在 A 之前初始化,但无法保证 C 在 B 之前初始化。因为这一技巧实际上打乱了 CRT 对不同编译单元的初始化顺序。因此在使用此技巧强制初始化 B 时,在 B 之前定义(同一编译单元内的)全局量可能尚未被初始化。
此外,使用字面常量初始化一个本地静态 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
对于 POD 类型,一种更好的解决方法是:充分利用操作系统加载进程时,对数据段做全零初始化的特性:C++ 标准中明确规定了,所有静态成员(即进程数据段)在进程加载时都必须 "zero-initialized"。由于数据段清零动作是在操作系统加载进程映像时就完成的,此时连主线程都还没有被创建,任何用户代码都没有开始执行,所以不存在多线程安全性问题。例如:
int func(void) { static int s_nMyVar; // s_nMyVar 在进程映像加载时已被初始化为 0, 编译器 // 不需要为其生成任何封装代码和标志变量 // ... return s_nMyVar; }