电子产业一站式赋能平台

PCB联盟网

搜索
查看: 106|回复: 0
收起左侧

面试官不讲武德,偷袭我一个大学生:当"我知道答案"遇上"请解释原理"

[复制链接]

317

主题

317

帖子

3149

积分

四级会员

Rank: 4

积分
3149
发表于 2024-11-22 11:30:00 | 显示全部楼层 |阅读模式
最近有读者和我描述他最近的一次面试,他说面试官不讲武德,我问发生了啥,原来是这么回事:
读者(我们简称为A)去面试,开始问了一些C++多态方面的,回答一切顺利,后面就情况不对了。

开始

yalxdlocwkl64012407231.png

yalxdlocwkl64012407231.png

面试官:static局部变量是线程安全的吗?

A(窃喜 这题简单啊):C++11之前不安全,C++11之后是安全的

面试官:好,那你知道原理吗?C++11后是如何实现保证线程安全的吗?

A:此处愣神30秒,(内心:我看的八股文也没讲原理啊)

面试官:可以从GCC、CLang、MSVC 或者其他常用编译器说一个熟悉的就行

A:我尼玛,再次愣神30秒,回答:应该是加了锁吧

面试官:好,那具体是怎么加的?只有一个锁就能实现效果吗?

A:(内心:这面试官不讲武德啊,我不知道啊)回答:我不是很懂原理,我就是知道它线程安全,没有研究过原理。

面试官:嗯哼

A:内心:嗯哼你妹啊

面试官:
C++无锁队列的底层原理是什么?
红黑树原理是什么?
多路复用select原理是什么?
......

A内心直呼:这面试官不讲武德啊,为难我一个没工作的大学生
面试结束

嗯,好吧,面试官确实有点不讲武德,不过现在大环境这么卷,没得办法。
我们就来拆解下:C++11如何保证static局部变量初始化的线程安全?
首先强调一点,C++11保证的只是初始化的安全。
首先,我要推荐一个网站,可以实时的把代码转换为编译器里面的代码
  • https://compiler-explorer.com/我们看个例子,左边代码,右边直接显示汇编代码,非常方便。如果汇编代码看不懂,现在可以丢给AI。

    xxazt4cxr4t64012407331.png

    xxazt4cxr4t64012407331.png

    我们把汇编代码丢给Cursor看看,Cursor回答:
    这是一段 x86_64 汇编代码,让我用中文为您详细解释每一行的含义:
  • func(int):                      # 函数声明,接受一个整数参数        push    rbp             # 保存旧的基址指针        mov     rbp, rsp        # 设置新的基址指针,建立新的栈帧        mov     DWORD PTR [rbp-4], edi   # 将参数(存储在edi寄存器中)存入局部变量        add     DWORD PTR [rbp-4], 20    # 将局部变量的值加20        mov     eax, DWORD PTR [rbp-4]   # 将结果移动到eax寄存器(作为返回值)        pop     rbp             # 恢复旧的基址指针        ret                     # 返回函数这个函数的功能很简单:
    接收一个整数参数
    将这个参数加上20
    返回计算结果
    用C语言表示相当于:
  • int func(int x) {    return x + 20;}
    不得不说AI工具确实强大,直接反推出来了函数。好了,我们言归正传,看一下静态局部变量:
    写如下代码:

    2zmgif1dklw64012407431.png

    2zmgif1dklw64012407431.png

    右边汇编代码如下:

    qzohnov1b5j64012407531.png

    qzohnov1b5j64012407531.png

    看这个汇编代码,看不出来哪里对初始化线程安全有做处理,上面是GCC,我们换成CLang再试一次

    bvzzfgw0fpq64012407631.png

    bvzzfgw0fpq64012407631.png

    换成CLang,发现多了一个global_var_init,但是这个和static没关系,这个是会导致生成的。
    可以发现,对于int类型,不好观察。因为int类型太简单了,编译器可能执行了优化处理。下一步我们把int类型换成string类型再看看。

    ufnof5kcki264012407731.png

    ufnof5kcki264012407731.png

    汇编代码,有点长不好截图
  • .LC0:        .string "Hello"func[abi:cxx11]():        push    rbp        mov     rbp, rsp        push    r12        push    rbx        sub     rsp, 32        mov     QWORD PTR [rbp-40], rdi        movzx   eax, BYTE PTR guard variable for func[abi:cxx11]()::key[rip]        test    al, al        sete    al        test    al, al        je      .L6        mov     edi, OFFSET FLAT:guard variable for func[abi:cxx11]()::key        call    __cxa_guard_acquire        test    eax, eax        setne   al        test    al, al        je      .L6        mov     r12d, 0        lea     rax, [rbp-25]        mov     QWORD PTR [rbp-24], rax        nop        nop        lea     rax, [rbp-25]        mov     rdx, rax        mov     esi, OFFSET FLAT:.LC0        mov     edi, OFFSET FLAT:func[abi:cxx11]()::key        call    std::__cxx11::basic_stringchar, std::char_traitschar>, std::allocatorchar> >::basic_stringstd::allocatorchar> >(char const*, std::allocatorchar> const&)        mov     edx, OFFSET FLAT:__dso_handle        mov     esi, OFFSET FLAT:func[abi:cxx11]()::key        mov     edi, OFFSET FLAT:std::__cxx11::basic_stringchar, std::char_traitschar>, std::allocatorchar> >::~basic_string() [complete object destructor]        call    __cxa_atexit        mov     edi, OFFSET FLAT:guard variable for func[abi:cxx11]()::key        call    __cxa_guard_release        lea     rax, [rbp-25]        mov     rdi, rax        call    std::__new_allocatorchar>::~__new_allocator() [base object destructor]        nop.L6:        mov     rax, QWORD PTR [rbp-40]        mov     esi, OFFSET FLAT:func[abi:cxx11]()::key        mov     rdi, rax        call    std::__cxx11::basic_stringchar, std::char_traitschar>, std::allocatorchar> >::basic_string(std::__cxx11::basic_stringchar, std::char_traitschar>, std::allocatorchar> > const&) [complete object constructor]        jmp     .L11        mov     rbx, rax        lea     rax, [rbp-25]        mov     rdi, rax        call    std::__new_allocatorchar>::~__new_allocator() [base object destructor]        nop        test    r12b, r12b        jne     .L9        mov     edi, OFFSET FLAT:guard variable for func[abi:cxx11]()::key        call    __cxa_guard_abort.L9:        mov     rax, rbx        mov     rdi, rax        call    _Unwind_Resume.L11:        mov     rax, QWORD PTR [rbp-40]        add     rsp, 32        pop     rbx        pop     r12        pop     rbp        ret下面拆解下这份汇编代码,看看能不能窥探到static局部变量的初始化是如何做到线程安全的?
    这段汇编代码展示了C++中静态局部变量的线程安全初始化机制(也称为"Double-Checked Locking Pattern")
    首先看第一次检查:
  • movzx   eax, BYTE PTR guard variable for func[abi:cxx11]()::key[rip]        test    al, al        sete    al        test    al, al        je      .L6这是在检查guard变量,guard variable是一个字节大小的变量,初始值为0,如果已经初始化则跳转到 .L6
    第二次检查:
  • mov     edi, OFFSET FLAT:guard variable for func[abi:cxx11]()::keycall    __cxa_guard_acquiretest    eax, eaxsetne   altest    al, alje      .L6第二次检查是在获取锁,如果guard变量为0,调用__cxa_guard_acquire,__cxa_guard_acquire尝试获取锁,如果获取失败则转到.L6
    __cxa_guard_acquire的实现通常包含:
  • 使用原子操作检查guard变量如果已初始化,返回0如果未初始化,获取互斥锁双重检查(double-check)guard变量返回1表示获得了初始化权限只有一个线程能获得初始化权限,这个线程会:
  • 初始化静态变量调用__cxa_guard_release设置guard变量释放互斥锁其他线程在__cxa_guard_acquire中等待,直到初始化完成

    如果初始化过程发生异常,调用__cxa_guard_abort
  • mov     edi, OFFSET FLAT:guard variable for func[abi:cxx11]()::keycall    __cxa_guard_abort
    这里实现线程安全的关键机制是:

    双重检查
    第一次检查:快速路径,检查guard变量是否已初始化
    第二次检查:通过__cxa_guard_acquire进行加锁检查
    Guard变量
    编译器为每个静态局部变量生成一个guard变量
    用于追踪变量的初始化状态
    关键函数
    __cxa_guard_acquire:获取锁,返回值表示是否需要初始化
    __cxa_guard_release:释放锁,标记初始化完成
    __cxa_guard_abort:初始化失败时的清理
    到这里,应该已经明白了面试官要问的底层原理了。


    上面我们看的是汇编代码,下面我们再从源码看一看,上面汇编看了GCC的,我们源码就看CLang的。
    关于static局部变量初始化线程安全最主要的实现在:
  • https://github.com/llvm/llvm-project/blob/main/libcxxabi/src/cxa_guard.cpphttps://github.com/llvm/llvm-project/blob/main/libcxxabi/src/cxa_guard_impl.h源码中详细解释了guard变量的定义和布局
  • The first "guard byte" (which is checked by the compiler) is set only upon * the completion of cxa release. * * The second "init byte" does the rest of the bookkeeping. It tracks if * initialization is complete or pending, and if there are waiting threads. * * If the guard variable is 64-bits and the platforms supplies a 32-bit thread * identifier, it is used to detect recursive initialization. The thread ID of * the thread currently performing initialization is stored in the second word. * *  Guard Object layout: * --------------------------------------------------------------------------- * | a+0: guard byte | a+1: init byte | a+2: unused ... | a+4: thread-id ... | * ---------------------------------------------------------------------------
    guard对象的内存布局包含:
    guard byte (位于偏移量0): 由编译器检查,只在cxa release完成时设置
    init byte (位于偏移量1): 用于跟踪初始化状态(完成/pending)和等待线程
    unused bytes (位于偏移量2-3): 未使用的字节
    thread-id (位于偏移量4开始): 存储当前执行初始化的线程ID(在64位guard变量且平台提供32位线程ID的情况下使用)
    我们看一下几个核心类:
    GuardByte类
  • struct GuardByte {    GuardByte(uint8_t* const guard_byte_address) : guard_byte(guard_byte_address) {}        bool acquire() {        // 如果guard_byte非0,说明初始化已完成        return guard_byte.load(std::_AO_Acquire) != UNSET;    }        void release() {         guard_byte.store(COMPLETE_BIT, std::_AO_Release);     }        void abort() {} // 终止时不需要做任何事
    private:    AtomicIntuint8_t> guard_byte;};表示guard的不同状态定义:
  • static constexpr uint8_t UNSET = 0;static constexpr uint8_t COMPLETE_BIT = (1 0);static constexpr uint8_t PENDING_BIT = (1 1);static constexpr uint8_t WAITING_BIT = (1 2);工具类 LazyValue
  • templateclass T, T (*Init)()>struct LazyValue {    LazyValue() : is_init(false) {}    T& get() {        if (!is_init) {            value = Init();            is_init = true;        }        return value;    }private:    T value;    bool is_init = false;};LazyValue是一个实现延迟初始化(lazy initialization)的模板工具类。
    主要工作流程:
    1、初始化检查:
    当遇到静态局部变量时,编译器会生成检查代码
    使用guard byte来追踪变量是否已经初始化
    2、线程同步:
    init byte用于处理多线程情况
    可能的状态:
    UNSET:未初始化
    COMPLETE:已完成初始化
    PENDING:正在初始化
    WAITING:有线程等待初始化完成
    3、防止递归初始化:
    使用thread ID来检测是否存在递归初始化
    64位guard变量可以存储32位thread ID
    4、原子操作:
    使用AtomicInt类来确保线程安全
    实现了load、store、exchange等原子操作
    可以看到CLang源码分析实现基本和GCC汇编代码分析是一样的,需要一个守护guard变量加上acquire锁。


    我简单做一个总结,用一句话回答这个问题就是:
    C++11会通过编译器生成的guard变量和调用__cxa_guard_acquire/__cxa_guard_release实现双重检查锁模式(DCLP),确保static局部变量只被初始化一次,其他线程在初始化未完成时会在__cxa_guard_acquire内部等待。
    更细节的部分,各位读者可以去看源码,这篇文章只是抛砖引玉,不过感叹下现在校招太卷了,我知道答案,但是面试官反手问一个原理是啥?IT开发也不再是会用工具会调包就能获得的岗位了。
    end

    一口Linux

    关注,回复【1024】海量Linux资料赠送
    精彩文章合集
    文章推荐
    ?【专辑】ARM?【专辑】粉丝问答?【专辑】所有原创?【专辑】linux入门?【专辑】计算机网络?【专辑】Linux驱动?【干货】嵌入式驱动工程师学习路线?【干货】Linux嵌入式所有知识点-思维导图
  • 回复

    使用道具 举报

    发表回复

    您需要登录后才可以回帖 登录 | 立即注册

    本版积分规则


    联系客服 关注微信 下载APP 返回顶部 返回列表