【问题标题】:Is std::call_once lock free?std::call_once 是否无锁?
【发布时间】:2016-11-20 17:34:44
【问题描述】:

我想知道 std::call_once 是否已解锁。 There 是使用互斥锁的 call_once 实现。但是我们为什么要使用互斥锁呢?我尝试使用 atomic_bool 和 CAS 操作编写简单的实现。代码线程安全吗?

#include <iostream>
#include <thread>
#include <atomic>
#include <unistd.h>

using namespace std;
using my_once_flag = atomic<bool>;

void my_call_once(my_once_flag& flag, std::function<void()> foo) {
    bool expected = false;
    bool res = flag.compare_exchange_strong(expected, true,
                                            std::memory_order_release, std::memory_order_relaxed);
    if(res)
        foo();
}
my_once_flag flag;
void printOnce() {
    usleep(100);
    my_call_once(flag, [](){
       cout << "test" << endl;
    });
}

int main() {
    for(int i = 0; i< 500; ++i){
            thread([](){
                printOnce();
            }).detach();
    }
    return 0;
} 

【问题讨论】:

  • 这是一个关于std::call_once 的问题和一个关于审查替代实施的问题。请选择一个问题。
  • 你的实现不是线程安全的,因为如果foo() 正在执行,call_once() 需要等待它完成。没有无锁的方法可以做到这一点。
  • 想了解std::call_once是否无锁,能否实现无锁。

标签: c++ multithreading c++11 lock-free


【解决方案1】:

您提出的实现不是线程安全的。它确实保证foo() 只会通过此代码调用一次,但它不保证所有线程都会看到调用foo() 的副作用。假设线程 1 执行比较结果为真,那么调度程序在线程 2 调用 foo() 之前切换到线程 2。线程 2 将变为假,跳过对 foo() 的调用,然后继续。由于尚未执行对 foo() 的调用,因此线程 2 可以在 foo() 的任何副作用发生之前继续执行。

【讨论】:

  • 感谢您的解释!
【解决方案2】:

已经调用过的快速路径可以免等待

gcc 的实现看起来并不那么高效。我不知道为什么它的实现方式与使用非常量 arg 初始化 static 局部变量的方式不同,对于已经初始化的情况,它使用非常便宜(但不是免费的!)的检查。

http://en.cppreference.com/w/cpp/thread/call_once 表示:

保证函数局部静态的初始化只发生 即使从多个线程调用一次,也可能更有效 比使用 std::call_once 的等效代码。


为了实现高效,std::once_flag 可以具有三种状态:

  • 执行完成:如果你找到这个状态,你已经完成了。
  • 执行中:如果您发现:等到它更改为已完成(或更改为 failed-with-exception,在这种情况下尝试声明它)
  • 执行未开始:如果发现此问题,请尝试对其进行 CAS 处理并调用该函数。如果 CAS 失败,则其他线程成功,因此进入等待完成状态。

在大多数架构(尤其是所有负载都是获取负载的 x86)上,使用获取加载检查标志非常便宜。一旦设置为“完成”,程序的其余部分就不会对其进行修改,因此它可以在所有内核上保持缓存在 L1 中(除非您将其与经常修改的内容放在同一缓存行中,从而创建错误共享)。

即使您的实现有效,它每次都会尝试原子 CAS,这比负载获取贵得多。


我还没有完全解码 gcc 为call_once 所做的事情,但在检查指针是否为 NULL 之前,它会无条件地执行一堆加载,并两次存储到线程本地存储。 (test rax,rax / je)。但如果是,它会调用std::__throw_system_error(int),因此它不是用于检测已初始化案例的保护变量。

所以看起来它无条件地调用__gthrw_pthread_once(int*, void (*)()),并检查返回值。因此,对于您想要廉价地确保完成一些初始化,同时避免静态初始化惨败的用例来说,这非常糟糕。 (即您的构建过程控制静态对象的构造函数的顺序,而不是您放入代码本身的任何内容。)

所以我建议使用static int dummy = init_function();,其中 dummy 要么是您真正想要构建的东西,要么只是一种调用 init_function 的方式,因为它的副作用。

然后在快速路径上,asm 来自:

int called_once();

void static_local(){
  static char dummy = called_once();
  (void)dummy;
}

看起来像这样:

static_local():
    movzx   eax, BYTE PTR guard variable for static_local()::dummy[rip]
    test    al, al
    je      .L18
    ret
 .L18:
    ... # code that implements basically what I described above: call or wait

See it on the Godbolt compiler explorer,以及 gcc 的std::once_flag 的实际代码。


您当然可以使用原子 uint8_t 自己实现一个保护变量,它一开始初始化为非零,并且仅在调用完成时才设置为零。如果编译器像 gcc 一样怪异并决定将其实际加载到寄存器中而不是使用 cmp byte [guard], 0,则在某些 ISA(包括 x86)上测试零可能会稍微便宜一些。

【讨论】:

  • 有一个 call_once 的实现,它使用线程本地存储来避免重新获取; gcc 可能正在使用它。 (当然,由于在 x86 上获取是免费的,我不知道为什么会......)
  • @tony:谢谢,这听起来像是一个合理的理论。 GNU 库并非专门针对 x86 进行调整,如果它只是一个通用的 C++ 实现,我不会感到惊讶。也许像编译器那样使用可移植的 C++ 来初始化 static 而不实际初始化 static 并不容易。 (我没有查看 call_once 的所有文档,但如果它的异常语义与 static 的初始化程序匹配,则 IDK。)cppreference.com 确实警告说 static 局部变量可能更有效,所以编译器可能很常见做这样的事情。
  • 静态的使用不能解决调用一次的函数是类或结构的特定实例的惰性初始化器的用例。示例: struct A { void get() {...} void initOnce() {...} // 其他内容 };在这里,我希望第一次在类 A 的实例上调用 get(),为该实例运行 initOnce。
  • @Fabio:所以手动使用非静态成员变量来跟踪是否为该实例完成了惰性初始化。它还需要是线程安全的吗?即是否允许两个线程同时在同一个对象上运行foo-&gt;get()
  • 我没有任何特殊需要,我只是评论static 解决方案的一般性。如果我要实现与once_flag 不同的东西,我可能会使用内存排序的双重检查,如justsoftwaresolutions.co.uk/threading/…
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-08-09
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多