【翻译】C++ and the Perils of Double-Checked Locking

译注

从某个文件夹里翻出这篇文章,感觉很好,尝试翻译一下。

译者: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处,然后被挂起。在它被挂起的之前,它刚刚判断认为pInstancenull,也就是说还没有Singleton对象被创建。

线程B现在进入到instance中同样执行到if处。它发现pInstancenull,所以创建了一个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;

这条语句会导致三个事情的发生:

  1. 分配能够存储Singleton对象的内存;
  2. 在被分配的内存中构造一个Singleton对象;
  3. 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变量),编译器仍然能执行一样的分析,得到同样的结论。范围?批围!游戏结束,你又输了。

所以你需要支援。你声明了tempextern变量,将它定义在分离的翻译单元中,阻止你的编译器看到你在干什么。真是为你遗憾啊,一些编译器拥有像夜视镜一样的优化能力:他们执行程序间的分析,发现你使用temp的诡计,再次优化了它的存在。记住,它们是优化编译器。它们应该追踪并消除不必要的代码。游戏结束,你又输了。

所以你尝试通过在不同文件中定义辅助函数禁止内联,因此强制编译器认为构造函数可能会产生异常,从而延迟pInstance的赋值。想得美,但一些构建环境会执行链接时内联,随后是更多的代码优化[5, 11, 4]。游戏结束,你又输了。

你所做的都没有解决这个最根本的问题:你有指定指令顺序约束的需求,而你的语言没有给你方法。

5. 几近成名:volatile关键词

指定指令顺序的需求使得很多人想知道是否volatile关键词可能在多线程方面有帮助,特别是在DCLP方面。在本节中,我们把注意力放到C++volatile的语义上,特别是它对DCLP的影响上。关于volatile更广范围的讨论,请参见附带的侧边栏。

C++标准[15]的1.9节包含了以下信息(我们所需要的):

volatile数据的读写以及库中I/O函数的调用,它的顺序性在C++抽象机的可观测行为中。
访问一个volatile左值对象、修改一个对象、调用库中的I/O函数、或者调用函数做任何以上操作,都属于副作用,即执行环境状态上发生了改变。

与我们之前所看到的结合起来:

  1. 标准保证结合所有的副作用将会在到达顺序点时执行完毕
  2. 每个C++语句的末尾都会有一个顺序点

似乎为了确保正确的指令顺序,我们只需要做用volvatile修饰适当的数据并且将我们的语句细心排序。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值