|
最近有读者和我描述他最近的一次面试,他说面试官不讲武德,我问发生了啥,原来是这么回事:
读者(我们简称为A)去面试,开始问了一些C++多态方面的,回答一切顺利,后面就情况不对了。
开始
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
我们把汇编代码丢给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
右边汇编代码如下:
qzohnov1b5j64012407531.png
看这个汇编代码,看不出来哪里对初始化线程安全有做处理,上面是GCC,我们换成CLang再试一次
bvzzfgw0fpq64012407631.png
换成CLang,发现多了一个global_var_init,但是这个和static没关系,这个是会导致生成的。
可以发现,对于int类型,不好观察。因为int类型太简单了,编译器可能执行了优化处理。下一步我们把int类型换成string类型再看看。
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嵌入式所有知识点-思维导图 |
|