到底什么是std::atomic?

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

a = a + 12;

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

222252 次浏览

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

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

a = a + 12;

std::atomic<>没有(使用模板表达式)将其简化为单个原子操作,相反,operator T() const volatile noexcept成员执行a的原子load(),然后添加12,operator=(T t) noexcept执行store(t)

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

原子类型的对象是唯一没有数据争用的c++对象;也就是说,如果一个线程写入一个原子对象,而另一个线程从中读取,则行为是定义良好的。

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

std::atomic<>包装的操作,在c++之前的11次中,必须使用(例如)MSVC的联锁功能或GCC的原子bultins来执行。

此外,std::atomic<>通过允许指定同步和排序约束的各种记忆的订单来提供更多的控制。如果你想阅读更多关于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()(它本身是原子的),然后将该值与最终结果的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);
}

注意,断言条件将始终为真(因此,永远不会触发),所以您始终可以确保在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

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

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

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

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

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

这个实验的主要结论是,现代cpu直接支持原子整数操作,例如x86中的LOCK前缀,而std::atomic基本上是作为这些指令的可移植接口而存在的:

这种支持允许更快地替代更通用的方法,如std::mutex,它可以使更复杂的多指令部分原子化,代价是比std::atomic慢,因为std::mutex在Linux中进行futex系统调用,这比std::atomic发出的用户域指令慢得多,另参见:std::互斥创建一个围栏吗?

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

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

结论:

  • 非原子版本将全局变量保存到寄存器中,并增加寄存器。

    因此,在最后,很可能有4个写入以相同的“错误”返回全局。值100000

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

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

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

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