为什么在删除原子引用计数智能指针中的数据之前需要获取屏障?

时间:2015-01-03 02:05:24

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

Boost提供sample atomically reference counted shared pointer

以下是相关的代码段以及所使用的各种排序的说明:

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   操作,但这导致不需要的“获取”操作时   参考计数器尚未达到零并且可能会产生性能   罚。

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

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

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

3 个答案:

答案 0 :(得分:6)

考虑两个线程,每个线程持有一个对象的引用,这是最后两个引用:

------------------------------------------------------------
        Thread 1                              Thread 2
------------------------------------------------------------
   // play with x here

   fetch_sub(...)                            
                                            fetch_sub(...)
   // nothing
                                            delete x;

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

答案 1 :(得分:2)

来自,http://en.cppreference.com/w/cpp/atomic/memory_order

  

memory_order_acquire - 具有此内存顺序的加载操作会对受影响的内存位置执行获取操作:   写入其他内存位置的线程执行了   释放在此主题中可见。

     

...

     

发布 - 获取订购

     

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

     

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

     

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

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

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

所以,让我们说线程A和B都共享一些数据D.

  1. A获得一些锁定,它会对D
  2. 做一些事情
  3. A release lock
  4. B释放锁定,找到0引用计数,因此决定删除D
  5. 删除D
  6. ......#1中待处理的数据尚未显示,因此发生了不好的事情。
  7. 在删除之前获取线程栏,当前线程会同步其地址空间中其他线程的所有挂起操作。当删除发生时,它会看到A在#1中做了什么。

答案 2 :(得分:0)

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

假设我们的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->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);
    }
}

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

  1. 线程2将x->data加载到寄存器中。
  2. 线程1生成新数据。
  3. 线程1释放“旧”数据。
  4. 线程1将新数据分配给x->data
  5. 线程1将refcount2递减到1
  6. 线程2从refcount减少10
  7. 线程2再次释放“旧”数据,而不是新数据。

对我来说,关键的见解是“先前的写入在该线程中变得可见”可能意味着一些琐碎的事,例如“不要使用在隔离区之前缓存到寄存器的值”。