std :: atomic到底是什么?


172

我明白那个 std::atomic<>是一个原子对象。但是原子到什么程度呢?据我了解,操作可以是原子的。使对象原子化的确切含义是什么?例如,如果有两个线程同时执行以下代码:

a = a + 12;

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


9
如果需要a.fetch_add(12)原子RMW,则需要使用类似的东西。
Kerrek SB 2015年

是的,这是我不明白的。使对象成为原子意味着什么。如果有一个接口,可以简单地用互斥锁或监视器使之原子化。

2
@AaryamanSagar它解决了效率问题。 互斥量和监视器会带来计算开销。使用std::atomic让标准库确定实现原子性所需的条件。
德鲁·多曼

1
@AaryamanSagar:std::atomic<T>允许原子操作的类型。它并不能神奇地使您的生活变得更好,但您仍然必须知道您想对它做什么。这是针对一个非常特定的用例,原子操作(在对象上)的使用通常非常微妙,需要从非本地角度来考虑。因此,除非您已经知道这一点以及为什么要执行原子操作,否则该类型可能对您没有太大用处。
Kerrek SB 2015年

Answers:


187

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

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

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

std::atomic<>包装在C ++之前的11倍中必须使用(例如)与MSVC或原子bultins 互锁的函数执行的操作 GCC)执行的操作。

另外,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;

不会评估为一个原子运算:它将导致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);
}

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

  • store()设置标志后执行sharedData(我们假定generateData()始终返回有用的东西,尤其是从不返回NULL)并使用std::memory_order_releaseorder:

memory_order_release

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

  • sharedDatawhile循环退出后使用,因此load()from标记后将返回非零值。load()使用std::memory_order_acquire顺序:

std::memory_order_acquire

具有此内存顺序的加载操作在受影响的内存位置上执行获取操作:此加载之前,无法对当前线程中的任何读取或写入进行重新排序。在其他线程中释放相同原子变量的所有写操作在当前线程中可见

这使您可以精确控制同步,并允许您明确指定代码可能/可能/不会/将/将不起作用的方式。如果仅保证原子性本身,这将是不可能的。尤其是涉及非常有趣的同步模型(例如发布-消费排序)时


2
实际上是否有没有像ints 这样的图元的原子负载和存储架构?

7
这不仅与原子性有关。它也与订购,多核系统中的行为等有关。您可能需要阅读本文
Mateusz Grzejek 2015年

4
@AaryamanSagar如果我没记错的话,即使在x86上读写也只有在字边界上对齐时才是原子的。
v.shashenko '16

@MateuszGrzejek我已经引用了原子类型。您能否验证以下内容是否仍能保证对对象分配进行原子操作 ideone.com/HpSwqo
xAditya3393 '18年

3
@TimMB是的,通常,您会(至少)在两种情况下更改执行顺序:(1)编译器可以对指令重新排序(在标准允许的范围内),以提供更好的输出代码性能(基于CPU寄存器的使用情况,预测等),以及(2)CPU可以以不同的顺序执行指令,例如,最大程度地减少高速缓存同步点的数量。为std::atomicstd::memory_order)提供的排序约束完全是为了限制允许发生的重新排序。
Mateusz Grzejek,

20

我知道这std::atomic<>会使对象成为原子。

这是一个观点问题……您无法将其应用于任意对象,并使它们的操作成为原子操作,但是可以使用(大多数)整数类型和指针提供的专业化功能。

a = a + 12;

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


那就是我想问的。常规int具有原子加载和存储。用atomic <>包装它的

8
@AaryamanSagar仅仅修改法线int并不能确保该更改在其他线程中可见,也无法阅读以确保您看到其他线程的更改,并且my_int += 3除非您使用某些方法,否则不能保证完全自动地完成某些操作std::atomic<>,因为它们可能涉及获取,然后添加,然后存储序列,其中其他一些尝试更新相同值的线程可能会在获取之后,存储之前进入,并破坏线程的更新。
托尼·德罗伊

简单地修改一个普通的int并不能确保该更改在其他线程中可见 ”。比这更糟的是:任何试图衡量可见性的尝试都会导致UB。
curiousguy19

8

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

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

因此,现在让我们看看要std::atomic编译什么以获得另一种见解。

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

这种支持允许更快地替代更通用的方法,例如std::mutex,它可以使更复杂的多指令节成为原子,但代价是要慢一些,std::atomic因为std::mutex它使futexLinux中的系统调用速度比,发出的userland指令慢得多std::atomic。另请参见:std :: mutex是否会创建围栏?

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

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

结论:

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

    因此,最后,很有可能发生四次以相同的“错误”值回到全局的写操作100000

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

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

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

在Ubuntu 19.10 AMD64,GCC 9.2.1,Lenovo ThinkPad P51中进行了测试。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.