译注
从某个文件夹里翻出这篇文章,感觉很好,尝试翻译一下。
译者:LittleFall
原文:https://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf
20200202 update:翻译不完啦,先发出来。
C++双重检查锁的风险
Scott Meyers / Andrei Alexandrescu
2004年九月
多线程只是另一件事情之前、之后、或同时发生的一件糟糕的事情。
1. 引入
从新闻组或网页上搜索不同设计模式的名字,你肯定会发现单例模式(Singleton)是最常提及的一种。然而尝试把单例模式用到实践中时,你肯定会在一个重要的限制面前卡住:在传统的实现(下方会说明)中,单例模式不是线程安全的。
为了解决这个缺点,人们付出了很多努力。最受欢迎的方法之一本身就是一种设计模式:双重检查锁模式(Double-Checked Locking Pattern,DCLP)。DCLP被设计用来为初始化共享资源(比如单例模式)添加线程安全性,但是它有一个问题:并不可靠。此外,几乎没有方便的方法能让它在C或C++中变得可靠,同时不修改常规的模式实现。让事情变得更有趣的是,DCLP会以各种各样的的原因失效,不管是在单处理器还是多处理器架构中。
这篇文章解释了为什么单例模式不是线程安全的、DCLP如何努力去解决这个问题、为什么DCLP不管在单处理器还是多处理器架构中都可能失效、还有为什么你不能(方便地)去用它解决各种问题。在这个过程中,我们会阐明源代码中语句顺序之间的关系、顺序点、编译器和硬件的优化、以及语句的实际执行顺序。最后,我们会以一些建议作为总结:如何为单例模式(以及类似结构)添加线程安全性,这样代码会变得既可靠又高效。
2. 单例模式与多线程
单例模式传统的实现是这样的,在对象第一次被请求时创建一个指针指向这个对象:
// 头文件
class Singleton{
public:
static Singleton* instance();
private:
static Singleton* pInstance;
};
// 实现
Singleton* Singleton::pInstance = 0;
Singleton* Singleton::instance(){
if(pInstance == 0){
pInstance = new Singleton;
}
return pInstance;
}
在单线程环境中,这种写法通常工作地很好,然而中断可能会有问题。如果你在函数Singleton::instance
中,接受到了一个中断,然后从处理程序中调用Singleton::instance
,你就会知道你怎样陷入到了问题之中。然而除了中断,这个实现会在单线程环境中工作地很好。
不幸的是,这种实现在多线程环境下是不可靠的。假设线程A进入了instance
函数,执行到了if
处,然后被挂起。在它被挂起的之前,它刚刚判断认为pInstance
是null
,也就是说还没有Singleton
对象被创建。
线程B现在进入到instance
中同样执行到if
处。它发现pInstance
是null
,所以创建了一个Singleton
对象,由pInstance
指向。它将pInstacne
返回给isntance
函数的调用者。
一段时间之后,线程A被允许继续执行,它要做的第一件事情就是进入if语句体中,创建了另一个Singleton
对象然后让pInstance
指向它。显然这违反了单例模式的原则,因为现在有了两个Singleton
对象。
技术上,pInstance
在语句Singleton* Singleton::pInstance = 0;
中就被初始化,但是以实际考虑,在if语句体中才让它指向我们所需要的对象。所以在本文的剩余部分中,我们认为pInstance
在if体中才被初始化。
使得单例模式的经典实现具有线程安全性是很简单的。只需要在测试pInstance
之前加锁:
Singleton* Singleton::instance(){
Lock lock; // 加锁(为了方便表示,省略了参数)
if(pInstance == 0){
pInstance = new Singleton;
}
return pInstance;
} // 解锁(Lock析构)
这种解决方案的缺点是它的代价可能会很昂贵。每次对Singleton
的访问权限需要取得锁,但实际上,我们只需要在初始化pInstace
时加锁。那应该发生在instance
第一次被调用时。如果在整个程序执行过程中instance
被调用了n次,我们只在首次调用时需要锁。当你已经知道它们中的n-1次不必要时,为什么还要付出n次取得锁的时间花费?DCLP旨在防止你必须这样做。
3. 双重检查锁模式(DCLP)
DCLP的关键在于,大多数对instance
的调用会看到pInstace
是非空的,因此甚至不用尝试去初始化它。因此,DCLP
在尝试获取锁之前检查pInstance
是否为空。只有当检查成功(也就是pInstance
还没有被初始化)时才会去获得锁,然后再次检查pInstance
是否仍然为空(因此命名为双重检查锁)。第二次检查是必要,因为就像我们刚刚看到的,很有可能另一个线程偶然在第一次检查之后,获得锁成功之前初始化pInstance
。
这里是经典的DCLP实现:
Singleton* Singleton::instance() {
if(pInstance == 0) { // 第一次检查
Lock lock;
if(pInstance == 0){ // 第二次检查
pInstance = new Singleton;
}
}
return pInstance;
}
定义DCLP的论文讨论了一些实现问题(例如volatile修饰的单例模式指针、多处理器系统上分离缓存的影响,这两者我们都会在下方提及;还有一些确保某些读写操作的原子性的需求,我们不会在这篇文章中提及),但是他们在考虑一个更基础的问题上失败了:在DCLP执行期间确保以一个可接受的顺序执行机器指令。这就是我们在这里终点关注的基础问题。
4. DCLP与指令顺序
再次考虑初始化pInstance
的那一行:
pInstance = new Singleton;
这条语句会导致三个事情的发生:
- 分配能够存储
Singleton
对象的内存; - 在被分配的内存中构造一个
Singleton
对象; - 让
pInstance
指向这块被分配的内存。
有一个至关重要的事实是,编译器并没有被约束按上面的顺序去执行这些步骤。实践中,编译器有时会交换第二步和第三步。这样做的原因我们会在稍后提及。现在,让我们关注于如果这样做了会发生什么。
考虑下面的代码,我们把pInstance
初始化那一行扩展成了在上面提到的三个内部任务,然后把第一步(内存分配)和第三步(pInstance
赋值)放在一个语句中,在第二步(Singleton
构造)之前。这个想法并不是说人类会写出这样的代码,而是说编译器可能会生成与之等效的代码,来响应人类编写的常规DCLP代码(如上所示)。
Singleton* Singleton::instance() {
if(pInstance == 0) {
Lock lock;
if(pInstance == 0){
pInstance = //第三步
operator new(sizeof(Singleton)); //第一步
new (pInstance) Singleton; //第二步
}
}
return pInstance;
}
通常,这并不是一个从DCLP源码的合法翻译,因为在二步中调用的Singleton
构造器可能会抛出一个异常,而且如果一个异常被抛出,pInstance
还没有被更改是很重要的。这也就是为什么在实际上,编译器不会把第三步移到第二步的上面。然而在某些条件下,这种转变是合法的。或许最简单的这样的条件是,当编译器能够证明Singleton
构造器不会抛出(比如通过内联后流量分析),但这并不是唯一的条件。一些会抛出异常的构造函数也可以进行指令重排,这样这个问题就又出现了。
根据上面的翻译代码,考虑下面的事件序列。
- 线程A进入
instance
,执行第一次pInstance
的测试,需求锁,执行由步骤1和3组成的语句,然后被挂起。此时pInstance
是非空的,但是pInstance
指向的内存中还没有Singleton
对象被构造。 - 线程B进入
instance
,判定pInstance
非空, 将其返回给instance
的调用者。调用者对指针解引用以获得Singleton
,噢,一个还没有被构造出的对象。
DCLP能够良好的工作仅当步骤一和二在步骤三之前被执行,但是并没有并没有方法在C或C++中表达这种限制。这就像是插在DCLP心脏上的一把匕首:我们需要在相对指令顺序上定义限制,但是我们的语言没有给出表达这种限制的方法。
是的,C和C++标准定义了顺序点(sequence points),它定义了执行顺序的约束。比如,C++标准在1.9节的第7段令人鼓舞地指出:
在执行序列中的一些特定点称为顺序点,之前评估的所有副作用应该被执行完毕,并且不应该有任何之后评估的副作用已经产生。
更进一步,两个标准都声明了顺序点出现在每个语句的末尾。所以似乎如果你只关心语句的执行顺序,那么不会有什么问题。
噢,奥德修斯,不要让自己被塞壬的声音吸引,还有很多麻烦在等着你和你的伙伴!(译注:希腊神话中,塞壬用自己的歌喉使得过往的水手倾听失神,航船触礁沉没。)
两个标准都根据抽象计算机的可观测行为定义了正确的程序行为。但这台机器并不是所有行为都是可观测的。考虑这个例子:
void Foo() {
int x = 0, y = 0; // 语句1
x = 5; // 语句2
y = 10; // 语句3
printf("%d, %d", x, y); // 语句4
}
这个函数看起来很愚蠢,但它可能是Foo
调用的一些其它函数内联后的结果。
在C和C++中,标准保证Foo
会打印出5, 10
,所以我们知道会发生什么。但这和我们被保证的程度有关,所以我们知道。而我们不知道语句1-3到底是否会被执行,实际上一个好的优化器将会避免执行他们。如果语句1-3被执行了,我们知道语句1将会先于语句2-4,如果假定printf
的调用没有被内联,而且结果被进一步优化了,我们知道语句4将会跟在语句1-3后面,但我们关于语句2,3的执行顺序不知道任何东西。编译器可能会选择先执行语句2、先执行语句3、或者并行同时执行(假定硬件可以做到),都有可能。现代处理器具有大的字长和几个执行单元,两个或多个算数单元是常见的(举例来说,奔腾4有3个整数ALU,PowerPC的G4e有4个,而Itaninum有6个)。它们的机器语言允许编译器生成并行代码,使得两个或多个指令在同一个时钟周期内被执行。
优化良好的编译器仔细地分析和重排你的代码,使得能够同时做尽可能多地事情(在可观测行为的约束之内)。在常规串行代码中发现并引入这种并行性是重排代码与引入乱序执行的最重要原因。但并不是只有这一个原因,编译器(和链接器)可能会重排代码,以避免寄存器数据溢出、保证指令总线满载、执行公共子表达式删除、减小生成的可执行文件的大小。
当执行这些种类的优化时,C与C++的编译器和链接器被约束于语言标准定义的抽象机上的可观测行为。而且有很重要的一点是,这些抽象机隐式地是单线程的。C和C++语言中都没有线程概念,所以编译器不用担心在做优化时打破线程程序。因此它们有时做的事情会让你感到惊讶。
在这种情况下,如何编写C和C++能够真正执行的多线程程序?答案是通过使用为了这个目的而定义的特定的系统库。类似于Posix线程
(pthreads
)的库给出了各种同步原语执行语义的精确定义。这些库给适用于库的编译器被允许生成的代码中施加了限制,因此强制这些编译器生成遵守被库所依赖的执行顺序约束的代码。这就是为什么线程包有一些部分是使用汇编语言写的,或者发出的系统调用本身就是用汇编写的(或者用一些不可移植的语言):你必须走到C与C++标准之外才能表达多线程程序所需要的顺序约束。DCLP尝试只使用语言标准去构建,这就是为什么DCLP是不可靠的。
通常,编程者不喜欢被编译器所困扰。如果你正是这样的编程者,那么你可能很想通过调整你的源代码去启发你的编译器使得pInstance
保持不变直到Singleton
的构造函数结束。比如说,你可能会尝试插入临时变量:
Singleton* Singleton::instance() {
if(pInstance == 0) {
Lock lock;
if(pInstance == 0){
Singleton *temp = new Singleton; //初始化temp
pInstance = temp; //把temp复制给pInstance
}
}
return pInstance;
}
本质上,你已经在优化大战中开场白给了。你的编译器想要去优化,你不想让它优化,至少在这里不想。但这场战争你不会想进入的。你的敌人狡猾又缜密,充满了那些整天、日复一日、年复一年只想着这类事情的人几十年来想出的计谋。如果你不自己写编译器优化,它们就会远远把你甩在身后。例如在这种情况下,编译器很容易应用依赖性分析来确定temp
是一个不必要的变量然后去消除它,这样处理你精心写出的“不可优化的”代码(如果它已经被按传统的DCLP的方式写出来了)。游戏结束,你输了。
如果你更狠一点,把temp
移动到更大的范围中(比如让它称为static
变量),编译器仍然能执行一样的分析,得到同样的结论。范围?批围!游戏结束,你又输了。
所以你需要支援。你声明了temp
为extern
变量,将它定义在分离的翻译单元中,阻止你的编译器看到你在干什么。真是为你遗憾啊,一些编译器拥有像夜视镜一样的优化能力:他们执行程序间的分析,发现你使用temp
的诡计,再次优化了它的存在。记住,它们是优化编译器。它们应该追踪并消除不必要的代码。游戏结束,你又输了。
所以你尝试通过在不同文件中定义辅助函数禁止内联,因此强制编译器认为构造函数可能会产生异常,从而延迟pInstance
的赋值。想得美,但一些构建环境会执行链接时内联,随后是更多的代码优化[5, 11, 4]。游戏结束,你又输了。
你所做的都没有解决这个最根本的问题:你有指定指令顺序约束的需求,而你的语言没有给你方法。
5. 几近成名:volatile
关键词
指定指令顺序的需求使得很多人想知道是否volatile
关键词可能在多线程方面有帮助,特别是在DCLP方面。在本节中,我们把注意力放到C++volatile
的语义上,特别是它对DCLP的影响上。关于volatile
更广范围的讨论,请参见附带的侧边栏。
C++标准[15]的1.9节包含了以下信息(我们所需要的):
volatile数据的读写以及库中I/O函数的调用,它的顺序性在C++抽象机的可观测行为中。
访问一个volatile左值对象、修改一个对象、调用库中的I/O函数、或者调用函数做任何以上操作,都属于副作用,即执行环境状态上发生了改变。
与我们之前所看到的结合起来:
- 标准保证结合所有的副作用将会在到达顺序点时执行完毕
- 每个C++语句的末尾都会有一个顺序点
似乎为了确保正确的指令顺序,我们只需要做用volvatile
修饰适当的数据并且将我们的语句细心排序。