C++和双重检查锁定的危险

多线程只是在另一件事之后、之前或同时发生的一件事。

1 简介

在Google新闻组或网络上搜索各种设计模式的名称,您肯定会发现最常提到的一种是 Singleton。但是,尝试将 Singleton 付诸实践,您几乎肯定会遇到一个重要的限制:按照传统实现(以及我们在下面解释的),Singleton 不是线程安全的。
已经付出了很多努力来解决这个缺点。最流行的方法之一是其自身的设计模式,即双重检查锁定模式 (DCLP) [13, 14]。 DCLP 旨在为共享资源(例如 Singleton)的初始化添加有效的线程安全性,但它有一个问题:它不可靠。此外,几乎没有可移植的方式使其在 C++(或 C)中可靠,而无需对传统模式实现进行实质性修改。更有趣的是,在单处理器和多处理器架构上,DCLP 可能因不同的原因而失败。
这篇文章解释了为什么 Singleton 不是线程安全的,DCLP 如何尝试解决这个问题,为什么 DCLP 在单处理器和多处理器架构上都可能失败,以及为什么你不能(可移植地)对此做任何事情。在此过程中,它阐明了源代码中语句顺序、序列点、编译器和硬件优化以及语句执行的实际顺序之间的关系。最后,它总结了一些关于如何添加的建议 Singleton(和类似的结构)的线程安全性,使得生成的代码既可靠又高效。

2 单例模式和多线程

单例模式 [7] 的传统实现基于在第一次请求对象时使指针指向新对象:

// from the header file				      //1 
class Singleton {					      //2
public:								      //3 
	static Singleton* instance();	      //4
	//...						          //5 
private:							      //6 
	static Singleton* pInstance;	      //7 								 
};									      //8
									      //9
// from the implementation file           //10 
Singleton* Singleton::pInstance = 0;      //11
									      //12
Singleton* Singleton::instance() {        //13 
	if (pInstance == 0) {			      //14
		pInstance = new Singleton();      //15
	}                                     //16
	return pInstance;                     //17
}									      //18

在单线程环境中,这通常可以正常工作,尽管中断可能是有问题的。如果你在 Singleton::instance 中,收到一个中断,并从处理程序调用 Singleton::instance,你可以看到你会遇到什么麻烦。然而,抛开中断不谈,这个实现在单线程环境中运行良好。


不幸的是,这种实现在多线程环境中并不可靠。假设线程 A 进入实例函数,通过第 14 行执行,然后被挂起。在它被挂起时,它刚刚确定 pInstance 为空,即尚未创建单例对象。


线程 B 现在进入 instance 并执行第 14 行。它看到 pInstance 为空,所以它继续到第 15 行并创建一个单例供 pInstance 指向。然后它将 pInstance 返回给实例的调用者。


稍后,线程 A 被允许继续运行,它做的第一件事是移动到第 15 行,在那里它变出另一个 Singleton 对象并使 pInstance 指向它。应该清楚这违反了单例的含义,因为现在有两个单例对象。


从技术上讲,第 11 行是初始化 pInstance 的地方,但出于实际目的,第 15 行使它指向我们想要的位置,因此在本文的其余部分,我们将第 15 行视为 pInstance 的初始化点。 使经典的 Singleton 实现线程安全很容易。在测试 pInstance 之前获取锁:
Singleton* Singleton::instance() {
	Lock lock; // acquire lock (params omitted for simplicity)
	if (pInstance == 0) {
		pInstance = new Singleton;
	}
		return pInstance;
} // release lock (via Lock destructor)

这个解决方案的缺点是它可能很昂贵。 每次访问 Singleton 都需要获取锁,但实际上,我们只在初始化 pInstance 时才需要锁。 这应该只在第一次调用实例时发生。 如果在程序运行过程中调用实例 n 次,我们只需要第一次调用的锁。 当您知道其中 n - 1 次是不必要的时,为什么还要为 n 次锁获取付费? DCLP 旨在防止您不得不这样做。

3 双重检查锁定模式

DCLP 的关键是观察到大多数对实例的调用都会看到 pInstance 是非空的,因此甚至不会尝试对其进行初始化。 因此,DCLP 在尝试获取锁之前测试 pInstance 是否为空。 仅当测试成功时(即,如果 pInstance 尚未初始化)才获得锁,然后再次执行测试以确保 pInstance 仍然为空(因此名称为双重检查锁定)。 第二个测试是必要的,因为,正如我们刚刚看到了,在第一次测试 pInstance 和获得锁的时间之间,可能有另一个线程碰巧初始化了 pInstance。

这是经典的 DCLP 实现 [13, 14]:

Singleton* Singleton::instance() {
	if (pInstance == 0) { // 1st test
		Lock lock;
		if (pInstance == 0) { // 2nd test
			pInstance = new Singleton;
		}
	}
	return pInstance;
}

定义 DCLP 的论文讨论了一些实现问题(例如,对单例指针进行 volatile 限定的重要性和单独缓存对多处理器系统的影响,我们将在下面讨论这两个问题;以及确保某些 读和写,我们不在本文中讨论),但他们没有考虑一个更基本的问题,即确保在 DCLP 期间执行的机器指令以可接受的顺序执行。 我们在这里关注的正是这个基本问题。

4 DCLP和指令排序

此语句导致发生三件事:

  1. 第一步:分配内存以保存 Singleton 对象。
  2. 第二步:在分配的内存中构造一个 Singleton 对象。
  3. 第三步:使 pInstance 指向分配的内存。

至关重要的是观察编译器不限于按此顺序执行这些步骤!特别是,有时允许编译器交换第 2 步和第 3 步。他们为什么要这样做是我们稍后将解决的问题。现在,让我们关注如果他们这样做会发生什么。


考虑下面的代码,我们将 pInstance 的初始化行扩展为我们上面提到的三个组成任务,并且我们将步骤 1(内存分配)和 3(pInstance 分配)合并到步骤 2 之前的单个语句中(单例结构)。这个想法并不是人类会编写这段代码。相反,编译器可能会生成与此等效的代码,以响应人类编写的传统 DCLP 源代码(如前所示):
Singleton* Singleton::instance() {
	if (pInstance == 0) {
		Lock lock;
		if (pInstance == 0) {
			pInstance =       // Step 3
				operator new(sizeof(Singleton)); // Step 1
			new (pInstance) Singleton; // Step 2
		}
	}
	return pInstance;
}

鉴于上述翻译,请考虑以下事件序列:

  • 线程 A 进入实例,执行 pInstance 的第一次测试,获取锁,并执行由步骤 1 和 3 组成的语句。然后它被挂起。 此时 pInstance 是非空的,但在 pInstance 指向的内存中还没有构造 Singleton 对象。
  • 线程 B 进入实例,确定 pInstance 不为空,并且将其返回给实例的调用者。 然后调用者取消引用指针访问尚未构建的单例。

只有在执行步骤 3 之前完成步骤 1 和 2 时,DCLP 才会起作用,但无法在 C 或 C++ 中表达此约束。这就是 DCLP 核心的匕首:我们需要定义相对指令顺序的约束,但我们的语言没有给我们表达约束的方法。


是的,C 和 C++ 标准 [16, 15] 确实定义了序列点,它定义了对评估顺序的约束。例如,C++ 标准第 1.9 节的第 7 段鼓励地指出:
在被称为序列点的执行序列中的某些指定点处,先前评估的所有副作用都应是完整的,并且后续评估的副作用不应发生。 此外,这两个标准都规定序列点出现在每个语句的末尾。所以看起来,如果你只是小心你如何排列你的陈述,一切都会水到渠成。 哦,Odysseus,不要让自己被警笛声所诱惑;因为有很多麻烦在等着你和你的伙伴!
这两个标准都根据抽象机器的可观察行为来定义正确的程序行为。但并不是这台机器的一切都是可观察的。例如,考虑这个简单的函数:
void Foo() {
	int x = 0, y = 0;      // Statement 1
	x = 5;			       // Statement 2
	y = 10;			       // Statement 3
	printf("%d,%d", x, y); // Statement 4
}

这个函数看起来很傻,但它可能是内联 Foo 调用的其他一些函数的结果。


在 C 和 C++ 中,标准都保证 Foo 将打印“5, 10”,所以我们知道那会发生。但这与我们得到保证的程度有关,也就是我们所知道的程度。我们根本不知道语句 1-3 是否会被执行,事实上一个好的优化器会摆脱它们。如果执行语句 1-3,我们知道语句 1 将在语句 2-4 之前,并且——假设对 printf 的调用没有内联并且结果进一步优化——我们知道语句 4 将在语句 1-3 之后,但是我们一无所知语句 2 和 3 的相对顺序。编译器可能会选择先执行语句 2,先执行语句 3,或者甚至并行执行它们,假设硬件有某种方法可以做到这一点。它可能有。现代处理器具有较大的字长和多个执行单元。两个或多个算术单元是常见的。 (For example, the Pentium 4 has three integer ALUs, PowerPC’s G4e has four, and Itanium has six)它们的机器语言允许编译器生成在单个时钟周期内并行执行两条或更多指令的代码。
优化编译器仔细分析和重新排序您的代码,以便一次执行尽可能多的事情(在可观察行为的约束范围内)。 在常规串行代码中发现和利用这种并行性是重新排列代码和引入乱序执行的最重要的原因。 但这不是唯一的原因。 编译器(和链接器)还可以重新排序指令以避免从寄存器溢出数据,保持指令流水线充满,执行公共子表达式消除,并减少生成的可执行文件的大小 [4]。
在执行这些类型的优化时,C 和 C++ 的编译器和链接器仅受语言标准定义的抽象机器上可观察行为的规定的约束,而且——这是重要的一点——这些抽象机器是隐式单线程的.作为语言,C 和 C++ 都没有线程,因此编译器在优化时不必担心破坏线程程序。因此应该不足为奇他们有时会做的事。
既然如此,如何编写真正有效的 C 和 C++ 多线程程序?通过使用为此目的定义的系统特定库。像 Posix 线程 (pthreads) [6] 这样的库为各种同步原语的执行语义提供了精确的规范。这些库对允许符合库的编译器生成的代码施加限制,从而迫使此类编译器发出尊重这些库所依赖的执行顺序约束的代码。这就是为什么线程包的部分是用汇编程序编写的,或者发出系统调用本身是用汇编程序(或某些不可移植的语言)编写的:您必须超越标准 C 和 C++ 来表达多线程程序所需的排序约束。 DCLP 试图通过仅使用语言结构来获得。这就是 DCLP 不可靠的原因。
通常,程序员不喜欢被他们的编译器左右。也许你就是这样的程序员。如果是这样,您可能会尝试通过调整源代码来使编译器变得更智能,以便 pInstance 在 Singleton 的构造完成之前保持不变。例如,您可以尝试插入使用临时变量:
Singleton* Singleton::instance() {
	if (pInstance == 0) {
		Lock lock;
		if (pInstance == 0) {
			Singleton* temp = new Singleton; // initialize to temp
			pInstance = temp;	             // assign temp to pInstance
		}
	}
	return pInstance;
}

从本质上讲,您刚刚在优化战中打响了开场白。您的编译器想要优化。你不想要它,至少在这里不想要。但这不是你想参加的战斗。你的敌人狡猾而老练,充满了人们数十年来梦想的战略,这些人整天、日复一日、年复一年地思考这种事情。除非您自己编写优化编译器,否则它们遥遥领先。在这种情况下,例如,编译器应用依赖分析来确定 temp 是一个不必要的变量,从而消除它是一件简单的事情,从而处理您精心制作的“不可优化”代码,如果它已经编写在传统的 DCLP 方式。游戏结束。你输了。
如果您使用更大的弹药并尝试将 temp 移动到更大的范围(例如通过使其文件静态),编译器仍然可以执行相同的分析并得出相同的结论。范围schmope。游戏结束。你输了。

所以你要求备份。您声明 temp extern 并在单独的翻译单元中定义它,从而防止您的编译器看到您在做什么。唉,有些编译器具有夜视镜的优化功能:它们执行过程间分析,发现你对 temp 的诡计,然后再次优化它以使其不存在。请记住,这些是优化编译器。他们应该追踪不必要的代码并消除它。


游戏结束。你输了。因此,您尝试通过在不同文件中定义辅助函数来禁用内联,从而迫使编译器假定构造函数可能会抛出异常,从而延迟对 pInstance 的分配。不错的尝试,但有些构建环境执行链接时内联,然后进行更多代码优化 [5,11,4]。游戏结束。你输了。
您所做的任何事情都无法改变基本问题:您需要能够指定指令顺序的约束,而您的语言无法做到这一点。

5 Almost Famous: The Keyword

对特定指令顺序的渴望使许多人怀疑 volatile 关键字是否有助于一般的多线程,特别是 DCLP。在本节中,我们将注意力限制在 C++ 中 volatile 的语义上,并进一步将讨论限制在它对 DCLP 的影响上。有关 volatile 的更广泛讨论,请参阅随附的侧边栏。
C++ 标准 [15] 的第 1.9 节包括以下信息(强调我们的):
[C++] 抽象机的可观察行为是它对易失性数据的读取和写入顺序以及对库 I/O 函数的调用。访问由 volatile 左值指定的对象、修改对象、调用库 I/O 函数或调用执行任何这些操作的函数都是副作用,它们是执行环境状态的变化。
结合我们之前的观察,(1)标准保证所有副作用都将在到达序列点时发生,(2)序列点出现在每个 C++ 语句的末尾,看起来我们只需要为确保正确的指令顺序所做的就是对适当的数据进行 volatile 限定并仔细排列我们的语句。 我们之前的分析表明,pInstance 需要被声明为 volatile,实际上这一点在 DCLP 的论文中已经提出[13, 14]。但是,Sherlock Holmes 肯定会注意到,为了确保正确的指令顺序,Singleton 对象本身也必须是易失的。这在最初的 DCLP 论文中没有提到,这是一个重要的疏忽。
要了解单独声明 pInstance 是 volatile 是不够的,请考虑以下几点:
class Singleton {
public:
	static Singleton* instance();
	//...
private:
	static Singleton*pInstance; // volatile added
	int x;
	Singleton() : x(5) {}
};

// from the implementation file
Singleton*Singleton::pInstance = 0;
Singleton* Singleton::instance() {
	if (pInstance == 0) {
		Lock lock;
		if (pInstance == 0) {
			Singleton*temp = new Singleton; // volatile added
			pInstance = temp;
		}
	}
	return pInstance;
}
//After inlining the constructor, the code looks like this:
if (pInstance == 0) {
	Lock lock;
	if (pInstance == 0) {
		Singleton* volatile temp =
			static_cast<Singleton*>(operator new(sizeof(Singleton)));
		temp->x = 5; // inlined Singleton constructor
		pInstance = temp;
	}
}

虽然 temp 是易变的,但 *temp 不是,这意味着 temp->x 也不是。 因为我们现在了解对非易失性数据的赋值有时可能会重新排序,所以很容易看出编译器可以对 temp->x 的赋值重新排序关于 pInstance 的赋值。 如果他们这样做了,pInstance 将在它指向的数据被初始化之前被分配,这再次导致不同线程读取未初始化的 x 的可能性。
对于这种疾病,一种看起来很吸引人的治疗方法是将 volatile 限定为 *pInstance 以及 pInstance 本身,从而产生一个美化的 Singleton 版本,其中所有棋子都被涂成 volatile。
class Singleton {
public:
	static volatile Singleton* volatile instance();
	//...
private:
	// one more volatile added
	static Singleton* volatile pInstance;
};
// from the implementation file
volatile Singleton* volatile Singleton::pInstance = 0;
volatile Singleton* volatile Singleton::instance() {
	if (pInstance == 0) {
		Lock lock;
		if (pInstance == 0) {
			// one more volatile added
			Singleton* volatile temp =new Singleton;
			pInstance = temp;
		}
	}
	return pInstance;
}

(此时,人们可能会想知道为什么 Lock 不也被声明为 volatile。毕竟,在我们尝试写入 pInstance 或 temp 之前初始化锁是至关重要的。好吧,Lock 来自线程库,所以我们可以假设它要么在其规范中规定了足够的限制,要么在其实现中嵌入了足够的魔法以在不需要 volatile 的情况下工作。这是我们所知道的所有线程库的情况。本质上,实体的使用(例如,对象、函数等) ) 来自线程库导致在程序中强加“硬序列点”——适用于所有线程的序列点。出于本文的目的,我们假设这些“硬序列点”充当代码期间指令重新排序的坚固障碍优化:源代码中使用库实体之前的source语句对应的指令不能移到使用实体对应的指令之后,和source对应的指令在源代码中使用此类实体之后的语句不得移至与其使用相对应的指令之前。真正的线程库施加的严格限制较少,但对于我们这里的讨论而言,细节并不重要。)
人们可能希望标准能够保证上述完全 volatile 限定的代码在多线程环境中正常工作,但它可能会因两个原因而失败。
首先,标准对可观察行为的约束仅适用于标准定义的抽象机器,并且该抽象机器没有多线程执行的概念。因此,尽管标准阻止编译器对线程内易失性数据的读取和写入重新排序,但它对跨线程的这种重新排序完全没有任何限制。至少大多数编译器实现者是这样解释的。因此,在实践中,许多编译器可能会从上面的源代码生成线程不安全的代码。如果您的多线程代码可以在 volatile 下正常工作,并且在没有 volatile 的情况下无法正常工作,那么要么您的 C++ 实现仔细实现了 volatile 以与线程一起工作(不太可能),要么您很幸运(更有可能)。无论哪种情况,您的代码都不可移植。
其次,正如 const 限定的对象在其构造函数运行完成之前不会变为 const 一样,volatile 限定的对象只有在其构造函数退出时才会变为 volatile。在声明中
Singleton* volatile temp = new volatile	Singleton;

被创建的对象在表达式之前不会变得易变

new volatile	Singleton;

已经运行完成,这意味着我们又回到了内存分配和对象初始化指令可以任意重新排序的情况。
这个问题是我们可以解决的,尽管有点尴尬。 在 Singleton 构造函数中,我们使用强制转换在 Singleton 对象的每个数据成员初始化时临时添加“易失性”,从而防止执行初始化的指令的相对移动。 例如,这里的 Singleton 构造函数就是这样写的。 (为了简化演示,我们使用赋值为 Singleton::x 赋予第一个值,而不是成员初始化列表,就像我们在上面的代码中所做的那样。这个更改对我们正在解决的任何问题都没有影响 这里。)

Singleton()
{
	static_cast<volatile int&>(x) = 5; // note cast to volatile
}

在 pInstance 具有适当 volatile-qualified 的 Singleton 版本中内联此函数后,我们得到

class Singleton {
public:
	static Singleton* instance();
	//...
private:
	static Singleton* volatile pInstance;
	int x;
	//...
};
Singleton* Singleton::instance()
{
	if (pInstance == 0) {
		Lock lock;
		if (pInstance == 0) {
			Singleton* volatile temp =
				static_cast<Singleton*>(operator new(sizeof(Singleton)));
			static_cast<volatile int&>(temp->x) = 5;
			pInstance = temp;
		}
	}
}

现在对 x 的赋值必须在对 pInstance 的赋值之前,因为两者都是易失的。
不幸的是,这一切都无法解决第一个问题:C++ 的抽象机器是单线程的,C++ 编译器可能会选择从源代码生成线程不安全的代码,无论如何。 否则,失去优化机会会导致效率损失过大。 经过所有的讨论,我们回到第一方。 但是等等,还有更多。 更多处理器。

6 DCLP 在多处理器机器上


假设你在一台有多个处理器的机器上,每个处理器都有自己的内存缓存,但所有这些都共享一个公共内存空间。这种架构需要准确定义一个处理器执行的写入如何以及何时传播到共享内存,从而对其他处理器可见。很容易想象这样的情况:一个处理器更新了自己缓存中的共享变量的值,但更新后的值还没有被刷新到主内存,更不用说加载到其他处理器的缓存中了。这种共享变量值的缓存间不一致被称为缓存一致性问题。
假设处理器 A 修改了共享变量 x 的内存,然后修改了共享变量 y 的内存。这些新值必须刷新到主内存,以便其他处理器可以看到它们。但是,以地址递增的顺序刷新新缓存值可能更有效,因此如果 y 的地址在 x 之前,则 y 的新值可能会在 x 之前写入主存储器。如果发生这种情况,其他处理器可能会在 x 之前看到 y 的值变化。
这种可能性对于 DCLP 来说是一个严重的问题。正确的 Singleton 初始化要求 Singleton 被初始化并且 pInstance 被更新为非 null 并且这些操作被视为按此顺序发生。如果处理器 A 上的线程执行步骤 1,然后执行步骤 2,但处理器 B 上的线程认为步骤 2 已在步骤 1 之前执行,则处理器 B 上的线程可能再次引用未初始化的单例。
缓存一致性问题的一般解决方案是使用内存屏障(即栅栏):编译器、链接器和其他优化实体识别的指令,这些指令限制了在多处理器系统中对共享内存的读取和写入可能执行的重新排序类型。在 DCLP 的情况下,我们需要使用内存屏障来确保 pInstance 在对 Singleton 的写入完成之前不会被视为非空。这是与 [1] 中给出的示例密切相关的伪代码。我们只显示插入内存屏障的语句的占位符,因为实际代码是特定于平台的(通常在汇编程序中)。
Singleton* Singleton::instance() {
	Singleton* tmp = pInstance;
	//... // insert memory barrier
	if (tmp == 0) {
		Lock lock;
		tmp = pInstance;
		if (tmp == 0) {
			tmp = new Singleton;
			//... // insert memory barrier
			pInstance = tmp;
		}
	}
	return tmp;
}


Arch Robison([12] 的作者,但这是来自个人交流)指出这是矫枉过正: 从技术上讲,您不需要完全的双向屏障。 第一个障碍必须防止 Singleton 的构造向下迁移(通过另一个线程); 第二个屏障必须防止 pInstance 的初始化向上迁移。 这些被称为“获取”和“释放”操作,并且可能会产生比硬件上的完全障碍(例如Itainum)更好的性能。 不过,如果您在支持内存屏障的机器上运行,那么这种实现 DCLP 的方法应该是可靠的。 所有可以对共享内存的写入重新排序的机器都以一种或另一种形式支持内存屏障。 有趣的是,同样的方法在单处理器环境中也同样有效。 这是因为内存屏障还充当硬序列点,以防止可能非常麻烦的指令重新排序类型。

7 结论和 DCLP 替代方案

这里有几个教训需要学习。 首先,请记住单处理器上基于时间片的并行性与跨多个处理器的真正并行性不同。 这就是为什么单处理器架构上特定编译器的线程安全解决方案在多处理器架构上可能不是线程安全的,即使您坚持使用相同的编译器也是如此。 (这是一般观察。它不是特定于 DCLP。)
其次,虽然 DCLP 本质上与 Singleton 没有关联,但使用 Singleton 往往会导致希望通过 DCLP “优化”线程安全访问。 因此,您应该确保避免使用 DCLP 实现 Singleton。 如果您(或您的客户)担心每次调用实例时锁定同步对象的成本,您可以建议客户通过缓存实例返回的指针来尽量减少此类调用。 例如,建议不要编写这样的代码,

Singleton::instance()->transmogrify();
Singleton::instance()->metamorphose(); 
Singleton::instance()->transmute();

客户以这种方式做事:

Singleton* const instance = Singleton::instance();	// cache instance pointer
instance->transmogrify(); 
instance->metamorphose(); 
instance->transmute();

应用这个想法的一个有趣的方法是鼓励客户端在每个需要访问单例对象的线程开始时对实例进行一次调用,将返回的指针缓存在线程本地存储中。因此,使用这种技术的代码只需为每个线程的单个锁访问付费。
在建议缓存调用实例的结果之前,通常最好验证这是否真的会带来显着的性能提升。使用线程库中的锁来确保线程安全的 Singleton 初始化,然后进行时序研究,看看成本是否真的值得担心。
第三,避免使用延迟初始化的 Singleton,除非你真的需要它。经典的 Singleton 实现基于在请求资源之前不初始化资源。另一种方法是使用预先初始化,即在程序运行开始时初始化资源。因为多线程程序通常作为单线程开始运行,这种方法可以将一些对象初始化推送到代码的单线程启动部分,从而无需担心初始化期间的线程。在许多情况下,在单线程程序启动期间(例如,在执行 main 之前)初始化单例资源是提供快速、线程安全的单例访问的最简单方法。
使用急切初始化的另一种方法是用单态模式 [2] 代替单例模式的使用。然而,Monostate 有不同的问题,尤其是在控制构成其状态的非局部静态对象的初始化顺序时。 Effective C++ [9] 描述了这些问题,具有讽刺意味的是,它建议使用 Singleton 的变体来逃避它们。 (不保证该变体是线程安全的[17]。)
另一种可能性是将全局单例替换为每个线程一个单例,然后使用线程本地存储来存储单例数据。这允许延迟初始化而不用担心线程问题,但这也意味着多线程程序中可能有多个“单例”。
最后,DCLP 及其在 C++ 和 C 中的问题体现了在没有线程概念(或任何其他形式的并发)的语言中编写线程安全代码的内在困难。多线程考虑无处不在,因为它们影响代码生成的核心。正如 Peter Buhr 指出的 [3],将多线程排除在语言之外并隐藏在库中的愿望是一种幻想。这样做,要么 (1) 库最终会限制编译器生成代码的方式(就像 Pthreads 所做的那样),要么 (2) 编译器和其他代码生成工具将被禁止执行有用的优化,即使在单线程上也是如此代码。您只能选择由多线程、线程无意识语言和优化代码生成形成的三驾马车中的两个。例如,Java 和 .NET CLI 分别通过将线程感知引入语言和语言基础设施来解决紧张局势 [8, 12]。

8 致谢

本文的出版前草稿由 Doug Lea、Kevlin Henney、Doug Schmidt、Chuck Allison、Petru Marginean、Hendrik Schober、David Brownell、Arch Robison、Bruce Leasure 和 James Kanze 审阅。 他们的评论、见解和解释极大地改进了论文的呈现方式,并使我们了解了我们目前对 DCLP、多线程、指令排序和编译器优化的理解。 出版后,我们纳入了 Fedor Pikus、Al Stevens、Herb Sutter 和 John Hicken 的评论。

9 关于作者

Scott Meyers 撰写了三本 Effective C++ 书籍,并且是 Addison-Wesley Effective Software Development Series 的顾问编辑。 他目前的兴趣集中在确定提高软件质量的基本原则。他的网站是 http://aristeia.com. Andrei Alexandrescu 是 Modern C++ Design 和众多文章的作者,其中大部分是作为 CUJ 专栏作家撰写的。 他攻读博士学位。 华盛顿大学学位,专攻编程语言。 他的网站是 http://moderncppdesign.com .

10 [Sidebar] :简史

要找到 volatile 的根源,让我们回到 1970 年代,当时 Gordon Bell(以 PDP-11 闻名)引入了内存映射 I/O (MMIO) 的概念。 在此之前,处理器为执行端口 I/O 分配引脚并定义特殊指令。 MMIO 背后的想法是对内存和端口访问使用相同的引脚和指令。 处理器外部的硬件拦截特定的内存地址,并将其转换为 I/O 请求; 因此处理端口变得简单地读取和写入特定于机器的内存地址。
真是个好主意。 减少引脚数是好的——引脚会减慢信号速度、增加缺陷率并使封装复杂化。 此外,MMIO 不需要端口的特殊说明。 程序只使用内存,其余的由硬件处理。
或者差不多。
要了解为什么 MMIO 需要 volatile 变量,让我们考虑以下代码:

unsigned int *p = GetMagicAddress();
unsigned int a, b;
a = *p;
b = *p;

如果 p 指代一个端口,a 和 b 应该接收从该端口读取的两个连续字。 但是,如果 p 指向一个真正的内存位置,那么 a 和 b 会加载相同的位置两次,因此比较相等。 编译器在复制传播优化中利用了这个假设,将上面的最后一行转换为更有效的:

b = a;

同样,对于相同的 p、a 和 b,请考虑:

*p = a;
*p = b;

代码将两个字写入 *p,但优化器可能会假设 *p 是内存并通过消除第一个赋值来执行死赋值消除优化。显然,这种“优化”会破坏代码。当主线代码和中断服务例程 (ISR) 修改变量时,可能会出现类似的情况。在编译器看来,冗余读取或写入实际上可能是必要的,以便主线代码与 ISR 通信。
所以在处理一些内存位置(例如内存映射端口或 ISR 引用的内存)时,必须暂停一些优化。 volatile 存在用于指定对此类位置的特殊处理,具体而言:(1)volatile 变量的内容是“不稳定的”(可以通过编译器未知的方式更改),(2)对 volatile 数据的所有写入都是“可观察的”,因此它们必须认真执行,并且 (3) 对 volatile 数据的所有操作都按照它们在源代码中出现的顺序执行。前两条规则确保正确的读写。最后一个允许实现混合输入和输出的 I/O 协议。这是 C 和 C++ 的 volatile 保证的非正式内容。
Java 通过保证跨多个线程的上述属性使 volatile 更进了一步。这是一个非常重要的步骤,但还不足以使 volatile 可用于线程同步:volatile 和 non-volatile 操作的相对顺序仍未指定。这种省略迫使许多变量是可变的,以确保正确的排序。
Java 1.5 的 volatile [10] 具有更严格但更简单的获取/释放语义:保证对 volatile 的任何读取都发生在随后的语句中的任何内存引用(易失性或非易失性)之前,并且任何写入volatile 保证在其前面的语句中的所有内存引用之后发生。 .NET 还定义了 volatile 以包含多线程语义,这与当前提出的 Java 语义非常相似。我们知道在 C 或 C++ 的 volatile 上没有类似的工作。

References

[1] David Bacon, Joshua Bloch, Jeff Bogda, Cliff Click, Paul Hahr, Doug Lea, Tom May, Jan-Willem Maessen, John D. Mitchell, Kelvin Nilsen, Bill Pugh, and Emin Gun Sirer. The “Double-Checked Locking Pattern is Broken” Declaration. Available at http://www.cs.umd.edu/∼pugh/java/ memoryModel/DoubleCheckedLocking.html.
[2] Steve Ball and John Crawford. Monostate Classes: The Power of One. C++ Report, May 1997. Reprinted in More C++ Gems, Robert C. Martin, ed., Cambridge University Press, 2000.
[3] Peter A. Buhr. Are Safe Concurrency Libraries Possible? Communications of the ACM, 38(2):117–120, 1995. Available at http://citeseer.nj.nec. com/buhr95are.html.
[4] Bruno De Bus, Daniel Kaestner, Dominique Chanet, Ludo Van Put, and Bjorn De Sutter. Post-pass Compaction Techniques. Communications of the ACM, 46(8):41–46, August 2003. Available at http://doi.acm.org/
10.1145/859670.859696.
[5] Robert Cohn, David Goodwin, P. Geoffrey Lowney, and Norman Rubin. Spike: An Optimizer for Alpha/NT Executables. Available at http://www.usenix.org/publications/library/proceedings/ usenix-nt97/presentations/goodwin/index.htm, August 1997.
[6] IEEE Standard for Information Technology. Portable Operating System Interface (POSIX) — System Application Program Interface (API) Amendment 2: Threads Extension (C Language). ANSI/IEEE 1003.1c-1995, 1995.
[7] Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 1995. Also available as Design Patterns CD, Addison-Wesley, 1998.
[8] Doug Lea. Concurrent Programming in JavaTM. Addison-Wesley, 1999. Excerpts relevant to this article can be found at http://gee.cs.oswego. edu/dl/cpj/jmm.html.
[9] Scott Meyers. Effective C++, Second Edition. Addison-Wesley, 1998. Item 47 discusses the initialization problems that can arise when using non-local static objects in C++.
[10] Sun Microsystems. J2SE 1.5.0 Beta 1. February 2004. http://java. sun.com/j2se/1.5.0/index.jsp; see http://jcp.org/en/jsr/detail?
id=133 for details on the changes brought to Java’s memory model.
[11] Matt Pietrek. Link-Time Code Generation. MSDN Magazine, May
2002. Available at http://msdn.microsoft.com/msdnmag/issues/02/ 05/Hood/.
[12] Arch D. Robison. Memory Consistency & .NET. Dr. Dobb’s Journal, April 2003.
[13] Douglas C. Schmidt and Tim Harrison. Double-Checked Locking. In Robert Martin, Dirk Riehle, and Frank Buschmann, editors, Pattern Languages of
Program Design 3, chapter 20. Addison-Wesley, 1998. Available at http: //www.cs.wustl.edu/∼schmidt/PDF/DC-Locking.pdf.
[14] Douglas C. Schmidt, Michael Stal, Hans Rohnert, and Frank Buschmann. Pattern-Oriented Software Architecture, Volume 2. Wiley, 2000. Tutorial notes based on the patterns in this book are available at http://cs.wustl. edu/∼schmidt/posa2.ppt.
[15] ISO/IEC 14882:1998(E) International Standard. Programming languages — C++. ISO/IEC, 1998.
[16] ISO/IEC 9899:1999 International Standard. Programming languages — C. ISO/IEC, 1999.
[17] John Vlissides. Pattern Hatching: Design Patterns Applied. AddisonWesley, 1998. The discussion of the “Meyers Singleton” is on pp. 69ff.

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值