原文:
C++ and the Perils of Double-Checked Locking
没有按照原文一字一句的排版和翻译,总体内容都是在的,原文见附件。
首先,先简单讲一下什么是DCLP。DCLP是 double check locking pattern的缩写,它的出现旨在为共享资源(比如单例模式)的初始化添加高效的线程安全性。单例模式,分为 lazy-initialization 和 eager-initialization,两种创建静态对象的方式。lazy-initialization 就是在调用 getInstance 接口时,创建对象,而 eager-initialization 则是在进程创建时,就创建好对象。eager-initialization 不存在线程安全问题,但是为了资源和性能问题,一般都会选择 lazy-initialization 的方式。而上面那篇文章则明确说明了DCLP本身是不可靠的,这里做一个简单的翻译(并非全文翻译)。
结论:在C/C++中,几乎没有任何一种不需要对传统模式的实现进行实质性修改的可移植方法使DCLP变得可靠。DCLP在单处理器和多处理器体系结构中可能由于不同的原因而失败。
下面我们来讲为什么,先来看一个简单的单例模式代码:
class Singleton {
public:
~Singleton();
static Singleton *getInstance() {
if (pInstance == NULL) {
pInstance = new Singleton();
}
return pInstance;
}
void setValue(int a) {
x = a;
}
int getValue() {
return x;
}
protected:
Singleton(){};
private:
int x;
static Singleton *pInstance;
};
Singleton* Singleton::pInstance = NULL;
int main(int argc, const char *argv[]) {
Singleton *ptr = Singleton::getInstance();
cout << ptr->getValue() << endl;
ptr->setValue(20);
cout << ptr->getValue() << endl;
return 0;
}
可以看到 getInstance 没有加互斥锁,单线程中使用,毫无问题。而如果有线程 A 和线程 B 同时调用,那么有可能 A 和 B 会分别创建两个对象出来。
语句顺序 |
线程 A |
线程 B |
1 |
if (pInstance == NULL) |
|
2 |
|
if (pInstance == NULL) |
3 |
pInstance = new Singleton |
|
4 |
return pInstance |
|
5 |
|
pInstance = new Singleton |
6 |
|
return pInstance |
当然,还有其他的可能调度顺序引起线程不安全,这里就不一一列出,仅举这样的例子作为一个说明。
因此,我们可以直接在getInstance 函数入口加锁来解决这个问题。
static Singleton *getInstance() {
std::scoped_lock(this.mutex); // c++17
if (pInstance == NULL) {
pInstance = new Singleton();
}
return pInstance;
}
这样确实能够做到了同步,但是互斥锁的效率实在是很低的,如果线程很多,而且 getInstance 会在很多地方调用的话,那么会引起非常严重的性能问题。这个可以大家下来写代码验证。
那么怎么样才能既线程安全问题,又能有高性能呢?这就有了 DCLP 的代码方式,如下:
static Singleton *getInstance() {
if (pInstance == NULL) {
std::scoped_lock(this.mutex); // c++17
if (pInstance == NULL) {
pInstance = new Singleton();
}
}
return pInstance;
}
是不是感觉很巧妙?然而依然会存在问题,而出问题的关键,在于代码
pInstance = new Singleton();
在 c++中,这一个语句其实是以下步骤,
1)为Singleton对象分配内存
2)在已分配内存空间中构造Singleton对象
3)将 pInstance 指向这片内存区
可以参考 深入C++的new,通常来说,由于step 2中构造函数可能会抛出异常,如果异常抛出,那么 pInstance 是不会被赋值,编译器不能把step 2 和 step 3的顺序对调,但是,由于编译器优化的原因,如果编译器能够确保构造函数不会抛出异常(例如通过 post-inlining flow analysis 等),某些能抛出异常的构造方法也可能重排语句顺序,导致step 2 和 step 3 的顺序是不确定的,也就是说可能存在以下情况
1)为Singleton对象分配内存
2)将 pInstance 指向这片内存区
3)在已分配内存空间中构造Singleton对象
那么这就会导致问题,我们来看一下可能出问题的调用顺序:
语句顺序 |
线程 A |
线程 B |
1 |
if (pInstance == NULL) => true |
|
2 |
std::scoped_lock(this.mutex) |
|
3 |
if (pInstance == NULL) => true |
|
4 |
step 1,分配内存 |
|
5 |
step 2,给 pInstance 赋值 |
|
6 |
|
if (pInstance == NULL) => false |
7 |
|
return pInstance (此时尚未构造对象) |
8 |
step 3,构造对象 |
|
9 |
return pInstance |
|
我们必须保证 step 1 和 step 2 在 step 3 之前执行,而C/C++语言没有办法表达这种限制。
C 和 C++ 标准确实有为求值顺序(the order of evaluation)定义限制,即序列点(sequence points)。例如,C++标准 1.9 节第七段令人鼓舞的提到:
序列点即在执行序列中的特定点上,在此之前的所有求值的副作用应全部完成,且后续求值的副作用不应发生。
此外,C和C++标准都有提到,序列点在每个语句结束时产生。因此,看起来,好像只要我们仔细地把代码按顺序放好,一切就能有条不紊了。然而事情并没有这么简单。这两个标准都根据抽象机器(abstract machine)的可观察行为来定义所谓正确的程序行为,但并不是抽象机器的所有东西都是可见的,比如下面这个简单函数:
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,如果硬件支持,甚至可以并行的执行它们。这种可能性很大,现代处理器都有大字长和多个执行单元。两个或多个运算单元也很常见。它们的机器语言都允许编译器生成可以在一个时钟周期执行多个指令的并行代码。
优化编译器会将你的代码仔细分析然后重新排序,在可见行为的限制内,能够同时做尽可能多的事情。在普通的串行代码中,发现并利用这种并行性是重新排列代码以及打乱原有执行顺序的重要原因。但并不是唯一的原因。编译器(和链接器)还可能对指令进行重新排序,以避免寄存器中的数据溢出、保持指令管道满、执行公共子表达式消除,以及减少生成的可执行文件的大小。
在进行这些优化的时候,C/C++编译器或者链接器只受语言标准定义的抽象机器上可观察行为的约束,然而很重要的一点是,那些抽象机器都是默认(implicitly)单线程的。作为编程语言,C和C++都不存在线程的概念,因此编译器在做优化的时候也不会考虑到会破坏多线程程序。 这一句其实是在说线程是一个抽象的概念,因此对于一个语言编译器来说,并没有这样的概念,所以在做代码优化的时候,它没有办法知道这是不是一个多线程程序。
既然如此,我们要如何才能写出一个能正常工作的C/C++多线程程序呢?答案就是,通过使用系统定义的多线程库来解决。比如像 pthread 这样的多线程库,为各种同步原语的执行语义提供了精确的规范。这些库对符合库的编译器允许生成的代码施加了限制,从而迫使这些编译器生成受这些库所依赖的执行顺序约束的代码。 这段话的意思应该是说,这些库在编码的时候就添加了执行顺序约束的代码,使得编译器生成的机器码也会按照约束的顺序执行。这就是为什么在这些线程库中都有使用汇编语言编写的部分或者使用了本身就用了汇编语言的系统调用,这也就是说,你必须在C/C++语言标准以外才能找到那些多线程程序所需要的约束执行顺序的方法。而DCLP仅仅试图使用语言的构造机制,显然是不可靠的。
一般来说,程序员不喜欢被编译器摆布。如果你就是这样的程序员,你可能会试图通过调整源码来使得 pInstance 在 Singleton 构造完成后再被赋值,以此来巧胜编译器。比如,下面这样的代码:
static Singleton *getInstance() {
if (pInstance == NULL) {
std::scoped_lock(this.mutex); // c++17
if (pInstance == NULL) {
Singleton* temp = new Singleton; // initialize to temp
pInstance = temp; // assign temp to pInstance
}
}
return pInstance;
}
事实上你这只是在挑战编译器的优化能力。编译器总是优化代码,你不希望编译器优化这段代码。但是你要知道,编译器的优化机制是由一群几十年来成天啥事儿不做一心只想着如何进行编译优化的人实现的,他们满肚子诡计、老奸巨猾。除非你自己写编译器优化,否则他们总是料敌先机。以上面这段代码为例,编译器很容易就能通过相关性分析出 temp 是一个非必需的变量,因此排除它,从而将你精心准备的“不可优化”的代码视为与传统 DCLP 方式写的代码一样。无解,此路不通。
如果你想要将 temp 改为file static的,以扩大其作用域,编译器依然能通过同样的分析得到同样的结论。作用域?傻屌。
如果你将 temp 改成 extern,然后在其他编译单元里面声明,想以此让编译器不知道你的意图?可悲的是,有些编译器如同具有看穿黑夜的眼睛,它们使用过程间分析,发现你的 temp 诡计,然后再一次优化它。记住,它们是优化编译器。它们的作用就是发现无用代码然后删除。Game over.
然后你试图通过在不同文件中声明一个 helper 函数来禁用内联性,从而迫使编译器假定构造函数可能抛出异常,从而延迟 pInstance 的赋值。不错的尝试,但是有些编译环境会进行链接时内联,然后紧跟着进行更多代码优化。GAME OVER!
你所做的一切都无法改变一个基本事实:你需要能够在指令顺序上指定约束,而你所用的语言无法做到这一点。
对指定指令顺序的渴求,让我们开始思考 volatile 关键字是否能够在多线程,尤其是 DCLP 上有所帮助。我们先来看看C++中的volatile关键字的历史渊源,然后再进一步讨论它对 DCLP 的影响。
20世纪70年代(1970s)一个叫做 Gordon Bell 的人提出了 MMIO (memroy-mapped I/O) 的概念。在此之前,处理器如果想要执行端口(外围硬件与处理器连接的端口)的IO,需要分配pin脚,还需要定义专门的指令。而MMIO的想法是使用同样的pin脚和指令来访问内存和端口。外围硬件将特殊的内存地址转变成 I/O 请求,于是对设备端口的 I/O 处理就变得和本地读写内存一样简单了。程序只需要像使用内存一样,剩下的工作几乎都由硬件去完成。我们来看看为什么 MMIO 会需要 volatile 变量,如下代码:
unsigned int *p = GetMagicAddress();
unsigned int a, b;
a = *p;
b = *p;
如果 p 指向一个硬件设备端口,a 和 b 应该能够接收到两个从端口读到的连续的words。这里的意思应该是说,p指向的是一个外部硬件设备,那么p指向的内存数据就可能由于外部硬件状态的改变而改变,也就是类似于多线程并发的情况,这里分别给 a 和 b 赋值实际上是有可能从硬件设备获取到两个不同的int值。然而,如果 p 指向一个真实内存地址,那么 a 和 b 加载了同一个地址两次,因而 a 和 b 理应相等。编译器在复制传播(copy propagatio)优化原则中利用了这样的假设,将上述代码的最后一行转变成了:
b = a;
类似的,对于同样的 p,a 和 b,考虑下面代码:
*p = a;
*p = b;
我们写了两个words到 *p,但是优化器可能假定 *p 是内存地址,然后运用无效赋值删除(dead assignment elimination) 优化来删除第一个赋值语句。显然,这样的优化破坏了代码。当一个变量同时被主线程代码和中断服务程序(SR, interrupt service routine)修改时,也会有类似的情况。对于主线程代码与中断服务程序通信的场景来说,冗余的读写操作是切实需要的。
因此,在处理一些内存地址相关代码时(比如,内存映射端口或者被ISR引用的内存),一些优化必须被禁用。volatile 的存在就是表明这些特殊的内存地址需要被特殊处理:
1)一个 volatile 的变量的内容是“不稳定的”(unstable)(可能在编译器不知道的情况下被改变)
2)所有对 volatile 数据的写操作都是可见的,因此他们必须严格执行这些写操作。
3)所有对 volatile 数据的操作都按照他们在源码中的顺序执行
前两条规则保证了正确的读写。最后一条允许IO能够实现输入输出的混合操作。这就是C/C++中的 volatile 关键字所保证的。
Java语言对 volatile 做了进一步的改进,以在多线程环境下也能保证上述性质。这是非常重要的改进,但还不足以使 volatile 用在线程同步上,原因在于 volatile 和 non-volatile 的操作间的相对顺序依然没有明确。而这点遗漏,导致许多变量都得被声明为 volatile 以保证正确的顺序。
Java 1.5 的 volatile 有了更多限制但更简单的 acquire/release 语义,对于volatile的任何读取都保证发生在后面语句中的任何内存引用之前(不管是否volatile),对volatile的任何写入都保证发生在它前面的语句中的所有内存引用之后。.NET也定义了 volatile 来合并多线程语义,和目前 Java 所用的很类似。而目前 C/C++ 的 volatile 还没有类似的改动。
如果你还想了解更多 volatile 相关的知识点,可以访问链接 https://liam.page/2018/01/18/volatile-in-C-and-Cpp/
书接上文,C++标准1.9节提到以下信息:
C++抽象机器的可观测行为是指volatile数据的读写顺序和对 I/O 库函数的调用。
访问一个被指定为 volatile 左值的对象、修改一个对象、调用 I/O 库函数或者调用一个执行这些操作的函数,都被称为副作用(side-effect),即在执行环境下发生的改变。
结合我们早期的观察:
1)语言标准确保所有副作用都会在达到序列点时完成。
2)序列点产生在每个C++语句结束时。
那么,我们想要确保正确的指令顺序,就需要将合适的数据 volatile 化,并谨慎的安排我们的语句顺序。
我们早期的分析显示 pInstance 需要被声明成 volatile,DCLP 相关的论文中也给出了这个结论。然而,为了确保指令顺序,Singleto 对象本身也必须是 volatile。DCLP 原版论文中并没有提及这一点,这是一个重大的疏忽。
为了让大家理接单独将 pInstance 声明为 volatile 的不足,我们看看下面的代码:
class Singleton {
public:
static Singleton* instance();
...
private:
static Singleton* volatile pInstance; // volatile added
int x;
Singleton() : x(5) {}
};
// from the implementation file
Singleton* volatile Singleton::pInstance = 0;
Singleton* Singleton::instance() {
if (pInstance == 0) {
Lock lock;
if (pInstance == 0) {
Singleton* volatile temp = new Singleton; // volatile added
pInstance = temp;
}
}
return pInstance;
}
在内联构造函数之后,代码会看起来像这样:
if (pInstance == 0) {
Lock lock;
if (pInstance == 0) {
Singleton* volatile temp =
static_cast(operator new(sizeof(Singleton)));
temp->x = 5; // inlined Singleton constructor
pInstance = temp;
}
}
尽管 temp 是 valatile 的,但是 *temp 并不是,同理 temp->x 也不是。对一个non-volatile变量赋值可能会被交换顺序,那么显然编译器可能改变 temp->x 赋值与 pInstance 赋值语句的顺序。如果这样,那么 pInstance 就会被赋值一个还未被初始化的数据,再次导致另一个线程可能会读到一个未被初始化的 x。
还有一种看起来完美的解决方法,就是将 *pInstance 也如 pInstance 一样加上 volatile 修饰,也就是将所有出现 Singleton 的地方都加上 volatile,如下:
class Singleton {
public:
static volatile Singleton* volatile instance();
...
private:
// one more volatile added
static volatile 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
volatile Singleton* volatile temp =
new volatile Singleton;
pInstance = temp;
}
}
return pInstance;
}
这时可能有人就会问了,为什么 Lock 没有被声明为 volatile?毕竟在把 temp 赋值给 pInstance 之前初始化好 lock 是至关重要的。其实,lock 是由多线程库提供,因此我们能够假设它要么在规范上就有足够的限制,要么就是在它的实现上已经嵌入了足够的魔法(magic),而不需要 volatile 就能有效(work)。我们所知道的所有线程库都是这样的。本质上,使用线程库中的实体(例如,对象,函数等)都会在程序中强行引入“硬序列点(hard sequence point)”,适用于所有线程的序列点。就这篇文章而言,我们假设这些“硬序列点”在代码优化指令顺序时都能够起到 barrier 的作用:
1)源代码中,在使用库实体之前的代码指令不允许被移动到使用库实体的指令之后。
2)源代码中,在使用库实体之后的代码指令不允许被移动到使用库实体的指令之前。
现实中的线程库没有这么严苛的限制,但是这些细节对本文的讨论来说并不重要。
回到上面的代码,我们已经全部加上了 volatile 修饰,那么按照标准的描述,就应该能够保证在多线程环境下的正确执行了吧?但是,还可能因为两个原因而失败。
第一点,标准仅仅约束的是标准定义的抽象机器的可观测行为,而这个抽象机器并没有多线程执行的概念。这就导致了一个问题,尽管标准阻止了编译器对 volatile 数据的读写操作重新排序,但这仅仅只是单线程的,在跨线程的重新排序上毫无约束力。至少大多数编译器的实现者都是这么说的。因此,事实上,上面的代码在许多编译器上都可能生成线程不安全(thread unsafe)的代码。如果你的多线程代码使用 volatile 能够正常工作,不使用就会出现问题的话,那么要么是你所用的 C++ 实现了支持多线程的 volatile (几乎没可能),要么就是你的运气比较好(大概率)。不管哪种情况,你的代码都是不可移植的。
第二点,就如同 const 修饰的对象得在构造完成之后才变为 const 一样,volatile 修饰的对象在它们的构造函数结束后才变成 volatile 。在下面的代码中:
volatile Singleton* volatile temp = new volatile Singleton;
Singleton对象直到下面语句
new volatile Singleton;
执行完成后,才会成为 volatile 的,这就意味着我们又回到了内存分配和对象初始化孰先孰后的问题上了。尽管有些笨拙,但这个问题我们可以解决。在 Singleton 的构造函数中,我们可以在每一个 Singleton 对象的数据成员初始化时,使用 cast 临时增加 volatile 修饰,以此来阻止执行初始化时相关指令的移动。比如,像下面这样写。(为了简化演示,我们沿用了之前的代码,使用了赋值语句,而不是成员初始化列表。这个改动对我们解决这个问题没有任何影响)。
Singleton()
{
static_cast(x) = 5; // note cast to volatile
}
将这个函数在 pInstance 加入适当的 volatile 修饰的版本中内联展开后,我们可以得到如下代码:
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(operator new(sizeof(Singleton)));
static_cast(temp->x) = 5;
pInstance = temp;
}
}
}
这样,x 的赋值就一定会在 pInstance 的赋值之前,因为它们都是 volatile 的。
不幸的是,这一切对于解决第一个问题毫无帮助:C++ 的抽象机器是单线程的,C++编译器总是有可能生成线程不安全的代码。否则,失去优化机制会导致效率的大幅下降。进行这么多讨论过后,我们又回到了原点。但等一等,还有另一个问题,多处理器。
假设你的代码工作在一个多处理器的机器上,每个处理器都有自己的缓存(cache),但是所有的缓存都共享一个通用的内存空间。这样的一个架构需要明确的定义一个处理器执行的写操作同步到共享内存以对其他处理器可见的方式(how)和时序(when)。很容易就能想象出这样一个场景:一个处理器更新了自己缓存中的共享变量的值,但是并没有刷新到内存中,其他处理器也就更不可能加载到它们的缓存中了。这种缓存间共享变量值不一样的情况被称为缓存一致性问题(cache coherency problem)。
假设处理器 A 修改了共享变量 x 的内存,稍后又改变了共享变量 y 的内存。这些新的改动必须被刷新到主内存中,这样其他处理器才能看到这些变化。但是,在刷新内存时按照内存地址增序会更有效率,因此如果 y 的地址在 x 的地址之前,那么 y 的新值将很可能会早于 x 刷新到主内存。这样其他处理器就会看到 y 在 x 之前被改变。
这样的可能性对于 DCLP 来说是一个严重的问题。正确的 Singleton 初始化要求先初始化 Singleton 对象,再更新 pInstance 为非空(non-null)。如果一个执行在处理器 A 上的线程执行了 step 1 和 step 2,但是处理器B上的一个线程看到的却是 step 2 发生在 step 1 之前,那么处理器B上这个线程可能又引用到一个未初始化的 pInstance。
解决缓存一致性问题一般的解决方案是使用内存屏障(memory barrier):这是一类能够被编译器、链接器和其它优化实体能够识别的指令,这些指令能够约束在多处理器系统中可能出现的对共享内存读、写顺序的重新排序。在 DCLP 而言,我们需要使用 memory barrier 来确保 pInstance 在完成 Singleton 写操作前不会被视作非空(non-null)。下面代码我们只在需要使用 memory barriers 的地方添加了注释,因为实际代码是平台相关的(通常使用汇编)。
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(这个人的介绍看原文吧,这里不赘述)认为这种解决方法过犹不及:从技术上说,你并不需要完全的双向屏障(bidirectional barriers)。第一个 barrier 必须防止 Singleton 的构造函数向下迁移(migration)(由另一个线程)。第二个 barrier 必须防止 pInstance 的赋值向上迁移(migration)。这可以分为 "acquire" 和 "release" 操作,可能比硬件上的完全屏障(full barrier)性能更好。这一段没看明白。
总之,这是一个能够让你在支持 memory barrier 的机器上可靠实现 DCLP 的方法。所有可能重排共享内存写入指令顺序的机器都支持某种形式的 memory barrier。有趣的是,同样的方法在单处理器环境下也能工作。这是因为内存屏障还充当了硬序列点(hard sequence point)的角色,可以防止那种非常麻烦的指令重排序。
经过这么多分析,我们可以了解到:
1)首先,记住,单处理器上的分时并行机制与多处理器的并行机制是不同的。这就是为什么对某个单处理器架构的编译器线程安全的方案,在多线程架构上并不一定是线程安全的,即使你使用同样的编译器。(这是一个一般性结论,并不是针对 DCLP )
2)其次,尽管从本质上讲 DCLP 并不局限于单例模式,但是使用单例模式往往会引发通过 DCLP “优化”线程安全访问的欲望(desire)。现在你应该避免使用 DCLP 实现单例模式。如果你(或者你的客户)担心每次 getInstance 调用都需要同步加锁导致的性能问题,你可以建议客户将 getInstance 返回的指针缓存下来。比如,建议他不要写这样的代码:
Singleton::instance()->transmogrify();
Singleton::instance()->metamorphose();
Singleton::instance()->transmute();
而是写为:
Singleton* const instance = Singleton::instance(); // cache instance pointer
instance->transmogrify();
instance->metamorphose();
instance->transmute();
所以最好就是在每个需要使用 Singleton 对象的线程开始时,只调用一次 getInstance,将返回的指针缓存在线程的局部存储中。这样每个线程只需要一次 lock 的访问花销。在此之前,最好验证一下这样是否会影响性能。使用线程库的锁来保证线程安全的 Singleton 对象初始化,然后计时,观察这样的花销是否值得我们担心。
3)避免使用 lazily-initialized 方式,除非你真正的需要。单例模式的经典实现就是在资源被请求前不初始化资源(not initializing a resource until that resource is requested)。另一种方法是使用 eager initialization 来替代,比如在程序开始运行时就将资源初始化。因为多线程程序在开始的时候都只有一个线程,这个方法可以将一些对象的初始化代码都放在这个单线程启动的部分,这样就不用担心多线程所引起的初始化问题了。在很多情况下,这是最简单的方法来提供高速且线程安全的单例对象访问。
4)还有另一种实现 eager initialization 的方法,就是使用单一状态模式( Monostate Pattern)替代单例模式。不过,Monostate 有另外的问题,特别是它控制非局部静态对象初始化顺序的时候。 Effective C++ 中描述了这个问题,但是尴尬的是,书中给出的建议是使用单例模式来避免。
5)另一种可能,是每个线程里使用一个局部单例来替代全局单例,在线程内部存储单例数据。这样就可以使用 lazy-initialization 而不用担心线程问题,但是这也就意味着在一个多线程程序中,会有多个 单例(Singleton) 对象。
最后,DCLP 和它在C/C++中遇到的问题说明了在一种没有线程(或者其他并发机制)概念的语言中编写线程安全代码的内在困难。编程时对多线程的考量是普遍的,因为它们是影响代码生成的核心。正如 Peter Buhr 的观点,希望将多线程从语言中分离出来,并将其隐藏在库中,这是一种妄想。这样做的结果就是:
1)要么库在编译器生成代码时加入约束。(pthreads 库就是这样做的)
2)要么编译器和其他代码生成工具被禁止执行有用的优化,即使是单线程代码。
多线程的编程语言、无线程的编程语言和经过优化的代码,你只能选择两个。比如,Java 和 .NET CLI 解决的办法就是将线程概念加入到语言和语言基础结果中。