你的原始代码是安全的。 不要引入额外的间接层(一个指针变量,在
std::map
的地址可用之前必须加载)。
正如Jerry Coffin所说,你的代码必须按照源代码顺序运行。这包括在主函数中运行,就好像已经构造了boost或
std::mutex
和
std::map
,然后才运行后续的线程。
在C++11之前,语言标准和内存模型并没有正式支持多线程,但是像这样的东西(线程安全的
static
局部初始化)仍然可以工作,因为编译器的作者希望他们的编译器有用。例如,2006年的GCC 4.1(
https://godbolt.org/z/P3sjo4Tjd)仍然使用了一个保护变量,以确保在多个对
get()
的调用同时发生时,只有一个线程进行构造。
现在,使用C++11及更高版本,ISO标准确实包括了线程,并且官方要求确保安全性。
由于您的程序无法观察到这种差异,理论上编译器可以选择跳过构造函数,而让第一个调用
get()
的线程来实际发生构造。这没问题,
static
局部变量的构造是线程安全的,像GCC和Clang这样的编译器会在函数开始时使用一个“守卫变量”进行检查(只读,使用
acquire
加载)。
文件作用域的
static
变量将避免每次调用时守卫变量的加载+测试/分支开销,并且只要在
main()
开始之前没有调用
get()
,它就是安全的。守卫变量在像x86、AArch64和32位ARMv8这样的ISA上是相当廉价的,因为它们具有廉价的acquire加载,但在ARMv7等地方则更昂贵,因为acquire加载使用了
dmb ish
全屏障。
如果有一个假设的编译器真的进行了你担心的优化,那么差异可能在于保存静态变量
static C c
的 .bss 页的 NUMA 放置,如果该页中的其他内容没有被首先访问。如果在第二个线程调用
get()
时构造函数还没有完成,可能会短暂地阻塞其他线程的第一次调用
get()
。
当前的GCC和clang在实践中并没有进行这种优化。
Clang 17与libc++在x86-64上使用
-O3
选项生成以下汇编代码(由
Godbolt解析)。
get()
的汇编代码也被内联到
main
中。GCC与libstdc++非常相似,只有在
std::map
内部有所不同。
get():
movzx eax, byte ptr [rip + guard variable for get()::c] # all x86 loads are acquire loads
test al, al # check the guard variable
je .LBB0_1
lea rax, [rip + get()::c] # retval = address of the static variable
# end of the fast path through the function.
# after the first call, all callers go through this path.
ret
# slow path, only reached if the guard variable is zero
.LBB0_1:
push rax
lea rdi, [rip + guard variable for get()::c]
call __cxa_guard_acquire@PLT
test eax, eax # check if we won the race to construct c,
je .LBB0_3 # or if we waited until another thread finished doing it.
xorps xmm0, xmm0
movups xmmword ptr [rip + get()::c+16], xmm0 # first 16 bytes of std::map<int,int> = NULL pointers
movups xmmword ptr [rip + get()::c], xmm0 # std::mutex = 16 bytes of zeros
mov qword ptr [rip + get()::c+32], 0 # another NULL
lea rsi, [rip + get()::c] # arg for __cxa_atexit
movups xmmword ptr [rip + get()::c+48], xmm0 # more zeros, maybe a root node?
lea rax, [rip + get()::c+48]
mov qword ptr [rip + get()::c+40], rax # pointer to another part of the map object
lea rdi, [rip + C::~C() [base object destructor]] # more args for atexit
lea rdx, [rip + __dso_handle]
call __cxa_atexit@PLT # register the destructor function-pointer with a "this" pointer
lea rdi, [rip + guard variable for get()::c]
call __cxa_guard_release@PLT # "unlock" the guard variable, setting it to 1 for future calls
# and letting any other threads return from __cxa_guard_acquire and see a fully-constructed object
.LBB0_3: # epilogue
add rsp, 8
lea rax, [rip + get()::c] # return value, same as in the fast path.
ret
即使
std::map
没有被使用,构造它仍然需要调用
__cxa_atexit
(C++内部版本的
atexit
)来注册析构函数,以便在程序退出时释放红黑树。我怀疑这就是优化器无法理解的部分,也是它不能像
static int x = 123;
或
static void *foo = &bar;
那样在
.data
中预初始化空间而不进行运行时构造(也没有保护变量)的主要原因。
如果
struct C
只包含
std::mutex
,那么常量传播可以避免任何运行时初始化的需要。至少在GNU/Linux中,
std::mutex
没有析构函数并且实际上是零初始化的。(在C++23之前的C++中,即使包含了
可见的副作用,也允许提前初始化。这里没有副作用;编译器仍然可以将
static int local_foo = an_inline_function(123);
常量传播到
.data
中的某些字节,而不进行运行时调用。)
GCC和Clang也不会优化掉保护变量(如果有任何运行时工作要做的话),尽管
main
根本没有启动任何线程,更不用说在调用
get()
之前了。在另一个编译单元(包括共享库)中的构造函数可能已经启动了另一个线程,同时调用了
get()
,就像
main
一样。(使用
gcc -fwhole-program
时,这可能是一个被忽视的优化。)
如果构造函数有任何(潜在的)可见副作用,可能包括对`new`的调用,因为`new`是可替换的,编译器不能推迟它,因为C++语言规则规定了构造函数在抽象机器中的调用时机。(编译器对`new`可以做一些假设,例如,带有libc++的clang可以优化未使用的`std::vector`的`new`/`delete`。)
像`std::unordered_map`这样的类(使用哈希表而不是红黑树)在其构造函数中确实使用了`new`。
我正在测试使用`std::map`,所以单个对象没有可见的副作用析构函数。如果`Foo::~Foo`打印一些东西的`std::map`在静态局部初始化器运行时会有影响,因为那时我们调用`__cxa_atexit`。假设析构顺序与构造相反,等到稍后再调用`__cxa_atexit`可能会导致它被提前析构,从而导致`Foo::~Foo()`调用过早,可能在其他可见副作用之前而不是之后发生。
或者其他全局数据结构可能会引用`std::map`中的`int`对象,并在其析构函数中使用它们。如果我们过早地析构`std::map`,那将不安全。
(我不确定ISO C++或GNU C++是否对析构函数的顺序提供了这样的保证。但如果确实有这样的保证,编译器通常无法推迟涉及注册析构函数的构造。在简单的程序中寻找这种优化并不值得付出编译时间的代价。)
使用文件范围的
static
来避免使用守卫变量。
请注意缺少守卫变量,使得快速路径更快,特别是对于像ARMv7这样没有良好方法来执行仅获取屏障的ISA。
https://godbolt.org/z/4bGx3Tasj -
static C global_c; // It's not actually global, just file-scoped static
C& get2() {
return global_c;
}
get2():
lea rax, [rip + global_c]
ret
main:
xor eax, eax
ret
# GCC -O3 -mcpu=cortex-a15 // a random ARMv7 CPU
get2():
ldr r0, .L81 @ PC-relative load
bx lr
@ somewhere nearby, between functions
.L81:
.word .LANCHOR0+52 @ pointer to struct C global_c
main:
mov r0, #0
bx lr
构造函数代码仍然存在,它执行存储并调用__cxa_atexit
,只是在一个名为_GLOBAL__sub_I_example.cpp:
(clang)或_GLOBAL__sub_I_get():
(GCC)的单独函数中,编译器将其添加到在main
之前调用的初始化函数列表中。
函数范围的局部变量通常都没问题,开销非常小,尤其是在x86-64和ARMv8上。但是,由于您担心像std::map
何时构造的微观优化问题,我认为值得一提。同时,这也展示了编译器在幕后使用的机制,使这些功能正常工作。
C
的构造函数和析构函数是否是平凡的? - James McNellis::get()
的调用如何,它们都以相同的方式被调用,编译器可以正确地优化该调用而不会损害功能。 - SingleNegationEliminationmain()
中的get()
是对get()
的第一个调用;如果在命名空间作用域的静态对象的动态初始化期间调用了get()
,那么这种情况就不成立了。 - James McNellis