
本文来源于读者朋友的面试真题——局部静态变量如何保证只被初始化一次。这个问题好像最近经常被问到,但是其实并不难的,我们可以借C++的单例模式进行类比分析,为了更加准确的了解其原理,需要结合汇编代码进行分析。让我们一起来分析一下。
C++11以来,单例模式的实现出现了多种方式,常用的线程安全的两种实现方式如下:
其实现如下:
//局部静态变量
class SingletonStatic {
public:
static SingletonStatic& getInstance() {
static SingletonStatic instance;
return instance;
}
// 禁止拷贝构造函数和赋值操作符
SingletonStatic(const SingletonStatic&) = delete;
SingletonStatic& operator=(const SingletonStatic&) = delete;
private:
SingletonStatic() {}
};
//DLC方法
SingletonDLC* SingletonDLC::instance = nullptr;
std::mutex SingletonDLC::mtx;
class SingletonDLC {
public:
static SingletonDLC* getInstance() {
if (nullptr == instance)
{
std::lock_guard<std::mutex> lock(mtx);
if (nullptr == instance)
{
auto dlc = new SingletonDLC();
instance = dlc;
}
}
return instance;
}
SingletonDLC(const SingletonDLC&) = delete;
SingletonDLC& operator=(const SingletonDLC&) = delete;
private:
SingletonDLC() {}
static SingletonDLC* instance;
staticstd::mutex mtx;
};
如上的两种方式,都是保证单例模式中对象只被初始化一次,并且保证线程安全。那两者的区别是为:局部静态变量的实现方式是通过C++标准定义编译器实现来保证局部静态变量只被初始化一次;而DLC的实现方式是由开发者自行借助锁和变量检查来实现变量只被初始化一次。若要探究编译器做的工作是否和开发者自行实现逻辑的一样呢,则需要结合汇编代码进行分析。
为了更直观的看到编译器生成的汇编代码,我们可以借助Compiler Explorer(https://godbolt.org/) 工具,该工具可以让我们直观的看到不同编译器对代码的处理结果。以下是该网站的使用步骤:

#include <iostream>
void ShowStatic(int nNum)
{
static int gnNumber = nNum;
printf("%d\n", gnNumber);
}
int main()
{
ShowStatic(20);
ShowStatic(50);
return 0;
}
再次回到我们的问题——局部静态变量如何保证只被初始化一次。我选择了MSVC、Clang、GCC三种编译器,分别生成对应的汇编代码,进行分析,本文以Clang为例进行分析,如下:
_Z10ShowStatici:
.Lfunc_begin0:
sub sp, sp, #32
stp x29, x30, [sp, #16]
add x29, sp, #16
stur w0, [x29, #-4]
.Ltmp0:
adrp x8, _ZGVZ10ShowStaticiE8gnNumber
add x8, x8, :lo12:_ZGVZ10ShowStaticiE8gnNumber
ldarb w8, [x8]
tbnz w8, #0, .LBB0_3
b .LBB0_1
.LBB0_1:
adrp x0, _ZGVZ10ShowStaticiE8gnNumber
add x0, x0, :lo12:_ZGVZ10ShowStaticiE8gnNumber
bl __cxa_guard_acquire
cbz w0, .LBB0_3
b .LBB0_2
.LBB0_2:
ldur w8, [x29, #-4]
adrp x9, _ZZ10ShowStaticiE8gnNumber
str w8, [x9, :lo12:_ZZ10ShowStaticiE8gnNumber]
adrp x0, _ZGVZ10ShowStaticiE8gnNumber
add x0, x0, :lo12:_ZGVZ10ShowStaticiE8gnNumber
bl __cxa_guard_release
b .LBB0_3
.LBB0_3:
adrp x8, _ZZ10ShowStaticiE8gnNumber
ldr w1, [x8, :lo12:_ZZ10ShowStaticiE8gnNumber]
adrp x0, .L.str
add x0, x0, :lo12:.L.str
bl printf
ldp x29, x30, [sp, #16]
add sp, sp, #32
ret
.Ltmp1:
.Lfunc_end0:
AI给出的汇编代码分析如下:
adrp 和 add 指令将守护变量(_ZGVZ10ShowStaticiE8gnNumber)的地址加载到 x8 寄存器中,然后使用 ldarb 指令从该地址加载一个字节的数据到 w8 寄存器。接着,使用 tbnz 指令测试 w8 的第 0 位,如果该位不为 0,说明局部静态变量已经初始化,跳转到 .LBB0_3 标签处(打印输出),跳过初始化过程;如果第 0 位为 0,说明局部静态变量未初始化,跳转到 .LBB0_1 标签处,准备进行初始化。.LBB0_1 标签处,将守护变量的地址加载到 x0 寄存器中,并调用 __cxa_guard_acquire 函数尝试获取守护变量。该函数会检查守护变量的状态,如果未被其他线程锁定且未初始化,则锁定并返回非零值,表示可以进行初始化。通过 cbz 指令检查 __cxa_guard_acquire 的返回值,如果为 0,说明获取守护变量失败(可能被其他线程抢先初始化了),跳转到 .LBB0_3 标签处;如果返回值不为 0,跳转到 .LBB0_2 标签处,开始进行初始化操作。.LBB0_2 标签处,从栈上读取之前保存的函数参数,将局部静态变量(_ZZ10ShowStaticiE8gnNumber)的地址加载到 x9 寄存器中,并将函数参数的值存储到该地址,完成局部静态变量的初始化。之后,再次将守护变量的地址加载到 x0 寄存器中,调用 __cxa_guard_release 函数释放守护变量,表示初始化完成。最后,跳转到 .LBB0_3 标签处。.LBB0_3 标签处,将局部静态变量的值加载到 w1 寄存器中,将格式化字符串的地址加载到 x0 寄存器中,调用 printf 函数打印局部静态变量的值。最后,恢复栈指针和帧指针,从函数返回。结合如上步骤,我们发现编译器为局部静态变量进行了DLC校验,从而保证了局部静态变量只被初始化一次。具体步骤如下:
__cxa_guard_acquire 函数尝试获取守护变量,__cxa_guard_release 函数释放守护变量,表示初始化完成。综上所述,编译器通过引入DLC机制,确保局部静态变量只被初始化一次。即使在多线程环境下,这些机制能有效避免多个线程同时初始化的问题。同时,借助Compiler Explorer和AI工具,可以快速的帮助我们拨开编译器的面纱,见识到代码的真面目。