【问题标题】:Why is an acquire barrier needed before deleting the data in an atomically reference counted smart pointer?为什么在删除原子引用计数智能指针中的数据之前需要获取屏障?
【发布时间】:2015-03-01 07:23:55
【问题描述】:

Boost 提供了一个sample atomically reference counted shared pointer

这里是相关的代码sn-p以及对使用的各种排序的解释:

class X {
public:
  typedef boost::intrusive_ptr<X> pointer;
  X() : refcount_(0) {}

private:
  mutable boost::atomic<int> refcount_;
  friend void intrusive_ptr_add_ref(const X * x)
  {
    x->refcount_.fetch_add(1, boost::memory_order_relaxed);
  }
  friend void intrusive_ptr_release(const X * x)
  {
    if (x->refcount_.fetch_sub(1, boost::memory_order_release) == 1) {
      boost::atomic_thread_fence(boost::memory_order_acquire);
      delete x;
    }
  }
};

增加引用计数器总是可以用 memory_order_relaxed:只能形成对对象的新引用 从现有的引用,并从一个现有的引用传递 到另一个线程必须已经提供了任何所需的同步。

在一个对象中强制执行任何可能的访问权限是很重要的 线程(通过现有引用)在删除之前发生 对象在不同的​​线程中。这是通过“释放”实现的 删除引用后的操作(通过 这个引用显然必须发生在之前),以及“获取” 删除对象之前的操作。

可以将 memory_order_acq_rel 用于 fetch_sub 操作,但这会导致不必要的“获取”操作,当 参考计数器尚未达到零,可能会施加性能 罚款。

我不明白为什么在delete x 操作之前需要memory_order_acquire 屏障。具体来说,编译器/处理器如何在不违反单线程语义的情况下,在fetch_sub 之前重新排序delete x 的内存操作以及对x == 1 的值的测试是安全的?

编辑我想,我的问题不是很清楚。这是一个改写的版本:

读取 x (x-&gt;refcount_.fetch_sub(1, boost::memory_order_release) == 1) 和 delete x 操作之间的控制依赖关系是否提供任何排序​​保证?即使考虑单线程程序,编译器/处理器是否有可能在fetch_sub 和比较之前重新排序与delete x 操作相对应的指令?如果答案尽可能低级,并包含删除操作被重新排序(不影响单线程语义)的示例场景,从而说明保留顺序的必要性,那将非常有帮助。

【问题讨论】:

  • 最好更抽象地考虑acquire 语义。 not (仅)关于是否对操作进行了重新排序,因此您关于重新排序的问题并不真正相关。这是关于这个线程是否“拉入”它的内存视图,在它自己的操作涉及这些内存位置之前,其他线程可能对引用计数以外的内存位置所做的任何更改。如果没有acquire,它就不会这样做,也就是说存在潜在的数据竞争。内存屏障不是(仅)对指令重新排序的限制,它们是 git pushgit pull
  • @SteveJessop - 抽象视图对我帮助不大。 TBH 我发现它们比以硬件为中心的视图更令人困惑(和神奇)。例如,AFAIU 的获取和释放屏障并不关心其他线程的内存访问,而是简单地分别维护 load->[load/store] 和 store->[load/store] 的顺序,用于同一线程中的内存访问线。将它们视为git pushgit pull 表明他们实际上知道图片中的其他线程,而他们显然不知道。
  • 关于同一主题的另外两个 SO 问题:stackoverflow.com/questions/10268737/…stackoverflow.com/questions/25304201/…
  • 我同意@SteveJessop 的观点,即抽象语义是推理这些事情的最佳方式,因为这是标准要求的。举一个具体的例子,将“释放”视为“刷新写入缓存”,将“获取”视为“刷新读取缓存”。想象一下,删除 x 的线程缓存了其他线程修改的部分对象。然后,如果没有“释放”和“获取”,delete 可能会在非 POD 对象上爆炸。 (对于 POD 对象,很难想象现实生活中的失败。但“它会触发未定义的行为”实际上是一个足够的答案......)
  • @Nemo 所以你对 rel/acq 的描述是 rel 作用于以前的挂起写入,而 acq 作用于以前的记忆读取?

标签: c++ multithreading boost shared-memory atomic


【解决方案1】:

我想我找到了一个相当简单的例子来说明为什么需要获取栅栏。

假设我们的X 看起来像这样:

struct X
{
    ~X() { free(data); }
    void* data;
    atomic<int> refcount;
};

让我们进一步假设我们有两个函数foobar,看起来像这样(我将内联引用计数递减):

void foo(X* x)
{
    void* newData = generateNewData();
    free(x->data);
    x->data = newData;
    if (x->refcount.fetch_sub(1, memory_order_release) == 1)
        delete x;
}

void bar(X* x)
{
    // Do something unrelated to x
    if (x->refcount.fetch_sub(1, memory_order_release) == 1)
        delete x;
}

delete指令会执行x的析构函数,然后释放x占用的内存。让我们内联:

void bar(X* x)
{
    // Do something unrelated to x
    if (x->refcount.fetch_sub(1, memory_order_release) == 1)
    {
        free(x->data);
        operator delete(x);
    }
}

因为没有获取栅栏,编译器可以决定在执行原子减量之前将地址x-&gt;data加载到寄存器中(只要没有数据竞争,可观察到的效果是一样的):

void bar(X* x)
{
    register void* r1 = x->data;
    // Do something unrelated to x
    if (x->refcount.fetch_sub(1, memory_order_release) == 1)
    {
        free(r1);
        operator delete(x);
    }
}

现在让我们假设x 中的refcount2,并且我们有两个线程。线程1调用foo,线程2调用bar

  1. 线程 2 将x-&gt;data 加载到寄存器中。
  2. 线程 1 生成新数据。
  3. 线程 1 释放“旧”数据。
  4. 线程 1 将新数据分配给 x-&gt;data
  5. 线程 1 将 refcount2 减少到 1
  6. 线程 2 将 refcount1 减少到 0
  7. 线程 2 再次释放“旧”数据而不是新数据。

对我来说,关键的见解是“之前的写入 [...] 在这个线程中变得可见”可能意味着一些微不足道的事情,例如“不要使用缓存到围栏前的寄存器中的值”。

【讨论】:

    【解决方案2】:

    发件人,http://en.cppreference.com/w/cpp/atomic/memory_order

    memory_order_acquire -- 使用此内存顺序的加载操作在受影响的内存位置执行获取操作:优先 线程对其他内存位置的写入 发布在此线程中可见。

    ...

    发布-获取排序

    如果线程 A 中的原子存储被标记为 std::memory_order_release 并且 线程 B 中来自同一变量的原子负载被标记 std::memory_order_acquire,所有内存写入(非原子和宽松 atomic) 发生在原子存储之前 线程 A 的,成为线程 B 中可见的副作用,即一次 原子加载完成,线程B保证看到一切 线程 A 写入内存。

    仅在释放线程之间建立同步 并获取相同的原子变量。其他线程可以看到 与其中一个或两个不同的内存访问顺序 同步线程。

    强排序系统(x86、SPARC TSO、IBM 大型机)上, 对于大多数操作来说,release-acquire 排序是自动的。 不会为此同步发出额外的 CPU 指令 模式下,只有某些编译器优化会受到影响(例如 禁止编译器将非原子存储移过原子 存储释放或执行比原子更早的非原子加载 加载获取)。在弱序系统(ARM、Itanium、PowerPC)上, 必须使用特殊的 CPU 负载或内存栅栏指令。

    这意味着 release 允许其他线程从当前线程同步挂起的操作,而后面的 acquire 则从其他线程获取所有已修改的更改。

    在强序系统上,这并不重要。我认为这些指令甚至不会生成代码,因为 CPU 在发生任何写入之前会自动锁定缓存行。保证缓存是一致的。但在每周排序的系统上,虽然原子操作定义明确,但可能存在对内存其他部分的待处理操作。

    所以,假设线程 A 和 B 都共享一些数据 D。

    1. A 获得了一些锁,它对 D 做事
    2. A 释放锁
    3. B 释放锁,发现 0 引用计数,因此决定删除 D
    4. 删除 D
    5. ... #1 中的待处理数据尚不可见,因此发生了不好的事情。

    使用删除前的线程栅栏获取,当前线程同步其地址空间中其他线程的所有未决操作。当删除发生时,它会看到 A 在 #1 中做了什么。

    【讨论】:

    • A 和 B 作用于同一个锁?
    【解决方案3】:

    考虑两个线程,每个线程都有一个对对象的引用,它们是最后两个引用:

    ------------------------------------------------------------
            Thread 1                              Thread 2
    ------------------------------------------------------------
       // play with x here
    
       fetch_sub(...)                            
                                                fetch_sub(...)
       // nothing
                                                delete x;
    

    您必须确保在调用delete x; 时,线程 1 对 //play with x here 中的对象所做的任何更改对线程 2 可见。为此,您需要一个获取栅栏,它与 fetch_sub() 调用上的 memory_order_release 一起保证线程 1 所做的更改是可见的。

    【讨论】:

    • 如果保证 x 是指向没有析构函数的 pod 的指针(我们可以只做 free(x)),是否仍然需要获取屏障?也可以在if (x.fetch_sub(...) == 1)之前重新排序free(x)而不违反单线程执行的语义吗?
    • @ScottPeterson:我懒得浏览法律术语的标准,但是如果你有一个内存块的free,它在访问同一内存时是无序的不同的线程,这是一个数据竞争和未定义的行为。因此,即使所有删除操作都是free,“在删除另一个线程中的对象之前,强制在一个线程中(通过现有引用)对对象进行任何可能的访问非常重要”。
    • @scott 想象处理器缓存中的数据。它被修改了,但没有写出来。然后将数据块发送到堆进行回收。然后缓存被刷新。繁荣,你只是写给你不拥有的记忆。
    • @Yakk 您的解释比其他人更接近我所寻找的内容(请参阅我在原始问题中的编辑)。但是,当您说“将数据块发送到堆进行回收”时,怎么会发生这种情况,因为delete x 仅在x == 1 时执行,即使对于单线程情况也是如此,而x == 1 暗示所有数据的使用都通过x 是完整的(因为释放障碍)?
    • @scott 虽然远不是这方面的专家,但我认为发布通常被定义为相对于其他收购。即,线程 A 中发布之前的事情发生在线程 B 中获取之后的事情之前。在某些体系结构(例如 x86 和 64)上,唯一的障碍比这更多。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2011-02-05
    • 1970-01-01
    • 1970-01-01
    • 2016-08-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多