最近有读者和我描述他最近的一次面试,他说面试官不讲武德,我问发生了啥,原来是这么回事:
读者(我们简称为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工具确实强大,直接反推出来了函数。好了,我们言归正传,看一下静态局部变量:
写如下代码:
右边汇编代码如下:
换成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]()::key
call __cxa_guard_acquire
test eax, eax
setne al
test al, al
je .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]()::key
call __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.cpp
https://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资料赠送
精彩文章合集
文章推荐