【问题标题】:Concurrent reads on non-atomic variable非原子变量的并发读取
【发布时间】:2018-02-19 11:07:42
【问题描述】:

我在尝试实现共享指针时遇到了这个问题。让我们关注托管数据指针。它的生命周期可以分为三个阶段:

  1. 没有并发访问的构造。
  2. 并发读取(无写入)。
  3. 没有并发访问的破坏。这是通过引用计数来保证的。

我的问题是,在这种情况下,指针是否必须是原子的?我认为它相当于:如果指针不是原子的,第 2 阶段会导致未定义的行为吗?理想情况下,我希望听到从理论(语言律师)和实践的角度讨论的答案。例如,如果不是原子的,第 2 阶段理论上可能是未定义的行为,但在实际平台上实际上是可以的。实现共享指针,如果非原子可以,托管指针可以是unique_ptr<T>,否则必须是atomic<T*>

更新

我找到了标准文本(第 1.10 节 p21):

一个程序的执行包含一个数据竞争,如果它包含两个 不同线程中的冲突操作,至少其中一个不是 原子的,并且两者都不发生在另一个之前。任何此类数据竞赛 导致未定义的行为。

我猜并发读取不会被归类为冲突操作。有人能找到一些关于这个的标准文本来确定吗?

【问题讨论】:

  • 您的问题听起来令人困惑,您会更改托管指针本身吗?如果不是,则指针是否为atomic 无关紧要。
  • @liliscent 修改操作发生在没有并发访问的构造和销毁(1 和 3)阶段。并发访问仅发生在只有读访问的第 2 阶段。
  • 这也是我的想法。在这种情况下,老实说,我找不到与原子指针的任何联系。原子指针仅在指针本身将被更改时使用,即您的上下文中的“写访问”。
  • @liliscent 我找到了标准文本 "如果一个程序在不同的线程中包含两个冲突的动作,则程序的执行包含数据竞争,其中至少一个不是原子的,并且两者都没有发生任何此类数据竞争都会导致未定义的行为。” 我猜并发读取不会归类为冲突操作?
  • @lingxi:这是您要查找的内容吗:“如果其中一个修改内存位置而另一个访问或修改相同的内存位置,则两个表达式求值会发生冲突。" ?

标签: c++ c++11 concurrency language-lawyer atomic


【解决方案1】:

对任何变量的并发读取,无论是否是原子的,都不会构成数据竞争,因为在[intro.multithread] 中找到了冲突评估的定义:

两个表达式求值冲突,如果其中一个修改了内存位置,而另一个访问或修改了相同的内存位置。

最近,这已转移到[intro.races],措辞发生了非常微妙的变化

两个表达式求值冲突,如果其中一个修改了内存位置,而另一个读取或修改了相同的内存位置。

accessesreads 的变化发生在草稿 n4296 和 n4431 之间。多线程部分的拆分发生在 n4582 和 n4604 之间。

【讨论】:

    【解决方案2】:

    规则是,如果多个线程同时访问同一个对象,并且这些线程中至少有一个正在修改数据,那么就会发生数据竞争,并且程序的行为是未定义的。如果没有人修改对象,则并发访问没有问题。

    【讨论】:

    • 其实这是我凭直觉想到的。然而,我养成了怀疑的习惯,因为 C++ 标准有很多违反直觉的令人兴奋的规则。
    • 我找到了标准文本 "如果一个程序在不同的线程中包含两个冲突的动作,则程序的执行包含数据竞争,其中至少一个不是原子的,并且在其他。任何此类数据竞争都会导致未定义的行为。” 这是否意味着并发读取不是冲突操作?
    【解决方案3】:

    自己寻找答案。引用自C++ Concurrency in Action 中第 5.1.2 节的第一段:

    [...] 如果两个线程都没有更新内存位置,那没关系; 只读数据不需要保护或同步。如果要么 线程正在修改数据,有可能发生竞争 条件,如第 3 章所述。

    【讨论】:

      【解决方案4】:

      所以我想你说的是保存计数器和指向所拥有数据的指针的结构:

      template<class ValueType>
      struct shared_counter{
        std::atomic<int> count=0;
        const std::unique_ptr<ValueType> ptr;
        //Your question is: Does ptr should be atomic?
        //... This is a dumb implementation, only focusing on the subject.
        };
      

      实际上,ptr 不需要是原子的,因为如果适当地实现了引用计数,所有对ptr 的访问都将在 shared_counter 的析构之前排序。

      为了确保在 shared_ptr 的析构函数中,计数器通过读取-修改-写入和获取-释放内存顺序递减:

      template<class ValueType>
      struct shared_ptr{
         shared_counter<ValueType>* counted_ptr;
         //...
         void reset(){
           if (counted_ptr->count.fetch_sub(1,std::memory_order_acq_rel) == 1)
             counter_ptr->~shared_counter<ValueType>();
           counter_ptr=nullptr;
           }
         };
      

      由于这个内存顺序,如果在线程 A 中获取的 count 值为 1,这意味着在所有其他线程中 other shared_ptrs 指向相同的shared_counter 将不再访问这个shared_counter。内存顺序确保这些其他线程中对该 shared_counter 执行的访问将发生在线程 A 中获取值 1 之前。(在其他线程中释放 -> 在将调用析构函数的线程中获取)。

      所以没有必要让ptr 是原子的,因为计数器递减会导致足够的排序。

      【讨论】:

      • 请注意,这不是std::shared_ptr 的合法方法,尽管可以通过这种方式构建有用的智能指针。
      • @BenVoigt,这是 libc++ 和 libstdc++ 的复制粘贴。
      猜你喜欢
      • 2015-08-13
      • 1970-01-01
      • 1970-01-01
      • 2012-04-27
      • 2017-08-12
      • 2015-09-16
      • 1970-01-01
      • 1970-01-01
      • 2020-05-21
      相关资源
      最近更新 更多