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

a = a + 12;

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


当前回答

我知道std::atomic<>使对象具有原子性。

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

A = A + 12;

std::atomic<>并没有(使用模板表达式)将其简化为单个原子操作,相反,operator T() const volatile noexcept成员对a进行原子加载(),然后添加12,operator=(T T) noexcept进行存储(T)。

其他回答

我知道std::atomic<>使对象具有原子性。

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

A = A + 12;

std::atomic<>并没有(使用模板表达式)将其简化为单个原子操作,相反,operator T() const volatile noexcept成员对a进行原子加载(),然后添加12,operator=(T T) noexcept进行存储(T)。

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

c++标准对std::atomic的解释已经在其他答案中进行了分析。

那么现在让我们看看std::atomic编译的对象是什么,以获得不同的见解。

这个实验的主要结果是,现代cpu直接支持原子整数操作,例如x86中的LOCK前缀,而std::atomic基本上是作为这些指令的可移植接口而存在的:在x86汇编中,“LOCK”指令意味着什么?在aarch64中,将使用LDADD。

这种支持可以更快地替代更通用的方法,如std::mutex,它可以使更复杂的多指令部分原子化,代价是比std::atomic慢,因为std::mutex在Linux中进行futex系统调用,这比std::atomic发出的用户域指令慢得多,请参见:std::mutex创建一个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上游。

编译、运行和分解:

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    $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

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   

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

结论:

the non-atomic version saves the global to a register, and increments the register. Therefore, at the end, very likely four writes happen back to global with the same "wrong" value of 100000. std::atomic compiles to lock addq. The LOCK prefix makes the following inc fetch, modify and update memory atomically. our explicit inline assembly LOCK prefix compiles to almost the same thing as std::atomic, except that our inc is used instead of add. Not sure why GCC chose add, considering that our INC generated a decoding 1 byte smaller.

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

在Ubuntu 19.10 AMD64, GCC 9.2.1,联想ThinkPad P51上测试。

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

原子类型的对象是唯一没有数据争用的c++对象;也就是说,如果一个线程写入一个原子对象,而另一个线程从中读取,则行为是定义良好的。 此外,对原子对象的访问可以建立线程间同步,并按std::memory_order指定的顺序对非原子内存访问进行排序。

std::atomic<>包装的操作,在c++之前的11倍,必须使用(例如)与MSVC或GCC情况下的原子bultins的联锁函数来执行。

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

c++原子和内存排序 对比:c++ 11中使用原子的无锁编程vs.互斥锁和rw锁 c++ 11引入了一个标准化的内存模型。这是什么意思?它将如何影响c++编程? c++ 11中的并发性

注意,对于典型的用例,你可能会使用重载算术运算符或另一组运算符:

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;

不会计算为单个原子op:它将导致a.load()(本身是原子的),然后将该值与12和a.store()(也是原子的)相加得到最终结果。如前所述,这里将使用std::memory_order_seq_cst。

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

至于你的评论:

常规int具有原子加载和存储。用原子<>包装它的意义是什么?

你的说法只适用于为存储和/或负载提供原子性保证的架构。有些体系结构不这样做。此外,通常要求对字/字对齐的地址执行操作必须是原子性的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循环退出后数据已准备就绪。这是因为:

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

memory_order_release 具有此内存订单的存储操作执行释放 操作:当前线程的所有读写操作都不能被重新排序 在这家店之后。中可见当前线程中的所有写操作 获得相同原子变量的其他线程

sharedData在while循环退出后使用,因此在load() from flag之后将返回一个非零值。Load()使用std::memory_order_acquire order:

std:: memory_order_acquire 具有此内存顺序的加载操作执行获取操作 在受影响的内存位置上:当前没有读写 线程可以在加载之前重新排序。所有写入都在其他线程中 释放相同的原子变量在当前可见 线程。

这使您可以精确地控制同步,并允许您显式地指定您的代码可能/可能不/将/将不表现。如果仅仅保证原子性本身,这是不可能的。特别是当涉及到非常有趣的同步模型时,如发布-消费排序。