在下面的程序中,我尝试使用函数本地互斥对象使print
函数成为线程安全的:
#include <iostream>
#include <chrono>
#include <mutex>
#include <string>
#include <thread>
void print(const std::string & s)
{
// Thread safe?
static std::mutex mtx;
std::unique_lock<std::mutex> lock(mtx);
std::cout <<s << std::endl;
}
int main()
{
std::thread([&](){ for (int i = 0; i < 10; ++i) print("a" + std::to_string(i)); }).detach();
std::thread([&](){ for (int i = 0; i < 10; ++i) print("b" + std::to_string(i)); }).detach();
std::thread([&](){ for (int i = 0; i < 10; ++i) print("c" + std::to_string(i)); }).detach();
std::thread([&](){ for (int i = 0; i < 10; ++i) print("d" + std::to_string(i)); }).detach();
std::thread([&](){ for (int i = 0; i < 10; ++i) print("e" + std::to_string(i)); }).detach();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
这样安全吗?
我怀疑来自this question,它提出了类似的案例。
答案 0 :(得分:18)
在C ++ 11及更高版本中,初始化function-local static variables is thread-safe,因此保证上面的代码是安全的。
这种方法在实践中的作用是编译器在函数本身中插入任何必要的样板,以检查变量是否在访问之前被初始化。如果在std::mutex
,gcc
和clang
中实现了icc
,则初始化状态为 all-zeros ,因此没有明确的需要初始化(变量将存在于全零.bss
部分,因此初始化为#34; free&#34;),正如我们从the assembly 1 看到的那样:
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库is present at all。
但是,并不保证所有实现都会将std::mutex
实现为全零,因此在某些情况下,您可能会在每次调用时产生持续开销,以检查mutex
是否有已初始化。在函数外部声明互斥锁可以避免这种情况。
这里an example将这两种方法与一个带有非可嵌入构造函数的替换mutex2
类进行对比(因此编译器无法确定初始状态是全部-zeros):
#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++;
}
函数本地版本编译(在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上,加载已经具有所需的获取语义。
如果此检查失败,我们转到慢速路径,这实际上是double-checked locking的形式:初始检查不足以确定变量需要初始化,因为两个或更多线程可能在这里竞争。 __cxa_guard_acquire
调用执行锁定和第二次检查,也可以通过快速路径(如果另一个线程同时初始化对象),或者可以跳转到{{1}处的实际初始化代码。 }。
最后请注意,程序集中的最后5条指令根本无法从函数直接到达,因为它们前面是无条件的.L12
,并且没有任何内容跳转到它们。如果对构造函数jmp .L3
的调用在某个时刻抛出异常,它们就会被异常处理程序跳转到那里。
总的来说,我们可以说第一次访问初始化的运行时成本是低到中等,因为快速路径只检查单个字节标志而没有任何昂贵的指令(并且函数的其余部分本身通常意味着至少两个mutex2()
和mutex.lock()
的原子操作,但代码大小增加。
与全局版本比较,除了在全局初始化期间而不是在首次访问之前发生初始化之外,它是相同的:
mutex.unlock()
该功能不到任何初始化样板的大小的三分之一。
然而,在C ++ 11之前,这通常是不安全的,除非您的编译器对静态本地化的初始化方式做出了一些特殊的保证。
前段时间,在查看类似问题时,我检查了Visual Studio为此案例生成的程序集。为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
方法生成的汇编代码的伪代码如下所示:
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
}
是一个特定于此方法的编译器生成的全局变量,用于跟踪是否已初始化本地静态。注意在&#34;一次&#34;初始化由此变量保护的块,该变量在初始化互斥锁之前设置为true。
我虽然这很愚蠢,因为它确保参与此方法的其他线程将跳过初始化程序并使用未初始化的init_check_print_mtx
- 而不是可能多次初始化mtx
的替代方案 - 但事实上这样做可以避免在mtx
回调打印时发生的无限递归问题,而且这种行为实际上是由标准规定的。
Nemo上面提到在C ++ 11中已经修复(更确切地说,重新指定)需要等待所有赛车线程,这会使这个安全,但是你需要检查自己的编译器的合规性。我没有检查实际上新规范是否包括这个保证,但是我不会感到惊讶,因为在没有这个的情况下,局部静态在多线程环境中几乎没用(除了原始值之外)没有任何检查和设置行为,因为它们只是直接引用.data段中已初始化的位置。)
1 请注意,我将std::mutex()
函数更改为稍微简单的print()
函数,该函数仅增加锁定区域中的整数。这与原始锁定结构和含义相同,但避免了大量代码处理inc()
运算符和<<
。
2 使用std::cout
将此变为c++filt
。
答案 1 :(得分:16)
由于多种原因,这与链接的问题不同。
链接的问题不是C ++ 11,而是你的。在C ++ 11中,函数局部静态变量的初始化始终是安全的。在C ++ 11之前,只有一些编译器是安全的,例如GCC和Clang默认为线程安全初始化。
链接的问题通过调用一个函数初始化引用,该函数是动态初始化并在运行时发生。 std::mutex
的默认构造函数是constexpr
,因此您的静态变量具有常量初始化,即互斥锁可以在编译时(或链接时)初始化,因此没有任何内容在运行时动态地做。即使多个线程同时调用该函数,在使用互斥锁之前,他们实际上也不需要做任何事情。
您的代码是安全的(假设您的编译器正确实现了C ++ 11规则。)
答案 2 :(得分:7)
只要互斥锁是静态的,是的。
本地的,非静态的,绝对不安全。除非你的所有线程使用相同的堆栈,这也意味着你现在已经发明了一个单元可以同时拥有许多不同值的存储器,并且只是等待诺贝尔委员会通知你下一个诺贝尔奖。
您必须为互斥锁提供某种“全局”(共享)内存空间。