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

科技   2024-11-22 11:30   河北  

最近有读者和我描述他最近的一次面试,他说面试官不讲武德,我问发生了啥,原来是这么回事:

读者(我们简称为A)去面试,开始问了一些C++多态方面的,回答一切顺利,后面就情况不对了。


开始



面试官: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。

我们把汇编代码丢给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工具确实强大,直接反推出来了函数。好了,我们言归正传,看一下静态局部变量:

写如下代码:

右边汇编代码如下:

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

换成CLang,发现多了一个global_var_init,但是这个和static没关系,这个是<iostream>会导致生成的。

可以发现,对于int类型,不好观察。因为int类型太简单了,编译器可能执行了优化处理。下一步我们把int类型换成string类型再看看。

汇编代码,有点长不好截图

.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_string<char, std::char_traits<char>, std::allocator<char> >::basic_string<std::allocator<char> >(char const*, std::allocator<char> const&)        mov     edx, OFFSET FLAT:__dso_handle        mov     esi, OFFSET FLAT:func[abi:cxx11]()::key        mov     edi, OFFSET FLAT:std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::~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_allocator<char>::~__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_string<char, std::char_traits<char>, std::allocator<char> >::basic_string(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) [complete object constructor]        jmp     .L11        mov     rbx, rax        lea     rax, [rbp-25]        mov     rdi, rax        call    std::__new_allocator<char>::~__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: AtomicInt<uint8_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

template<class 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嵌入式所有知识点-思维导图


一口Linux
《从零开始学ARM》作者,一起学习嵌入式,Linux,网络,驱动,arm知识。
 最新文章