C++11
在 C++11 及更高版本中:是的,这种模式是安全的。特别是,函数局部静态变量的初始化是线程安全的,因此您上面的代码可以跨线程安全地工作。
这种在实践中起作用的方式是编译器在函数本身中插入任何必要的样板,以检查变量是否在访问之前被初始化。然而,在,和std::mutex
中实现的情况下,初始化状态是全零,因此不需要显式初始化(变量将存在于全零部分,因此初始化是“自由的”),正如我们从大会1:gcc
clang
icc
.bss
inc(int& i):
mov eax, OFFSET FLAT:_ZL28__gthrw___pthread_key_createPjPFvPvE
test rax, rax
je .L2
push rbx
mov rbx, rdi
mov edi, OFFSET FLAT:_ZZ3incRiE3mtx
call _ZL26__gthrw_pthread_mutex_lockP15pthread_mutex_t
test eax, eax
jne .L10
add DWORD PTR [rbx], 1
mov edi, OFFSET FLAT:_ZZ3incRiE3mtx
pop rbx
jmp _ZL28__gthrw_pthread_mutex_unlockP15pthread_mutex_t
.L2:
add DWORD PTR [rdi], 1
ret
.L10:
mov edi, eax
call _ZSt20__throw_system_errori
请注意,从该行开始,mov edi, OFFSET FLAT:_ZZ3incRiE3mtx
它只是加载inc::mtx
函数局部静态的地址并对其进行调用pthread_mutex_lock
,而无需任何初始化。之前处理的代码pthread_key_create
显然只是检查 pthreads 库是否存在。
但是,不能保证所有实现都将实现std::mutex
为全零,因此在某些情况下,您可能会在每次调用时产生持续开销以检查是否mutex
已初始化。在函数外部声明互斥体可以避免这种情况。
这是一个将这两种方法与具有不可内联构造函数的替代类进行对比的示例mutex2
(因此编译器无法确定初始状态为全零):
#include <mutex>
class mutex2 {
public:
mutex2();
void lock();
void unlock();
};
void inc_local(int &i)
{
// Thread safe?
static mutex2 mtx;
std::unique_lock<mutex2> lock(mtx);
i++;
}
mutex2 g_mtx;
void inc_global(int &i)
{
std::unique_lock<mutex2> lock(g_mtx);
i++;
}
函数本地版本编译(on gcc
)为:
inc_local(int& i):
push rbx
movzx eax, BYTE PTR _ZGVZ9inc_localRiE3mtx[rip]
mov rbx, rdi
test al, al
jne .L3
mov edi, OFFSET FLAT:_ZGVZ9inc_localRiE3mtx
call __cxa_guard_acquire
test eax, eax
jne .L12
.L3:
mov edi, OFFSET FLAT:_ZZ9inc_localRiE3mtx
call _ZN6mutex24lockEv
add DWORD PTR [rbx], 1
mov edi, OFFSET FLAT:_ZZ9inc_localRiE3mtx
pop rbx
jmp _ZN6mutex26unlockEv
.L12:
mov edi, OFFSET FLAT:_ZZ9inc_localRiE3mtx
call _ZN6mutex2C1Ev
mov edi, OFFSET FLAT:_ZGVZ9inc_localRiE3mtx
call __cxa_guard_release
jmp .L3
mov rbx, rax
mov edi, OFFSET FLAT:_ZGVZ9inc_localRiE3mtx
call __cxa_guard_abort
mov rdi, rbx
call _Unwind_Resume
__cxa_guard_*
注意处理函数的大量样板文件。首先,检查相对于 rip 的标志字节_ZGVZ9inc_localRiE3mtx
2,如果不为零,则变量已经初始化,我们完成并进入快速路径。不需要原子操作,因为在 x86 上,加载已经具有所需的获取语义。
如果此检查失败,我们将进入慢速路径,这本质上是一种双重检查锁定形式:初始检查不足以确定变量需要初始化,因为这里可能有两个或多个线程在竞争。该__cxa_guard_acquire
调用执行锁定和第二次检查,并且可能也进入快速路径(如果另一个线程同时初始化对象),或者可能跳转到实际的初始化代码.L12
。
最后请注意,程序集中的最后 5 条指令根本无法从函数直接访问,因为它们前面是无条件的jmp .L3
,并且没有任何东西跳转到它们。mutex2()
如果对构造函数的调用在某个时候抛出异常,它们就会被异常处理程序跳转到。
总的来说,我们可以说第一次访问初始化的运行时成本是低到中等的,因为快速路径只检查一个字节标志而没有任何昂贵的指令(函数本身的其余部分通常意味着至少两个原子操作mutex.lock()
和mutex.unlock()
,但它会显着增加代码大小。
与全局版本相比,它是相同的,只是在全局初始化期间而不是在首次访问之前进行初始化:
inc_global(int& i):
push rbx
mov rbx, rdi
mov edi, OFFSET FLAT:g_mtx
call _ZN6mutex24lockEv
add DWORD PTR [rbx], 1
mov edi, OFFSET FLAT:g_mtx
pop rbx
jmp _ZN6mutex26unlockEv
该函数的大小不到三分之一,根本没有任何初始化样板。
在 C++11 之前
然而,在 C++11 之前,这通常是不安全的,除非您的编译器对静态局部变量的初始化方式做出一些特殊保证。
前段时间,在查看类似问题时,我检查了 Visual Studio 为这种情况生成的程序集。为您的print
方法生成的汇编代码的伪代码如下所示:
void print(const std::string & s)
{
if (!init_check_print_mtx) {
init_check_print_mtx = true;
mtx.mutex(); // call mutex() ctor for mtx
}
// ... rest of method
}
这init_check_print_mtx
是一个编译器生成的特定于该方法的全局变量,它跟踪本地静态是否已被初始化。请注意,在此变量保护的“一次性”初始化块内,该变量在互斥锁初始化之前设置为 true。
我虽然这很愚蠢,因为它确保其他线程竞相进入此方法将跳过初始化程序并使用未初始化的mtx
- 而不是可能多次初始化的替代方案mtx
- 但实际上这样做可以避免无限递归问题如果std::mutex()
要回调打印,则会发生这种情况,而这种行为实际上是标准规定的。
上面的 Nemo 提到,这已在 C++11 中修复(更准确地说,重新指定)要求等待所有赛车线程,这将使其安全,但您需要检查自己的编译器是否合规。我没有检查新规范是否真的包含这个保证,但我一点也不感到惊讶,因为如果没有这个,本地静态在多线程环境中几乎没有用(除了可能没有的原始值)任何检查和设置行为,因为它们只是直接引用 .data 段中已经初始化的位置)。
1请注意,我将该print()
函数更改为一个稍微简单的inc()
函数,该函数仅在锁定区域中增加一个整数。这具有与原始相同的锁定结构和含义,但避免了一堆代码处理<<
运算符和std::cout
.
2使用c++filt
它可以消除对guard variable for inc_local(int&)::mtx
.