首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >面试题:局部静态变量如何保证只被初始化一次?

面试题:局部静态变量如何保证只被初始化一次?

作者头像
程序员的园
发布2025-02-08 15:24:48
发布2025-02-08 15:24:48
5810
举报

本文来源于读者朋友的面试真题——局部静态变量如何保证只被初始化一次。这个问题好像最近经常被问到,但是其实并不难的,我们可以借C++的单例模式进行类比分析,为了更加准确的了解其原理,需要结合汇编代码进行分析。让我们一起来分析一下。

从单例模式谈起

C++11以来,单例模式的实现出现了多种方式,常用的线程安全的两种实现方式如下:

  • 使用局部静态变量定义对象,返回变量的引用
  • 使用double check(DLC)机制返回指针

其实现如下:

代码语言:javascript
复制
//局部静态变量
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

为了更直观的看到编译器生成的汇编代码,我们可以借助Compiler Explorer(https://godbolt.org/) 工具,该工具可以让我们直观的看到不同编译器对代码的处理结果。以下是该网站的使用步骤:

  • 进入官网:在浏览器的地址栏输入 https://godbolt.org/, 按下回车键,即可进入网站主页,如下:
  • 输入待分析的代码:在左侧的代码编辑区域输入我们想要分析的代码(本例为C++代码)。以包含局部静态变量的代码为例:
代码语言:javascript
复制
#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 等。建议在分析问题多选择几种不同类型、不同版本的编译器,便于对比分析。
  • 调整编译选项(可选):可以在编译选项输入框内输入编译选项,如优化级别、标准版本等。不同的编译选项会影响编译器生成的代码,通过调整这些选项,我们可以更深入地探索代码在不同条件下的表现。
  • 查看汇编代码:选择好编译器和编译选项后,右侧会实时显示该编译器生成的汇编代码。汇编代码能够看懂可能需要一定的汇编基础,如果不会看,可以将汇编代码交给AI,由AI为我们逐行解释。

分析汇编代码

再次回到我们的问题——局部静态变量如何保证只被初始化一次。我选择了MSVC、Clang、GCC三种编译器,分别生成对应的汇编代码,进行分析,本文以Clang为例进行分析,如下:

代码语言:javascript
复制
_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给出的汇编代码分析如下:

  • 初始化检查阶段:代码首先通过 adrpadd 指令将守护变量(_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工具,可以快速的帮助我们拨开编译器的面纱,见识到代码的真面目。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-02-08,如有侵权请联系 cloudcommunity@tencent.com 删除
目录
  • 从单例模式谈起
  • 代码分析利器:Compiler Explorer
  • 分析汇编代码
  • 总结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档