std::atomic 到底是什么?

What exactly is std::atomic?

我知道 std::atomic<> 是一个原子对象。但原子性到什么程度呢?据我了解,操作可以是原子的。使对象原子化究竟意味着什么?例如如果有两个线程同时执行下面的代码:

a = a + 12;

那么整个操作(比如说add_twelve_to(int))是原子的吗?还是对变量 atomic 进行了更改(因此 operator=())?

I understand that std::atomic<> makes an object atomic.

这是一个视角问题...您不能将它应用于任意对象并使它们的操作成为原子操作,但可以使用为(大多数)整数类型和指针提供的特化。

a = a + 12;

std::atomic<> 不会(使用模板表达式)将其简化为单个原子操作,而是 operator T() const volatile noexcept 成员执行 a 的原子 load(),然后添加十二,operator=(T t) noexcept 执行 store(t).

std::atomic<> 的每个实例化和完全特化表示不同线程可以同时操作的类型(它们的实例),而不会引发未定义的行为:

Objects of atomic types are the only C++ objects that are free from data races; that is, if one thread writes to an atomic object while another thread reads from it, the behavior is well-defined.

In addition, accesses to atomic objects may establish inter-thread synchronization and order non-atomic memory accesses as specified by std::memory_order.

std::atomic<> 包装在 C++ 之前的 11 次操作,必须使用(例如)interlocked functions with MSVC or atomic bultins 在 GCC 的情况下执行。

此外,std::atomic<> 通过允许指定同步和排序约束的各种 memory orders 为您提供更多控制。如果您想阅读更多有关 C++ 11 原子和内存模型的信息,这些链接可能会有用:

请注意,对于典型用例,您可能会使用 overloaded arithmetic operators or another set of them:

std::atomic<long> value(0);
value++; //This is an atomic op
value += 5; //And so is this

因为运算符语法不允许指定内存顺序,这些操作将以std::memory_order_seq_cst执行,因为这是C++ 11中所有原子操作的默认顺序。它保证了顺序一致性(总计全局排序)在所有原子操作之间。

然而,在某些情况下,这可能不是必需的(而且没有免费的东西),因此您可能希望使用更明确的形式:

std::atomic<long> value {0};
value.fetch_add(1, std::memory_order_relaxed); // Atomic, but there are no synchronization or ordering constraints
value.fetch_add(5, std::memory_order_release); // Atomic, performs 'release' operation

现在,你的例子:

a = a + 12;

不会评估为单个原子操作:它将导致 a.load() (它本身是原子的),然后在该值与 12a.store() (也是原子的)之间添加) 的最终结果。如前所述,此处将使用 std::memory_order_seq_cst

然而,如果你写 a += 12,它将是一个原子操作(正如我之前提到的)并且大致等同于 a.fetch_add(12, std::memory_order_seq_cst)

关于您的评论:

A regular int has atomic loads and stores. Whats the point of wrapping it with atomic<>?

您的陈述仅适用于为存储 and/or 负载提供这种原子性保证的架构。有些架构不这样做。此外,通常要求操作必须在 word-/dword-aligned 地址上执行才能是原子的 std::atomic<> 是保证在 每个 平台上都是原子的,无需额外要求。此外,它允许您编写如下代码:

void* sharedData = nullptr;
std::atomic<int> ready_flag = 0;

// Thread 1
void produce()
{
    sharedData = generateData();
    ready_flag.store(1, std::memory_order_release);
}

// Thread 2
void consume()
{
    while (ready_flag.load(std::memory_order_acquire) == 0)
    {
        std::this_thread::yield();
    }

    assert(sharedData != nullptr); // will never trigger
    processData(sharedData);
}

请注意,断言条件将始终为真(因此永远不会触发),因此您始终可以确保在 while 循环退出后数据已准备就绪。那是因为:

  • store() 标志是在设置 sharedData 之后执行的(我们假设 generateData() 总是 return 有用的东西,特别是从不 returns NULL) 并使用 std::memory_order_release 命令:

memory_order_release

A store operation with this memory order performs the release operation: no reads or writes in the current thread can be reordered after this store. All writes in the current thread are visible in other threads that acquire the same atomic variable

  • sharedDatawhile 循环退出后使用,因此在 load() from flag 之后将 return 一个非零值。 load() 使用 std::memory_order_acquire 顺序:

std::memory_order_acquire

A load operation with this memory order performs the acquire operation on the affected memory location: no reads or writes in the current thread can be reordered before this load. All writes in other threads that release the same atomic variable are visible in the current thread.

这使您可以精确控制同步,并允许您明确指定代码 may/may not/will/will 的行为方式。如果只保证原子性本身,这是不可能的。特别是涉及到非常有趣的同步模型时,例如 release-consume ordering.

std::atomic 存在是因为许多 ISA 对它有直接的硬件支持

C++ 标准关于 std::atomic 的内容已在其他答案中进行了分析。

现在让我们看看 std::atomic 编译成什么以获得不同的见解。

这个实验的主要收获是现代 CPU 直接支持原子整数操作,例如 x86 中的 LOCK 前缀,并且 std::atomic 基本上作为这些指令的可移植接口存在:What does the "lock" instruction mean in x86 assembly? In aarch64, LDADD 将被使用。

这种支持允许更快地替代更通用的方法,例如 std::mutex,它可以使更复杂的多指令部分原子化,但代价是比 std::atomic 慢,因为 std::mutex 它在 Linux 中进行 futex 系统调用,这比 std::atomic 发出的用户态指令慢得多,另请参阅:Does std::mutex create a fence?

让我们考虑以下多线程程序,它在多个线程中递增一个全局变量,根据使用的预处理器定义使用不同的同步机制。

main.cpp

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

size_t niters;

#if STD_ATOMIC
std::atomic_ulong global(0);
#else
uint64_t global = 0;
#endif

void threadMain() {
    for (size_t i = 0; i < niters; ++i) {
#if LOCK
        __asm__ __volatile__ (
            "lock incq %0;"
            : "+m" (global),
              "+g" (i) // to prevent loop unrolling
            :
            :
        );
#else
        __asm__ __volatile__ (
            ""
            : "+g" (i) // to prevent he loop from being optimized to a single add
            : "g" (global)
            :
        );
        global++;
#endif
    }
}

int main(int argc, char **argv) {
    size_t nthreads;
    if (argc > 1) {
        nthreads = std::stoull(argv[1], NULL, 0);
    } else {
        nthreads = 2;
    }
    if (argc > 2) {
        niters = std::stoull(argv[2], NULL, 0);
    } else {
        niters = 10;
    }
    std::vector<std::thread> threads(nthreads);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i] = std::thread(threadMain);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i].join();
    uint64_t expect = nthreads * niters;
    std::cout << "expect " << expect << std::endl;
    std::cout << "global " << global << std::endl;
}

GitHub upstream.

编译,运行反汇编:

comon="-ggdb3 -O3 -std=c++11 -Wall -Wextra -pedantic main.cpp -pthread"
g++ -o main_fail.out                    $common
g++ -o main_std_atomic.out -DSTD_ATOMIC $common
g++ -o main_lock.out       -DLOCK       $common

./main_fail.out       4 100000
./main_std_atomic.out 4 100000
./main_lock.out       4 100000

gdb -batch -ex "disassemble threadMain" main_fail.out
gdb -batch -ex "disassemble threadMain" main_std_atomic.out
gdb -batch -ex "disassemble threadMain" main_lock.out

极有可能 main_fail.out 的“错误”竞争条件输出:

expect 400000
global 100000

和其他人的确定性“正确”输出:

expect 400000
global 400000

反汇编main_fail.out

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     mov    0x29b5(%rip),%rcx        # 0x5140 <niters>
   0x000000000000278b <+11>:    test   %rcx,%rcx
   0x000000000000278e <+14>:    je     0x27b4 <threadMain()+52>
   0x0000000000002790 <+16>:    mov    0x29a1(%rip),%rdx        # 0x5138 <global>
   0x0000000000002797 <+23>:    xor    %eax,%eax
   0x0000000000002799 <+25>:    nopl   0x0(%rax)
   0x00000000000027a0 <+32>:    add    [=14=]x1,%rax
   0x00000000000027a4 <+36>:    add    [=14=]x1,%rdx
   0x00000000000027a8 <+40>:    cmp    %rcx,%rax
   0x00000000000027ab <+43>:    jb     0x27a0 <threadMain()+32>
   0x00000000000027ad <+45>:    mov    %rdx,0x2984(%rip)        # 0x5138 <global>
   0x00000000000027b4 <+52>:    retq

反汇编main_std_atomic.out

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   [=15=]x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a6 <threadMain()+38>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock addq [=15=]x1,0x299f(%rip)        # 0x5138 <global>
   0x0000000000002799 <+25>:    add    [=15=]x1,%rax
   0x000000000000279d <+29>:    cmp    %rax,0x299c(%rip)        # 0x5140 <niters>
   0x00000000000027a4 <+36>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a6 <+38>:    retq   

反汇编main_lock.out

Dump of assembler code for function threadMain():
   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   [=16=]x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a5 <threadMain()+37>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock incq 0x29a0(%rip)        # 0x5138 <global>
   0x0000000000002798 <+24>:    add    [=16=]x1,%rax
   0x000000000000279c <+28>:    cmp    %rax,0x299d(%rip)        # 0x5140 <niters>
   0x00000000000027a3 <+35>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a5 <+37>:    retq

结论:

  • 非原子版本将全局保存到寄存器,并递增寄存器。

    因此,最后,很可能有四次写回全局,具有相同的“错误”值 100000

  • std::atomic 编译为 lock addq。 LOCK 前缀使以下 inc 以原子方式获取、修改和更新内存。

  • 我们的显式内联汇编 LOCK 前缀编译成与 std::atomic 几乎相同的东西,只是使用我们的 inc 而不是 add。不确定为什么 GCC 选择 add,考虑到我们的 INC 生成的解码小了 1 个字节。

ARMv8 可以在较新的 CPU 中使用 LDAXR + STLXR 或 LDADD:How do I start threads in plain C?

在 Ubuntu 19.10 AMD64、GCC 9.2.1、Lenovo ThinkPad P51 中测试。