什么是std :: atomic?

129 c++ multithreading atomic c++11

我明白这std::atomic<>是一个原子对象.但原子到什么程度?根据我的理解,操作可以是原子的.使对象成为原子意味着什么?例如,如果有两个线程同时执行以下代码:

a = a + 12;
Run Code Online (Sandbox Code Playgroud)

然后是整个操作(比方说add_twelve_to(int))原子?或者是变量原子(so operator=())的变化?

Mat*_*jek 140

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

原子类型的对象是唯一没有数据竞争的C++对象; 也就是说,如果一个线程写入原子对象而另一个线程从中读取,则行为是明确定义的.

此外,对原子对象的访问可以建立线程间同步并按照指定的顺序排序非原子内存访问std::memory_order.

std::atomic<>包装操作,在C++之前的11次,必须使用(例如)GCC情况下的MSVC或原子文本的互锁函数来执行.

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

请注意,对于典型用例,您可能会使用重载算术运算符其他一组:

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

因为运算符语法不允许您指定内存顺序,所以这些操作将使用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
Run Code Online (Sandbox Code Playgroud)

现在,你的例子:

a = a + 12;
Run Code Online (Sandbox Code Playgroud)

不会评估单个原子操作:它将导致a.load()(这是原子本身),然后在此值和最终结果的(12a.store()原子)之间添加.正如我前面提到的,std::memory_order_seq_cst将在这里使用.

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

至于你的评论:

常规int具有原子载荷和存储.包装它的重点是什么atomic<>

您的陈述仅适用于体系结构,它为商店和/或负载提供原子性保证.有些架构不会这样做.此外,通常需要的是,必须在字/双字对齐的地址上执行操作才能成为原子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);
}
Run Code Online (Sandbox Code Playgroud)

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

  • store()sharedData设置后执行到标志(我们假设,generateData()总是返回有用的东西,特别是永远不会返回NULL)并使用std::memory_order_release顺序:

memory_order_release

具有此内存顺序的存储操作将执行释放 操作:此存储之后,不能对当前线程中的读取或写入进行重新排序 .当前线程中的所有写入在获取相同原子变量的其他线程中是可见的

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

std::memory_order_acquire

具有此内存顺序的加载操作会对受影响的内存位置执行获取操作:此加载之前,不能对当前线程中的读取或写入进行重新排序.在当前线程中可以看到释放相同原子变量的其他线程中的所有写入.

这使您可以精确控制同步,并允许您明确指定代码可能/可能不会/将要/不会表现的方式.如果只保证是原子性本身,这是不可能的.特别是当涉及非常有趣的同步模型,如发布消耗排序.

  • 这不仅仅是关于原子性的.它也与订购,多核系统中的行为等有关.您可能需要阅读[本文](http://preshing.com/20120930/weak-vs-strong-memory-models/). (5认同)
  • @AaryamanSagar如果我没有弄错的话,即使在x86读取和写入上,只有在字边界上对齐时才是原子的. (4认同)
  • @TimMB是的,通常,您(至少)会遇到两种情况,执行顺序可能会改变:(1)编译器可以对指令重新排序(在标准允许的范围内),以便提供更好的输出代码性能(基于CPU寄存器的使用、预测等)以及(2)CPU可以以不同的顺序执行指令,例如,最小化高速缓存同步点的数量。为“std::atomic”(“std::memory_order”)提供的排序约束正是为了限制允许发生的重新排序。 (4认同)
  • 实际上是否存在没有原子负载和存储诸如int之类的基元的体系结构? (2认同)

Cir*_*四事件 20

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

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

所以现在让我们看看std::atomic编译成什么以获得不同的洞察力。

这个实验的主要内容是现代 CPU 直接支持原子整数操作,例如 x86 中的 LOCK 前缀,并且std::atomic基本上作为这些指令的可移植接口存在:x86 汇编中的“锁定”指令是什么意思?在 aarch64 中,将使用LDADD

这种支持能够实现更快的替代更一般的方法,例如std::mutex,它可以使更多的复杂的多指令部的原子,在比慢的成本std::atomic,因为std::mutex它使得futex在Linux系统调用,这是远远比发出的用户态指令较慢std::atomic,另请参阅:std::mutex 是否创建围栏?

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

主程序

#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;
}
Run Code Online (Sandbox Code Playgroud)

GitHub 上游.

编译、运行和反汇编:

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
Run Code Online (Sandbox Code Playgroud)

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

expect 400000
global 100000
Run Code Online (Sandbox Code Playgroud)

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

expect 400000
global 400000
Run Code Online (Sandbox Code Playgroud)

拆卸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    $0x1,%rax
   0x00000000000027a4 <+36>:    add    $0x1,%rdx
   0x00000000000027a8 <+40>:    cmp    %rcx,%rax
   0x00000000000027ab <+43>:    jb     0x27a0 <threadMain()+32>
   0x00000000000027ad <+45>:    mov    %rdx,0x2984(%rip)        # 0x5138 <global>
   0x00000000000027b4 <+52>:    retq
Run Code Online (Sandbox Code Playgroud)

拆卸main_std_atomic.out

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

拆卸main_lock.out

Dump of assembler code for function threadMain():
   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,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    $0x1,%rax
   0x000000000000279c <+28>:    cmp    %rax,0x299d(%rip)        # 0x5140 <niters>
   0x00000000000027a3 <+35>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a5 <+37>:    retq
Run Code Online (Sandbox Code Playgroud)

结论:

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

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

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

  • 我们的显式内联程序集 LOCK 前缀编译为与 几乎相同的东西std::atomic,除了使用 ourinc代替add. 不知道为什么 GCC 选择add,考虑到我们的 INC 生成的解码小 1 个字节。

ARMv8 可以在较新的 CPU 中使用 LDAXR + STLXR 或 LDADD:如何在普通 C 中启动线程?

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


Ton*_*roy 17

我明白这std::atomic<>使得一个对象成为原子.

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

a = a + 12;

std::atomic<>不(使用模板表达式)简化这对单个原子操作,而不是operator T() const volatile noexcept构件确实的原子load()a,那么12被添加,并operator=(T t) noexcept做了store(t).

  • @AaryamanSagar简单地修改一个普通的`int`并不能确保从其他线程可以看到更改,读取它也不会确保你看到其他线程的变化,并且不能保证像`my_int + = 3`这样的东西是原子地完成,除非你使用`std :: atomic <>` - 它们可能涉及一个提取,然后添加,然后存储序列,其中一些其他尝试更新相同值的线程可能在获取之后和存储之前进入,并且clobber你的线程的更新. (6认同)