41

在以下程序中,我尝试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));
}

这安全吗?

我的疑问来自 这个问题,它提出了一个类似的案例。

4

3 回答 3

25

C++11

在 C++11 及更高版本中:是的,这种模式是安全的。特别是,函数局部静态变量的初始化是线程安全的,因此您上面的代码可以跨线程安全地工作。

这种在实践中起作用的方式是编译器在函数本身中插入任何必要的样板,以检查变量是否在访问之前被初始化。然而,在,和std::mutex中实现的情况下,初始化状态是全零,因此不需要显式初始化(​​变量将存在于全零部分,因此初始化是“自由的”),正如我们从大会1gccclangicc.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_localRiE3mtx2,如果不为零,则变量已经初始化,我们完成并进入快速路径。不需要原子操作,因为在 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.

于 2013-01-01T02:39:58.173 回答
16

由于几个原因,这与链接的问题不同。

链接的问题不是 C++11,但你的问题是。在 C++11 中,函数局部静态变量的初始化始终是安全的。在 C++11 之前,它只对某些编译器是安全的,例如 GCC 和 Clang 默认为线程安全初始化。

链接的问题通过调用函数来初始化引用,这是动态初始化并且在运行时发生。默认构造函数std::mutexconstexpr这样您的静态变量具有常量初始化,即互斥锁可以在编译时(或链接时)初始化,因此在运行时无需动态执行任何操作。即使多个线程同时调用该函数,在使用互斥体之前它们实际上也不需要做任何事情。

您的代码是安全的(假设您的编译器正确实现了 C++11 规则。)

于 2013-01-01T16:08:46.423 回答
6

只要互斥锁是静态的,就可以。

本地的、非静态的肯定不安全。除非你的所有线程都使用相同的堆栈,这也意味着你现在已经发明了一个内存,其中一个单元可以同时保存许多不同的值,并且只是在等待诺贝尔委员会通知你下一个诺贝尔奖。

您必须为互斥体提供某种“全局”(共享)内存空间。

于 2012-12-31T23:00:31.947 回答