单例模式 [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 指向它。应该清楚这违反了单例的含义,因为现在有两个单例对象。
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 旨在防止您不得不这样做。
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 期间执行的机器指令以可接受的顺序执行。 我们在这里关注的正是这个基本问题。
此语句导致发生三件事:
至关重要的是观察编译器不限于按此顺序执行这些步骤!特别是,有时允许编译器交换第 2 步和第 3 步。他们为什么要这样做是我们稍后将解决的问题。现在,让我们关注如果他们这样做会发生什么。
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;
}
鉴于上述翻译,请考虑以下事件序列:
只有在执行步骤 3 之前完成步骤 1 和 2 时,DCLP 才会起作用,但无法在 C 或 C++ 中表达此约束。这就是 DCLP 核心的匕首:我们需要定义相对指令顺序的约束,但我们的语言没有给我们表达约束的方法。
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 调用的其他一些函数的结果。
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 extern 并在单独的翻译单元中定义它,从而防止您的编译器看到您在做什么。唉,有些编译器具有夜视镜的优化功能:它们执行过程间分析,发现你对 temp 的诡计,然后再次优化它以使其不存在。请记住,这些是优化编译器。他们应该追踪不必要的代码并消除它。
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;
}
}
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;
}
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++ 编译器可能会选择从源代码生成线程不安全的代码,无论如何。 否则,失去优化机会会导致效率损失过大。 经过所有的讨论,我们回到第一方。 但是等等,还有更多。 更多处理器。
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;
}
这里有几个教训需要学习。 首先,请记住单处理器上基于时间片的并行性与跨多个处理器的真正并行性不同。 这就是为什么单处理器架构上特定编译器的线程安全解决方案在多处理器架构上可能不是线程安全的,即使您坚持使用相同的编译器也是如此。 (这是一般观察。它不是特定于 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]。
本文的出版前草稿由 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 的评论。
Scott Meyers 撰写了三本 Effective C++ 书籍,并且是 Addison-Wesley Effective Software Development Series 的顾问编辑。 他目前的兴趣集中在确定提高软件质量的基本原则。他的网站是 http://aristeia.com. Andrei Alexandrescu 是 Modern C++ Design 和众多文章的作者,其中大部分是作为 CUJ 专栏作家撰写的。 他攻读博士学位。 华盛顿大学学位,专攻编程语言。 他的网站是 http://moderncppdesign.com .
要找到 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 上没有类似的工作。
[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.