原子引用计数

24
我想确切地了解线程安全的原子引用计数是如何工作的,例如像std::shared_ptr一样。我的意思是,基本概念很简单,但我真的很困惑如何通过decref加上delete来避免竞态条件。
这个Boost教程演示了如何使用Boost原子库(或C++11原子库)实现原子线程安全的引用计数系统。
#include <boost/intrusive_ptr.hpp>
#include <boost/atomic.hpp>

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;
    }
  }
};

好的,我大致明白了。但是我不明白为什么以下情况是不可能的:

假设 refcount 目前为 1

  1. 线程 A:原子性地将 refcount 减少至 0
  2. 线程 B:原子性地将 refcount 增加至 1
  3. 线程 A:调用指向管理对象的指针的 delete 方法。
  4. 线程 B:视 refcount 为 1,访问管理对象指针... 段错误!

我无法理解是什么阻止了这种情况的发生,因为并没有防止数据竞争在 refcount 变为 0 和对象被删除之间发生。decref refcount 和调用 delete 是两个独立的、非原子的操作。所以,如果没有锁,这怎么可能?


5
两个线程都引用了一个对象,但是引用计数为1是怎么回事? - Curve25519
你需要明确使用计数器的不变量。只要有用户,使用计数器就不应该达到0。你的代码一定有漏洞。 - curiousguy
5个回答

30
您可能高估了shared_ptr提供的线程安全性。
原子引用计数的本质是确保如果管理同一对象的两个不同shared_ptr实例被访问/修改,就不会有竞态条件。但是,如果两个线程访问同一个shared_ptr对象(其中一个是写入操作),shared_ptr不能确保线程安全。例如,一个线程取消引用指针,而另一个线程重置它。
因此,shared_ptr唯一保证的事情就是只要单个shared_ptr实例没有竞争,就不会出现双重删除和泄漏(它也不会使指向的对象的访问线程安全)。
因此,如果没有其他线程可以同时删除/重置共享指针,复制共享指针也是安全的(也可以说它没有内部同步)。这就是您描述的情况。

再重复一遍:从多个线程中访问一个shared_ptr 实例,其中之一是对指针的写入,仍然存在竞态条件。

如果您想以线程安全的方式复制std::shared_ptr,则必须确保所有加载和存储都通过 std :: atomic_ ... 操作进行,这些操作专门针对 shared_ptr 进行了特化。


2
shared_ptr 上的原子操作鲜为人知且被低估。+1 - sehe
我使用过它们,但它们还没有得到广泛支持。此外,标准并不要求实现必须实际使用原子操作。 - sehe
这也适用于QSharedPointer吗? Qt文档指出,“QSharedPointer和QWeakPointer是线程安全的,并且在指针值上原子操作。” - philipp
@philipp:正如你自己所说,QSharedPointer的所有方法都是线程安全的。所以上面的回答并不适用于它,只适用于std::shared_ptr(我相信也适用于boost版本)。实际上,我的印象是QT中的大多数东西与STL中的工作方式不同。 - MikeMB
@MartinQuinson:我只是提到这个作为我知道这些专业的原因(尽管我仍然没有使用它们)。你想要回答什么问题?也许我可以满足您。 - MikeMB
显示剩余4条评论

3

您的情况不可能发生,因为Thread B应该已经使用递增的引用计数创建。Thread B不应该在第一件事情做的时候增加引用计数。

假设Thread A生成Thread B。Thread A有责任在创建线程之前增加对象的引用计数,以保证线程安全。然后,Thread B只需要在退出时调用release。

如果Thread A在创建Thread B时没有增加引用计数,可能会发生像您描述的那样的问题。


1
你能提供你回答问题的链接吗? - IInspectable
4
好的,我会尽力进行翻译。这句话的意思是:“@IInspectable在这个页面上对问题给出了一个完全合理的回答,或者我错过了什么?” - psmears

3

线程B:原子地将引用计数增加到1。

不可能。要将引用计数增加到1,引用计数必须为零。但是如果引用计数为零,线程B如何访问该对象呢?

无论是线程B有一个对该对象的引用还是没有,如果它有引用,则引用计数不能为零。如果没有引用,那么为什么它要干涉由智能指针管理的对象,当它没有对该对象的引用时?


3

该实现并不提供或要求这样的保证,避免您描述的行为取决于计数引用的正确管理,通常通过RAII类(例如std::shared_ptr)完成。关键是完全避免通过原始指针跨作用域传递。任何存储或保留对象指针的函数必须采用共享指针,以便可以正确地增加引用计数。

void f(shared_ptr p) {
   x(p); // pass as a shared ptr
   y(p.get()); // pass raw pointer
}

这个函数传入了一个shared_ptr,因此引用计数已经是1+。我们的本地实例p在复制赋值期间应该增加了引用计数。当我们调用x时,如果我们按值传递,我们会创建另一个引用。如果我们通过const引用传递,我们会保留当前的引用计数。如果我们通过非const引用传递,则x()释放了引用是可行的,y将使用null被调用。
如果x()存储/保留原始指针,那么我们可能会有问题。当我们的函数返回时,引用计数可能达到0,对象可能被销毁。这是我们未能正确维护引用计数的错误。
考虑:
template<typename T>
void test()
{
    shared_ptr<T> p;
    {
        shared_ptr<T> q(new T); // rc:1
        p = q; // rc:2
    } // ~q -> rc:1
    use(p.get()); // valid
} // ~p -> rc:0 -> delete

vs

template<typename T>
void test()
{
    T* p;
    {
        shared_ptr<T> q(new T); // rc:1
        p = q; // rc:1
    } // ~q -> rc:0 -> delete
    use(p); // bad: accessing deleted object
}

然而,仅使用共享指针赋值运算符可能会导致问题。我遇到了这样的情况,就像我在这里描述的那样:http://stackoverflow.com/questions/31918932/what-is-the-cost-of-calling-member-function-via-shared-pointer - philipp

1
对于std::shared_ptr,引用计数的变化是线程安全的,但访问`shared_ptr`内容不是线程安全的。
关于boost::intrusive_ptr<X>,这并不是一个答案。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接