上一讲我们讨论了一些并发编程的基本概念,今天我们来讨论一个略有点绕的问题,C++ 里的内存模型和原子量。
C++98 的执行顺序问题
C++98 的年代里,开发者们已经了解了线程的概念,但 C++ 的标准里则完全没有提到线程。从实践上,估计大家觉得不提线程,C++ 也一样能实现多线程的应用程序吧。不过,很多聪明人都忽略了,下面的事实可能会产生不符合直觉预期的结果:
为了优化的必要,编译器是可以调整代码的执行顺序的。唯一的要求是,程序的“可观测”外部行为是一致的。
处理器也会对代码的执行顺序进行调整(所谓的 CPU 乱序执行)。在单处理器的情况下,这种乱序无法被程序观察到;但在多处理器的情况下,在另外一个处理器上运行的另一个线程就可能会察觉到这种不同顺序的后果了。
对于上面的后一点,大部分开发者并没有意识到。原因有好几个方面:
多处理器的系统在那时还不常见
主流的 x86 体系架构仍保持着较严格的内存访问顺序
只有在数据竞争(data race)激烈的情况下才能看到“意外”的后果
举一个例子,假设我们有两个全局变量:
int x = 0;
int y = 0;
然后我们在一个线程里执行:
x = 1;
y = 2;
在另一个线程里执行:
if (y == 2) {
x = 3;
y = 4;
}
想一下,你认为上面的代码运行完之后,x、y 的数值有几种可能?
你如果认为有两种可能,1、2 和 3、4 的话,那说明你是按典型程序员的思维模式看问题的——没有像编译器和处理器一样处理问题。事实上,1、4 也是一种结果的可能。有两个基本的原因可以造成这一后果:
编译器没有义务一定按代码里给出的顺序产生代码。事实上,跟据上下文调整代码的执行顺序,使其最有利于处理器的架构,是优化中很重要的一步。就单个线程而言,先执行 x = 1 还是先执行 y = 2 完全是件无关紧要的事:它们没有外部“可观察”的区别。
在多处理器架构中,各个处理器可能存在缓存不一致性问题。取决于具体的处理器类型、缓存策略和变量地址,对变量 y 的写入有可能先反映到主内存中去。之所以这个问题似乎并不常见,是因为常见的 x86 和 x86-64 处理器是在顺序执行方面做得最保守的——大部分其他处理器,如 ARM、DEC Alpha、PA-RISC、IBM Power、IBM z/ 架构和 Intel Itanium 在内存序问题上都比较“松散”。x86 使用的内存模型基本提供了顺序一致性(sequential consistency);相对的,ARM 使用的内存模型就只是松散一致性(relaxed consistency)。较为严格的描述,请查看参考资料 [1] 和里面提供的进一步资料。
虽说 Intel 架构处理器的顺序一致性比较好,但在多处理器(包括多核)的情况下仍然能够出现写读序列变成读写序列的情况,产生意料之外的后果。参考资料 [2] 中提供了完整的例子,包括示例代码。对于缓存不一致性问题的一般中文介绍,可以查看参考资料 [3]。
双重检查锁定
在多线程可能对同一个单件进行初始化的情况下,有一个双重检查锁定的技巧,可基本示意如下:
// 头文件
class singleton {
public:
static singleton* instance();
…
private:
static singleton* inst_ptr_;
};
// 实现文件
singleton* singleton::inst_ptr_ =
nullptr;
singleton* singleton::instance()
{
if (inst_ptr_ == nullptr) {
lock_guard lock; // 加锁
if (inst_ptr_ == nullptr) {
inst_ptr_ = new singleton();
}
}
return inst_ptr_;
}
这个代码的目的是消除大部分执行路径上的加锁开销。原本的意图是: