C ++ 11 atomic:为什么这段代码有效?

时间:2013-06-04 18:02:40

标签: c++ multithreading c++11 atomic race-condition

让我们来看看这个结构:

struct entry {
    atomic<bool> valid;
    atomic_flag writing;
    char payload[128];
}

两个踏板A和B以这种方式同时访问此结构(让e成为entry的实例):

if (e.valid) {
    // do something with e.payload...
} else {
    while (e.writing.test_and_set(std::memory_order_acquire));
    if (!e.valid) {
       // write e.payload one byte at a time
       // (the payload written by A may be different from the payload written by B)
       e.valid = true;
       e.writing.clear(std::memory_order_release);
    }
}

我猜这段代码是正确的并且没有出现问题,但我想了解它为何有效。

引用C ++标准(29.3.13):

  

实现应该使原子库对原子载荷可见   在合理的时间内

现在,考虑到这一点,想象线程A和B都进入else块。这种交错可能吗?

  1. AB都会进入else分支,因为validfalse
  2. A设置writing标志
  3. B开始锁定writing旗帜
  4. A读取valid标记(false)并输入if
  5. A写入有效负载
  6. A在有效标记上写true;很明显,如果A再次阅读valid,则会显示true
  7. A清除writing标志
  8. B设置writing标志
  9. B读取有效标记(false)的陈旧值并输入if
  10. B写入其有效负载
  11. Btrue标志
  12. 上写valid
  13. B清除writing标志
  14. 我希望这是不可能的,但是当谈到实际回答“为什么不可能?”这个问题时,我不确定答案。这是我的想法。

    再次引用标准(29.3.12):

      

    原子读 - 修改 - 写操作应始终读取最后一个值   (在修改顺序中)写入与写入相关联之前   读 - 修改 - 写操作。

    atomic_flag::test_and_set()是一个原子读 - 修改 - 写操作,如29.7.5中所述。

    由于atomic_flag::test_and_set()总是读取“新值”,而我用std::memory_order_acquire内存排序调用它,然后我无法读取{的陈旧值{1}}标记,因为我必须在valid调用之前看到A引起的所有副作用(使用atomic_flag::clear())。

    我说错了吗?

    澄清即可。我的整个推理(错误或正确)依赖于29.3.12。就我目前所理解的情况而言,如果我们忽略std::memory_order_release,即使atomic_flag,也可以从valid读取陈旧数据。 atomic似乎并不意味着“始终立即可见”每个线程。您可以要求的最大保证是您读取的值中的一致顺序,但您仍然可以在获取新数据之前读取过时数据。幸运的是,atomic和每个atomic_flag::test_and_set()操作都具有以下关键特性:它们始终读取新数据。因此,只有在exchange标志上获取/释放(不仅仅在writing上),您才能获得预期的行为。你看到了我的观点(正确与否)?


    编辑:我的原始问题包括以下几行,与问题的核心相比,引起了太多关注。我留下他们与已经给出的答案保持一致,但如果你现在正在阅读这个问题,请忽略它们。

      

    valid中有valid是否有任何意义   不是简单atomic<bool>?此外,如果它应该是bool   什么是它不会出现的'最小'内存排序约束   问题?

4 个答案:

答案 0 :(得分:5)

else分支valid内部应受waiting上的操作强加的获取/释放语义的保护。然而,这并没有忘记使valid成为原子的需要:

您忘记在分析中包含第一行(if (e.valid))。如果validbool而不是atomic<bool>,则此访问权限将完全不受保护。因此,在valid完全写入/可见之前,您可能会遇到payload的更改对其他线程可见的情况。这意味着线程B可以评估e.validtrue并输入do something with e.payload分支,而payload尚未完全编写。

除此之外,您的分析似乎有些合理,但对我来说并不完全正确。记忆顺序要记住的事情是获取和释放语义将配对。在对同一版本的获取操作读取修改值之后,可以安全地读取在释放操作之前写入的所有内容。考虑到这一点,waiting.clear(...)上的发布语义确保在valid上的循环退出时必须显示对writing.test_and_set(...)的写入,因为后者会读取等待(the write done in的更改waiting.clear(...)`)具有获取语义,并且在该变化可见之前不会退出。

关于§29.3.12:它与您的代码的正确性有关,但与阅读过时的valid标志无关。你不能在clear之前设置标志,所以获取 - 释放语义将确保那里的正确性。 §29.3.12保护您免受以下情况的影响:

  
      
  1. A和B都进入else分支,因为valid为false
  2.   
  3. A设置书写标志
  4.   
  5. B看到写作的陈旧价值,并设置它
  6.   
  7. A和B都读取有效标志(为假),输入if块并写入有效负载创建竞争条件
  8.   

编辑:对于最小的Ordering约束:对于存储的加载和释放的获取应该可以完成这项工作,但是根据您的目标硬件,您可能仍然保持顺序一致性。对于这些语义之间的区别,请查看here

答案 1 :(得分:2)

第29.3.12节与此代码正确或不正确的原因无关。您想要的部分(在the draft version of the standard available online中)是第1.10节:“多线程执行和数据争用”。 1.10节定义了原子操作的先发生关系,以及与原子操作有关的非原子操作。

1.10节说如果有两个非原子操作,你无法确定之前发生的关系,那么你就有了数据竞争。它进一步声明(第21段)任何具有数据竞争的程序都有未定义的行为。

如果e.valid不是原子的,那么您在第一行代码和行e.valid=true之间会有数据竞争。因此,关于else子句中的行为的所有推理都是错误的(程序没有定义的行为,所以没有任何理由可以解释。)

另一方面,如果e.valid的所有访问都受到e.writing上的原子操作的保护(就像else子句是整个程序一样)那么你的推理是正确的。列表中的事件9不可能发生。但原因不是第29.3.12节,而是第1.10节,它表示如果没有数据条,你的非原子操作将出现顺序一致。

您使用的模式称为double checked locking‌​。在C ++ 11之前,无法实现双重检查锁定。在C ++ 11中,您可以正确且可移植地进行双重检查锁定。您这样做的方法是将valid声明为atomic

答案 2 :(得分:1)

如果valid不是原子的,那么第一行的e.valid的初始读数与e.valid的作业相冲突。

在其中一个线程获得自旋锁之前,不能保证两个线程都已经完成了读取,即步骤1和6没有被排序。

答案 3 :(得分:1)

e.valid的商店需要发布,条件中的负载需要获取。否则,编译器/处理器可以自由地在写入有效载荷之前设置e.valid。 有一个开源工具CDSChecker,用于根据C / C ++ 11内存模型验证这样的代码。