编辑
2025-05-26
记录知识
0
请注意,本文编写于 32 天前,最后修改于 32 天前,其中某些信息可能已经过时。

目录

单例代码
多线程环境
double-check
CPU乱序执行
编译器乱序
barrier指令
最优解
总结
参考文章

单例的目的是保证一个类只有一个实例,这样代码在调用的时候不需要频繁的初始化,但是在调试的过程中,单例可能出现因为多线程访问导致的概率性bug,本文主要讨论单例出现的这种问题

单例代码

为了支持单例,最明显的代码就是实现一个getInstance函数,这个函数中在第一次访问的时候new自己的对象,之后所有的调用通过getInstance来调用类的函数,代码示例如下:

GlobalConfig *GlobalConfig::getInstance() { if(instance == nullptr) // a instance = new GlobalConfig; // b return instance; }

多线程环境

对于单例,如果在单线程环境下,上述代码不会有任何问题,如果instance是nullptr,则新建一个,如果不是,则返回这个instance。
但是如果是多线程的情况,满足instance都没有创建的情况下,两个线程同时调用了getInstance函数,那么可能出现:

  • 第一个线程正在执行b位置代码
  • 而第二个线程正在执行a代码

第二个线程判断了instance此时还是nullptr,所以再一次构造了GlobalConfig,这样子我们对这个单例构造了两次。这是不必要的开销。
为了解决多线程环境下出现的单例构造两次的问题,我们可以对这段代码加锁,如下

GlobalConfig *GlobalConfig::getInstance() { LOCK(); if(instance == nullptr) // a instance = new GlobalConfig; // b UNLOCK(); return instance; }

此时我们通过锁解决了上面提到的单例构造两次的问题,因为当第一个线程正在执行时会上锁,这样第二个线程就不会重入代码内部。因为锁也是原子的,所以我们似乎解决了单例构造两次的问题。
但是,上面代码会引入新的问题,我们知道getInstance函数在99%的情况下instance是有值的,而在1%的情况下是需要构造的,这也就导致了这个锁在99%的情况下是不需要的。那么怎么优化这个问题呢?

double-check

对于上面的代码,我们需要解决锁带来的性能开销问题,因为我们不能因为1%的场景去让代码执行99%的任务。所以我们需要进行double-check,那么具体是什么样的呢,如下代码

GlobalConfig *GlobalConfig::getInstance() { if(instance == nullptr) { LOCK(); if(instance == nullptr) // a instance = new GlobalConfig; // b UNLOCK(); } return instance; }

这段代码通过重复检测instance的值,从而避免了99%的不必要加锁操作。它看似会正常工作了。
但是我们对构造GlobalConfig这个类需要再分析一下,它主要做如下事情

  1. 分配内存
  2. 调用构造函数
  3. 将内存地址返回给instance

如果上面步骤1,2,3都是顺序执行的,那么这一切都是正常的,那么假设步骤2和步骤3的顺序颠倒呢?
那么程序是否崩溃就取决于是否访问到没有权限不可访问的区域了,如果访问到了,就崩溃,如果不是,那么可能修改错误的值,或者存在了一个隐藏的bug。

CPU乱序执行

根据上面提到的,步骤2和步骤3在体系架构中真会出现,这主要原因是cpu的乱序执行。

我们知道一条指令在cpu的流水线执行包含如下几个部分

取指---译码---执行---访存---写回

这些步骤是顺序的,但是一般来说CPU会有多级流水线,这样就会出现如下情况。以三级流水线为例

取指---译码---执行---访存---写回 取指---译码---执行---访存---NOP 取指---译码---执行---NOP---写回

而且,我们知道CPU访问内存的速度是慢于CPU自身时钟频率的,所以在取指和访存以及写回三个步骤上会比较慢。所以为了加快CPU的指令执行,CPU乱序执行的就出现了。其含义是:

  • 在指令执行过程中,如果后面的指令不依赖前面的指令,那么就可以将后面的指令提前到前面执行

对于上面的例子,我们知道步骤2是调用构造函数,步骤3是将内存地址返回,对于它们而言,其执行是理论可以乱序的,所以就可以出现运行步骤是 1-->3-->2 这样的现象。
根据上面提到的CPU乱序问题,我们也可以很好解决,那就是让两行代码出现数据依赖就行了,那么代码如下

GlobalConfig *GlobalConfig::getInstance() { if(instance == nullptr) { LOCK(); if(instance == nullptr) // a GlobalConfig* temp = new GlobalConfig; // b instance = temp //c UNLOCK(); } return instance; }

从代码的第b行和第c行看,我们将构造的对象赋值给了temp,然后再将temp传递给instance。它能够完美规避CPU乱序的问题。
那还有什么问题没有呢? 实则不然,我们知道编译器会将指令乱序,这段代码仍可能出现乱序执行的问题。

编译器乱序

虽然我们在代码b和代码c行特地构造了数据依赖,让我们人眼直观来看CPU不会针对这行指令进行乱序了,但是我们知道通常情况下代码编译成汇编的时候,编译器会优化代码,如果关闭优化如O(0),则代码执行效率低,所以通常编译默认的优化等级是O(2)。在默认情况下,上述代码编译器会认为temp是没有意义的变量,故合并两行代码。
这样,我们看似自行做的优化,实际上在编译阶段就已经被编译器进行了负优化了。那么我们只能寻找其他办法。

barrier指令

barrier的含义就是栅栏的意思,它同样适用于编译器,在aarch64中,有三种barrier指令,如下

  • DMB:Data Memory Barrier,针对内存访问,所有数据在DMB之前访问完成,所有数据在DMB之后,不能乱序到DMB之前。
  • DSB:Data Synchronization Barrier,比DMB更严格,对于CPU流水线上的访存,它禁止了DSB之后的访存完全不会开始,也确保了DSB之前的访存会完全结束(访存/回写)
  • ISB:Instruction Synchronization Barrier,从指令角度,在DSB之前的指令必须流水线完全完成,然后清空所有的指令缓存,重新取指执行。

对于上述的问题,我们知道编译器也会执行乱序,所以为了让编译器不做乱序优化,我们在其数据依赖的中间添加任意的barrier指令,避免编译器的错误优化,代码如下

GlobalConfig *GlobalConfig::getInstance() { if(instance == nullptr) { LOCK(); if(instance == nullptr) // a GlobalConfig* temp = new GlobalConfig; // b barrier(); //d instance = temp //c UNLOCK(); } return instance; }

如上,我们添加了第d行,这个barrier可以是任意的barrier指令,例如DMB,它可以使得编译器不进行乱序优化。这样就真正的解决了单例在多线程中的所有问题。

最优解

根据上面的介绍,我们为了解决单例在多线程访问中并发的问题做了很多的思考,使得代码看起来非常的乱,甚至是过度优化,对于这种情况,实际上还有最优解。那就是资源最早初始化。
这里表达的意思就是:

如果把getInstance函数放在初始化的单线程中,这样所有的多线程访问都不可能进入instance == nullptr 的逻辑内。

如果就将约束程序员在代码的第一次进入getInstance时是单线程的,那或许比较困难(谁知道其他程序员怎么想~),那么另一种方法或许风险更小,就是在每个线程开始的时候,主动调用如下

GlobalConfig* const instance = GlobalConfig::getInstance();

这样这个instance的值引用的instance并且保存在cache中。从而使得后面调用所有的getInstance都不需要考虑多线程重入的问题。

总结

本文基于线程安全的角度介绍了单例在多线程中的bug,如果我们为了解决一个一个多线程重入导致的问题,那么代码可读性就会很差,并且容易出现负优化,如果我们在这种情况下,对每个线程的最开始主动增加一次getInstance调用,后面的调用实际上利用了cache,那么反而其整体性能最高。

参考文章

https://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf