无锁编程:原子价值有多新鲜?

时间:2016-11-26 14:17:44

标签: multithreading c++11 concurrency atomic lock-free

原子变量在几个并发运行的线程之间共享。据我所知,一个线程可以读取它执行轻松加载的陈旧值:

x.load(std::memory_order_relaxed)

如果我们使用发布 - 获取同步怎么办?据我所知,执行获取的线程可以保证看到释放线程写入变量的内容。

// Thread 1:
x.store(33, std::memory_order_release);

// Thread 2:
x.load(std::memory_order_acquire)

在这种情况下,线程2是否总能看到新值?

现在,如果我们将执行宽松存储的第三个线程添加到前一个示例,则线程2可能看不到该更新,因为仅在线程1和2之间建立了同步。我是对的吗?

最后,读 - 修改 - 写操作据说总是在新值上运行。这是否意味着他们强迫线程"看到"其他线程所做的更新,所以如果我们在读取 - 修改 - 写入操作之后加载,我们会看到该值至少与该操作一样新鲜吗?

// Thread 1:
x.fetch_add(1, std::memory_order_relaxed); // get the fresh value and add 1
if (x.load(std::memory_order_relaxed) == 3) // not older than fetch_add
    fire();

// Thread 2:
x.fetch_add(2, std::memory_order_relaxed); // will see thread 1 modification
if (x.load(std::memory_order_relaxed) == 3) // will see what fetch_add has put in x or newer
    fire();

对于x最初为零,我可以确定在这种情况下fire至少会被调用一次吗?我的所有测试都证明它有效,但也许只是我编译器或硬件的问题。

我也对订购感到好奇。 x修改和加载之间存在明显的依赖关系,因此我认为这些指令不会被重新排序,尽管指定了宽松的顺序。或者我错了吗?

1 个答案:

答案 0 :(得分:2)

关键点是memory_order_relaxed表示fetch_add没有为任何其他位置加载/存储订单。它做任何必要的原子,而不是更多。但是,单个线程中的依赖顺序仍然适用。

要使fetch_add成为原子,它必须阻止任何其他fetch_add尝试同时执行它所读取的相同输入值。几乎所有东西都使用MESI cache-coherence protocol的衍生物,所以在实践中,通过保持缓存行"锁定"来完成原子fetch_add。在M状态下从读到写。 (或者使用LL/SC,检测到这没有发生并重试。)另请参阅Can num++ be atomic for 'int num'?以获取有关x86的更详细说明。

C ++没有指定关于如何实现它的任何内容,但了解C ++试图暴露的硬件操作是非常有用的。

  

对于x最初为零,我可以确定在这种情况下至少会调用fire一次吗?

是的,它至少会运行一次。它可以运行两次,因为两个负载都可以看到第二个fetch_add的结果。

加载始终会看到x的值,该值至少已由其自己的线程中的fetch_add更新。 memory_order_relaxed不允许线程观察其自身无序发生的操作,并且fetch_adds确实按某种顺序发生。所以至少是"第二个"会看到x == 3

订单无法预测。您可以通过查看fetch_add的返回值来观察它,而不是使用单独的加载。 (此代码不遵守fetch_add建立的顺序,因为多个线程可以获得相同的值。为此,您需要捕获值作为单个原子操作的一部分。)

  

我也对订购感到好奇。 x修改和加载之间存在明显的依赖关系,因此我认为这些指令不会被重新排序,尽管指定了宽松的顺序。

运行时和编译时的内存重新排序以及乱序执行都会保留单个线程的行为。所有这些东西的黄金法则(包括编译器" as-if规则")都不会破坏单线程代码"。

但不保证这些操作对其他线程全局可见的顺序。在x.fetch_add之后,x.load的商店部分可以对其他线程全局可见。它不会在x86上运行,因为x86不会对来自同一地址的后续加载重新排序商店(但允许对其他地址even when a store and load partially overlap进行StoreLoad重新排序。)

第三个线程可能会看到T1和T2的操作发生的顺序与T1看到的T2操作不同。 (即不必是所有线程都同意的总订单,除非您使用顺序一致性。)

注意"观察"一个负载变得全局可见只能间接地:通过查看线程完成的其他商店,找出必须加载的值。但是负载是订购的真正重要部分。

因此,如果fire()将某些内容写入内存,第三个线程可以看到发生这种情况时仍会看到x==0只有看起来才有可能(<1}}线程3)像T1的负载发生在任一线程中的fetch_add之前。即使线程3使用x.fetch_add(mo_relaxed)来观察x中的值,C ++也允许这样做。但就像我说的那样,你在x86上看不到它。

请注意&#34;依赖性排序&#34;是一个具有另一种含义的短语。即使在弱排序的CPU上,加载指针然后取消引用它也可以保证LoadLoad重新排序之间不会发生。只有一种架构没有做到这一点:Alpha需要一个障碍才能实现这一点。这是memory_order_consumememory_order_relaxed分开的一个原因。

如果发布指针,您可以使用memory_order_consume代替memory_order_acquiremo_release生产者更便宜地同步。 (编制者通常只是加强消费以获取,因为它很难实施。)

依赖顺序的mo_consume含义适用于两个不同的内存位置:指针和缓冲区。与线程总是按顺序看到自己的行为这一事实完全不同。

当只有一个对象时,线程将始终看到至少与其最近的商店一样新的数据。 (按照它为操作观察的顺序是新的,而不是像绝对时间那样新的!这只是意味着一个线程不能存储一个值,然后让它的下一个读取发现该值从一个商店中恢复为一个在fetch_add之前已经看过了。)

不容易推理这些东西(例如,其他线程看到的全局顺序,与所涉及的一个线程所看到的顺序相比)。列举所有可能或可能不可能的令人惊讶的重新排序也很棘手。

代码wiki中有一些链接。我特别推荐Jeff Preshing's blog。他解释得很好,并发表了许多好文章。