单例模式:线程安全的初始化

开始研究之前,说明一下:我个人并不提倡使用单例模式。

对于单例模式的看法

我只在案例研究中使用单例模式,因为它是以线程安全的方式,初始化变量的典型例子。先来了解一下单例模式的几个严重缺点:

  • 单例是一个经过乔装打扮的全局变量。因此,测试起来非常困难,因为它依赖于全局的状态。
  • 通过MySingleton::getInstance()可以在函数中使用单例,不过函数接口不会说明内部使用了单例,并隐式依赖于对单例。
  • 若将静态对象xy放在单独的源文件中,并且这些对象的构造方式相互依赖,因为无法保证先初始化哪个静态对象,将陷入静态初始化混乱顺序的情况。这里要注意的是,单例对象是静态对象
  • 单例模式是惰性创建对象,但不管理对象的销毁。如果不销毁不需要的东西,那就会造成内存泄漏。
  • 试想一下,当子类化单例化,可能实现吗?这意味着什么?
  • 想要实现一个线程安全且快速的单例,非常具有挑战性。

关于单例模式的详细讨论,请参阅Wikipedia中有关单例模式的文章。

我想在开始讨论单例的线程安全初始化前,先说点别的。

双重检查的锁定模式

双重检查锁定模式是用线程安全的方式,初始化单例的经典方法。听起来像是最佳实践或模式之类的方法,但更像是一种反模式。它假设传统实现中有相关的保障机制,而Java、C#或C++内存模型不再提供这种保障。这样,创建单例是原子操作就是一个错误的假设,这样看起来是线程安全的解决方案并不安全。

什么是双重检查锁定模式?实现线程安全单例的,首先会想到用锁来保护单例的初始化过程。

  1. std::mutex myMutex;
  2. class MySingleton {
  3. public:
  4. static MySingleton& getInstance() {
  5. std::lock_guard<mutex> myLock(myMutex);
  6. if (!instance) instance = new MySingleton();
  7. return *instance;
  8. }
  9. private:
  10. MySingleton() = default;
  11. ~MySingleton() = default;
  12. MySingleton(const MySingleton&) = delete;
  13. MySingleton& operator= (const MySingleton&) = delete;
  14. static MySingleton* instance;
  15. };
  16. MySingleton* MySingleton::instance = nullptr;

程序有毛病么?有毛病:是因为性能损失太大;没毛病:是因为实现的确线程安全。第7行的锁会对单例的每次访问进行保护,这也适用于读取。不过,构造MySingleton之后,就没有必要读取了。这里双重检查锁定模式就发挥了其作用,再看一下getInstance函数。

  1. static MySingleton& getInstance() {
  2. if (!instance) { // check
  3. lock_guard<mutex> myLock(myMutex); // lock
  4. if (!instance) instance = new MySingleton(); // check
  5. }
  6. return *instance;
  7. }

第2行没有使用锁,而是使用指针比较。如果得到一个空指针,则申请锁的单例(第3行)。因为,可能有另一个线程也在初始化单例,并且到达了第2行或第3行,所以需要额外的指针在第4行进行比较。顾名思义,其中两次是检查,一次是锁定。

牛B不?牛。线程安全?不安全。

问题出在哪里?第4行中的instance= new MySingleton()至少包含三个步骤:

  1. MySingleton分配内存。
  2. 初始化MySingleton对象。
  3. 引用完全初始化的MySingleton对象。

能看出问在哪了么?

C++运行时不能保证这些步骤按顺序执行。例如,处理器可能会将步骤重新排序为序列1、3和2。因此,在第一步中分配内存,在第二步中实例引用一个非初始化的单例。如果此时另一个线程t2试图访问该单例对象并进行指针比较,则比较成功。其结果是线程t2引用了一个非初始化的单例,并且程序行为未定义。

性能测试

我要测试访问单例对象的开销。对引用测试时,使用了一个单例对象,连续访问4000万次。当然,第一个访问的线程会初始化单例对象,四个线程的访问是并发进行的。我只对性能数字感兴趣,因此我汇总了这四个线程的执行时间。使用一个带范围(Meyers Singleton)的静态变量、一个锁std::lock_guard、函数std::call_oncestd::once_flag以及具有顺序一致和获取-释放语义的原子变量进行性能测试。

程序在两台电脑上运行。读过上一节的朋友肯定知道,我的Linux(GCC)电脑上有四个核心,而我的Windows(cl.exe)电脑只有两个核心,用最大级别的优化来编译程序。相关设置的详细信息,参见本章的开头。

接下来,需要回答两个问题:

  1. 各种单例实现的性能具体是多少?
  2. Linux (GCC)和Windows (cl.exe)之间的差别是否显著?

最后,我会将所有数字汇总到一个表中。

展示各种多线程实现的性能数字前,先来看一下串行的代码。C++03标准中,getInstance方法线程不安全。

  1. // singletonSingleThreaded.cpp
  2. #include <chrono>
  3. #include <iostream>
  4. constexpr auto tenMill = 10000000;
  5. class MySingLeton {
  6. public:
  7. static MySingLeton& getInstance() {
  8. static MySingLeton instance;
  9. volatile int dummy{};
  10. return instance;
  11. }
  12. private:
  13. MySingLeton() = default;
  14. ~MySingLeton() = default;
  15. MySingLeton(const MySingLeton&) = delete;
  16. MySingLeton& operator=(const MySingLeton&) = delete;
  17. };
  18. int main() {
  19. constexpr auto fourtyMill = 4 * tenMill;
  20. const auto begin = std::chrono::system_clock::now();
  21. for (size_t i = 0; i <= fourtyMill; ++i) {
  22. MySingLeton::getInstance();
  23. }
  24. const auto end = std::chrono::system_clock::now() - begin;
  25. std::cout << std::chrono::duration<double>(end).count() << std::endl;
  26. }

作为参考实现,我使用了以Scott Meyers命名的Meyers单例。这个实现的优雅之处在于,第11行中的singleton对象是一个带有作用域的静态变量,实例只初始化一次,而初始化发生在第一次执行静态方法getInstance(第10 - 14行)时。

使用volatile声明变量dummy

当我用最高级别的优化选项来编译程序时,编译器删除了第30行中的MySingleton::getInstance(),因为调用不调用都没有效果,我得到了非常快的执行,但结果错误的性能数字。通过使用volatile声明变量dummy(第12行),明确告诉编译器不允许优化第30行中的MySingleton::getInstance()调用。

下面是单线程用例的性能结果。

单例模式:线程安全的初始化 - 图1

C++11中,Meyers单例已经线程安全了。

线程安全的Meyers单例

C++11标准中,保证以线程安全的方式初始化具有作用域的静态变量。Meyers单例使用就是有作用域的静态变量,这样就成了!剩下要做的工作,就是为多线程用例重写Meyers单例。

多线程中的Meyers单例

  1. // singletonMeyers.cpp
  2. #include <chrono>
  3. #include <iostream>
  4. #include <future>
  5. constexpr auto tenMill = 10000000;
  6. class MySingLeton {
  7. public:
  8. static MySingLeton& getInstance() {
  9. static MySingLeton instance;
  10. volatile int dummy{};
  11. return instance;
  12. }
  13. private:
  14. MySingLeton() = default;
  15. ~MySingLeton() = default;
  16. MySingLeton(const MySingLeton&) = delete;
  17. MySingLeton& operator=(const MySingLeton&) = delete;
  18. };
  19. std::chrono::duration<double> getTime() {
  20. auto begin = std::chrono::system_clock::now();
  21. for (size_t i = 0; i <= tenMill; ++i) {
  22. MySingLeton::getInstance();
  23. }
  24. return std::chrono::system_clock::now() - begin;
  25. }
  26. int main() {
  27. auto fut1 = std::async(std::launch::async, getTime);
  28. auto fut2 = std::async(std::launch::async, getTime);
  29. auto fut3 = std::async(std::launch::async, getTime);
  30. auto fut4 = std::async(std::launch::async, getTime);
  31. const auto total = fut1.get() + fut2.get() + fut3.get() + fut4.get();
  32. std::cout << total.count() << std::endl;
  33. }

函数getTime中使用单例对象(第24 - 32行),函数由第36 - 39行中的四个promise来执行,相关future的结果汇总在第41行。

单例模式:线程安全的初始化 - 图2

我们来看看最直观的方式——锁。

std::lock_guard

std::lock_guard中的互斥量,保证了能以线程安全的方式初始化单例对象。

  1. // singletonLock.cpp
  2. #include <chrono>
  3. #include <iostream>
  4. #include <future>
  5. #include <mutex>
  6. constexpr auto tenMill = 10000000;
  7. std::mutex myMutex;
  8. class MySingleton {
  9. public:
  10. static MySingleton& getInstance() {
  11. std::lock_guard<std::mutex> myLock(myMutex);
  12. if (!instance) {
  13. instance = new MySingleton();
  14. }
  15. volatile int dummy{};
  16. return *instance;
  17. }
  18. private:
  19. MySingleton() = default;
  20. ~MySingleton() = default;
  21. MySingleton(const MySingleton&) = delete;
  22. MySingleton& operator=(const MySingleton&) = delete;
  23. static MySingleton* instance;
  24. };
  25. MySingleton* MySingleton::instance = nullptr;
  26. std::chrono::duration<double> getTime() {
  27. auto begin = std::chrono::system_clock::now();
  28. for (size_t i = 0; i <= tenMill; ++i) {
  29. MySingleton::getInstance();
  30. }
  31. return std::chrono::system_clock::now() - begin;
  32. }
  33. int main() {
  34. auto fut1 = std::async(std::launch::async, getTime);
  35. auto fut2 = std::async(std::launch::async, getTime);
  36. auto fut3 = std::async(std::launch::async, getTime);
  37. auto fut4 = std::async(std::launch::async, getTime);
  38. const auto total = fut1.get() + fut2.get() + fut3.get() + fut4.get();
  39. std::cout << total.count() << std::endl;
  40. }

这种方式非常的慢。

单例模式:线程安全的初始化 - 图3

线程安全单例模式的下一个场景,基于多线程库,并结合std::call_oncestd::once_flag

使用std::once_flag的std::call_once

std::call_oncestd::once_flag可以一起使用,以线程安全的方式执行可调用对象。

  1. // singletonCallOnce.cpp
  2. #include <chrono>
  3. #include <iostream>
  4. #include <future>
  5. #include <mutex>
  6. #include <thread>
  7. constexpr auto tenMill = 10000000;
  8. class MySingleton {
  9. public:
  10. static MySingleton& getInstance() {
  11. std::call_once(initInstanceFlag, &MySingleton::initSingleton);
  12. volatile int dummy{};
  13. return *instance;
  14. }
  15. private:
  16. MySingleton() = default;
  17. ~MySingleton() = default;
  18. MySingleton(const MySingleton&) = delete;
  19. MySingleton& operator=(const MySingleton&) = delete;
  20. static MySingleton* instance;
  21. static std::once_flag initInstanceFlag;
  22. static void initSingleton() {
  23. instance = new MySingleton;
  24. }
  25. };
  26. MySingleton* MySingleton::instance = nullptr;
  27. std::once_flag MySingleton::initInstanceFlag;
  28. std::chrono::duration<double> getTime() {
  29. auto begin = std::chrono::system_clock::now();
  30. for (size_t i = 0; i <= tenMill; ++i) {
  31. MySingleton::getInstance();
  32. }
  33. return std::chrono::system_clock::now() - begin;
  34. }
  35. int main() {
  36. auto fut1 = std::async(std::launch::async, getTime);
  37. auto fut2 = std::async(std::launch::async, getTime);
  38. auto fut3 = std::async(std::launch::async, getTime);
  39. auto fut4 = std::async(std::launch::async, getTime);
  40. const auto total = fut1.get() + fut2.get() + fut3.get() + fut4.get();
  41. std::cout << total.count() << std::endl;
  42. }

下面是具体的性能数字:

单例模式:线程安全的初始化 - 图4

继续使用原子变量来实现线程安全的单例。

原子变量

使用原子变量,让实现变得更具有挑战性,我甚至可以为原子操作指定内存序。基于前面提到的双重检查锁定模式,实现了以下两个线程安全的单例。

顺序一致语义

第一个实现中,使用了原子操作,但没有显式地指定内存序,所以默认是顺序一致的。

  1. // singletonSequentialConsistency.cpp
  2. #include <chrono>
  3. #include <iostream>
  4. #include <future>
  5. #include <mutex>
  6. #include <thread>
  7. constexpr auto tenMill = 10000000;
  8. class MySingleton {
  9. public:
  10. static MySingleton& getInstance() {
  11. MySingleton* sin = instance.load();
  12. if (!sin) {
  13. std::lock_guard<std::mutex>myLock(myMutex);
  14. sin = instance.load(std::memory_order_relaxed);
  15. if (!sin) {
  16. sin = new MySingleton();
  17. instance.store(sin);
  18. }
  19. }
  20. volatile int dummy{};
  21. return *instance;
  22. }
  23. private:
  24. MySingleton() = default;
  25. ~MySingleton() = default;
  26. MySingleton(const MySingleton&) = delete;
  27. MySingleton& operator=(const MySingleton&) = delete;
  28. static std::atomic<MySingleton*> instance;
  29. static std::mutex myMutex;
  30. };
  31. std::atomic<MySingleton*> MySingleton::instance;
  32. std::mutex MySingleton::myMutex;
  33. std::chrono::duration<double> getTime() {
  34. auto begin = std::chrono::system_clock::now();
  35. for (size_t i = 0; i <= tenMill; ++i) {
  36. MySingleton::getInstance();
  37. }
  38. return std::chrono::system_clock::now() - begin;
  39. }
  40. int main() {
  41. auto fut1 = std::async(std::launch::async, getTime);
  42. auto fut2 = std::async(std::launch::async, getTime);
  43. auto fut3 = std::async(std::launch::async, getTime);
  44. auto fut4 = std::async(std::launch::async, getTime);
  45. const auto total = fut1.get() + fut2.get() + fut3.get() + fut4.get();
  46. std::cout << total.count() << std::endl;
  47. }

与双重检查锁定模式不同,由于原子操作的默认是顺序一致的,现在可以保证第19行中的sin = new MySingleton()出现在第20行instance.store(sin)之前。看一下第17行:sin = instance.load(std::memory_order_relax),因为另一个线程可能会在第14行第一个load和第16行锁的使用之间,介入并更改instance的值,所以这里的load是必要的。

单例模式:线程安全的初始化 - 图5

我们进一步的对程序进行优化。

获取-释放语义

仔细看看之前使用原子实现单例模式的线程安全实现。第14行中单例的加载(或读取)是一个获取操作,第20行中存储(或写入)是一个释放操作。这两种操作都发生在同一个原子上,所以不需要顺序一致。C++11标准保证释放与获取操作在同一原子上同步,并建立顺序约束。也就是,释放操作之后,不能移动之前的所有读和写操作,并且在获取操作之前不能移动之后的所有读和写操作。

这些都是实现线程安全单例的最低保证。

  1. // singletonAcquireRelease.cpp
  2. #include <chrono>
  3. #include <iostream>
  4. #include <future>
  5. #include <mutex>
  6. #include <thread>
  7. constexpr auto tenMill = 10000000;
  8. class MySingleton {
  9. public:
  10. static MySingleton& getInstance() {
  11. MySingleton* sin = instance.load(std::memory_order_acquire);
  12. if (!sin) {
  13. std::lock_guard<std::mutex>myLock(myMutex);
  14. sin = instance.load(std::memory_order_release);
  15. if (!sin) {
  16. sin = new MySingleton();
  17. instance.store(sin);
  18. }
  19. }
  20. volatile int dummy{};
  21. return *instance;
  22. }
  23. private:
  24. MySingleton() = default;
  25. ~MySingleton() = default;
  26. MySingleton(const MySingleton&) = delete;
  27. MySingleton& operator=(const MySingleton&) = delete;
  28. static std::atomic<MySingleton*> instance;
  29. static std::mutex myMutex;
  30. };
  31. std::atomic<MySingleton*> MySingleton::instance;
  32. std::mutex MySingleton::myMutex;
  33. std::chrono::duration<double> getTime() {
  34. auto begin = std::chrono::system_clock::now();
  35. for (size_t i = 0; i <= tenMill; ++i) {
  36. MySingleton::getInstance();
  37. }
  38. return std::chrono::system_clock::now() - begin;
  39. }
  40. int main() {
  41. auto fut1 = std::async(std::launch::async, getTime);
  42. auto fut2 = std::async(std::launch::async, getTime);
  43. auto fut3 = std::async(std::launch::async, getTime);
  44. auto fut4 = std::async(std::launch::async, getTime);
  45. const auto total = fut1.get() + fut2.get() + fut3.get() + fut4.get();
  46. std::cout << total.count() << std::endl;
  47. }

获取-释放语义与顺序一致内存序有相似的性能。

单例模式:线程安全的初始化 - 图6

x86体系结构中这并不奇怪,这两个内存顺序非常相似。我们可能会在ARMv7PowerPC架构上的看到性能数字上的明显差异。对这方面比较感兴趣的话,可以阅读Jeff Preshings的博客Preshing on Programming,那里有更详细的内容。

各种线程安全单例实现的性能表现总结

数字很明确,Meyers 单例模式是最快的。它不仅是最快的,也是最容易实现的。如预期的那样,Meyers单例模式比原子模式快两倍。锁的量级最重,所以最慢。std::call_once在Windows上比在Linux上慢得多。

操作系统(编译器) 单线程 Meyers单例 std::lock_guard std::call_once 顺序一致 获取-释放语义
Linux(GCC) 0.03 0.04 12.48 0.22 0.09 0.07
Windows(cl.exe) 0.02 0.03 15.48 1.74 0.07 0.07

关于这些数字,我想强调一点:这是四个线程的性能总和。因为Meyers单例模式几乎与单线程实现一样快,所以并发Meyers单例模式具有最佳的性能。