(learn&think)

不浮躁,不自傲,学习,思考,总结

Double-Checked Locking Works in C++11

| Comments

浅谈设计模式六: 单例模式(Singleton) 中提到 double-checked locking pattern(DCLP)来实现 Singleton 设计模式,但是在 C++11 之前,没有安全方法在可移植的 C++中去实现它.具体原因可见 单例模式(Singleton) 或 Scott Meyers 和 Andrei Alexandrescu 发布的原文 “C++ and the Perils of Double-Checked Locking”

C++11 引入了新的内存模型和线程库,使得能在 C++中实现可移植的 DCLP.本文说明如何实现它.

什么是 Double-Checked Locking

单例模式(Singleton) 很好的介绍什么是 DCLP,这里稍作回顾.

线程安全的方式实现 Signleton 模式如下:

singleton.cc
1
2
3
4
5
6
7
Singleton* Singleton::instance() {
  Lock lock;    // acquire lock (params omitted for simplicity)
  if(pInstance == NULL) {
    pInstance = new Singleton();
  }
  return pInstance;
  }  // release lock (via Lock destructor)

每次获取 Singleton 都要获取一个锁,但是实际上,我们只有当初始化 pInstance 时才需要一个锁。也就是只发生在第一次调用 instance 时。如果在一个程序运行时, instance 被调用了 n 次,我们只需要锁在第一次调用时。当我们知道那 n-1 次锁是没必要的.

DCLP 的关键点是发现,大多数 instance 的调用将看到 pInstance 是非空的,因此根本没必要去尝试初始化它。因此,DCLP 判断 pInstance 是否为空在尝试获取锁前。只有当判断成功( pInstance 还没有被初始化)才去获取锁,然后之后这个判断在此进行一次确保 pInstance 是仍然空的。(所以名字叫双重检查锁)。第二个检查是有必要的,因为从上可以看到,另外的线程可能碰巧初始化了 pInstance 在 pInstance 被第一次判断和获取锁之间。

singleton-dclp.cc
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Singleton* Singleton::instance() {
  Singleton *tmp = pInstance;
  ...  // need memory barrier
  if(tmp == 0) { // 1st test
  Lock lock;
  tmp = pInstance;
  if(tmp == 0) { // 2nd test
    tmp  = new Singleton;
  ...  // need memory barrier
    pInstance = tmp;
  }
  }
return pInstance;
}

单例模式(Singleton) 说明了各种不安全实现的缺陷,主要原因是 1) 编译器的乱序编译 和 2) CPU 的乱序执行指令.所以安全的实现依靠 memory barrier,防止它们的乱序,使得在多线程中得到同步,C++11 之前没有可移植的 C/C++函数,但现在,C++11 有了.

使用 C++11 的 Acqure 和 Release Fence

使用 Acqure 和 Release Fence 来实现它,并且保证对实例pInstance进行原子操作,把它定义为atomic类型,并用memory_order_relaxed操作.(Relaxed ordering: there are no synchronization or ordering constraints, only atomicity is required of this operation.)如下实现代码.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::atomic<Singleton *> Singleton::m_pInstance;
std::mutex Singleton::m_mutex;

Singleton* Singleton::instance() {
  Singleton *tmp = m_pInstance.load(std::memory_order_relaxed);
  std::atomic_thread_fence(std::memory_order_acquire);
  if(tmp == nullptr) {
    std::lock_guard<std::mutex> lock(m_mutex);
    tmp = m_pInstance.load(std::memory_order_relaxed);
    if(tmp == nullptr) {
      tmp  = new Singleton;
      std::atomic_thread_fence(std::memory_order_release);
      m_pInstance.store(tmp, std::memory_order_relaxed);
    }
  }
  return m_pInstance;
}

在多核系统中,这整个代码也是稳健的,因为 memory fences 在多个线程间建立了同步的关系.Singleton::m_pInstance作为 guard variable,singleton 变量自身成为 payload.

如果没有这层同步关系的话,就不能保证第一个线程的所有写操作(这里就是 singleton 实力的创建)被第二个线程读取到,即使m_pInstance已经被第二个线程能看到.

使用 C++11 的底层的内存顺序约束在 C++11 中也可以在单元操作时附加底层的内存顺序约束来达到同样的目的.一个

write-release 能同步于一个 read-release.

  1. memory_order_acquire: A load operation with this memory order performs the acquire operation on the affected memory location: prior writes made to other memory locations by the thread that did the release become visible in this thread.

  2. memory_order_release: A store operation with this memory order performs the release operation: prior writes to other memory locations become visible to the threads that do a consume or an acquire on the same location.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::atomic<Singleton *> Singleton::m_pInstance;
std::mutex Singleton::m_mutex;

Singleton* Singleton::instance() {
  Singleton *tmp = m_pInstance.load(std::memory_order_acquire);
  if(tmp == nullptr) {
    std::lock_guard<std::mutex> lock(m_mutex);
    tmp = m_pInstance.load(std::memory_order_relaxed);
    if(tmp == nullptr) {
      tmp  = new Singleton;
      m_pInstance.store(tmp, std::memory_order_release);
    }
  }
  return m_pInstance;
}

从深层分析来看,这种形式的免锁机制的同步比上面单独 memory fences 来的约束更小.这种形式的操作只意味在这个操作周围防止内存乱序,而 memory fences 意味着在一块区域内防止内存乱序.更多细节参考 preshing 的 Acquire and Release Fences Don’t Work the Way You’d Expect 的分析. ## 使用 C++11 的 Sequentially-consistent ordering C++11 还提供了其他的方法来写 lock-free 的代码.当在 atomic 操作函数中忽略 std::memory_order参数项,那么默认值是std::memory_order_seq_cst,使得所有原子参数成为 sequentically consistent(SC) 原子.通过 SC 原子性,整个算法保证 sequentically consistent 只要没有 data races.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::atomic<Singleton *> Singleton::m_pInstance;
std::mutex Singleton::m_mutex;

Singleton* Singleton::instance() {
  Singleton *tmp = m_pInstance.load();
  std::atomic_thread_fence(std::memory_order_acquire);
  if(tmp == nullptr) {
    std::lock_guard<std::mutex> lock(m_mutex);
    tmp = m_pInstance.load(std::memory_order_relaxed);
    if(tmp == nullptr) {
      tmp  = new Singleton;
      std::atomic_thread_fence(std::memory_order_release);
      m_pInstance.store(tmp);
    }
  }
  return m_pInstance;
}

SC 的原子性可能更容易理解.权衡点就是它产生的机器代码没有之前做法的高效.比如如下是 Gcc 4.8.2 intle X64 对上面代码产生的机器代码,通过g++ -O2 -std=c++11 -S.

因为使用了 SC 原子性,对m_pInstance的存储实现使用了mfence指令,起到一个在 X64 上的 full memory fence.这是个更严格的指令想对于 DCLP 在 X64 上的实际需求.一个普通的mov足以胜任.但也无关紧要,因为mfence指令也仅仅执行一次而已,就在创建 singleton 的实例的代码路径上.

More

使用 Preshing 的小型可移植的 lock-free 库,在没有 C++11 的支持下,使用它的 Mintomic Fences 实现 DCLP.

更多关于 C++11 的 multithreading 库的详解见之后的文章.

Comments