为什么这些无锁引用计数实现不会崩溃(或者它们会吗)?

3

我正在努力理解LFRC算法,但我一直找到的例子都是在我假设它们有效的情况下工作,但我无法理解为什么。我关注的是复制和删除。特别是有三种算法都遵循相同的一般模式(伪代码):

Copy {
  shared = source->shared;
  if (shared != null) {
     Atomically_Increment_Counter(shared);
  }
}

Delete {
  if (shared != null) {
     oldcounter = Atomically_Decrement_Counter(shared);
     if (oldcounter == value signifying all references released) {
        Deallocate(shared);
     }
  }
}

首先,这篇文章是2004年David L. Detlefs所写的论文《无锁引用计数》,其中的图2、第8页(为了格式而编辑):

void LFRCCopy(SNode **v, SNode *w) {
   Snode *oldv = *v;
   if (w != null) 
      add_to_rc(w,1);
   *v = w;
   LFRCDestroy(oldv);
}

void LFRCDestroy(SNode *v) {
   if (v != null && add_to_rc(v, -1)==1) {
     LFRCDestroy(v->L);
     LFRCDestroy(v->R);
     delete v;
   }
}

其中add_to_rc是原子增量和加载。第二个来自于opencv4中Mat实现(编辑以缩短长度):

// Line 405
Mat::Mat(const Mat& m)
    : flags(m.flags), dims(m.dims), rows(m.rows), cols(m.cols), data(m.data),
      datastart(m.datastart), dataend(m.dataend), datalimit(m.datalimit), allocator(m.allocator),
      u(m.u), size(&rows), step(0)
{
    if( u )
        CV_XADD(&u->refcount, 1);
    ...
}

// Line 551
void Mat::release()
{
    if( u && CV_XADD(&u->refcount, -1) == 1 )
        deallocate();
    u = NULL;
    ...
}

其中CV_XADD是原子增量和加载。第三个来自最新版本的GCC(10.2)中随附的libstdc++中的std::shared_ptr实现(此实现非常复杂,我已经为了简洁性进行了编辑):

class _Sp_counted_base {

    void _M_add_ref_copy()
    { __gnu_cxx::__atomic_add_dispatch(&_M_use_count, 1); }

    // Line 161 for full implementation
    void _M_release() noexcept
    {
       _GLIBCXX_SYNCHRONIZATION_HAPPENS_BEFORE(&_M_use_count);
       if (__gnu_cxx::__exchange_and_add_dispatch(&_M_use_count, -1) == 1)
       {
          _GLIBCXX_SYNCHRONIZATION_HAPPENS_AFTER(&_M_use_count);
          _M_dispose();
          if (_Mutex_base<_Lp>::_S_need_barriers)
             __atomic_thread_fence (__ATOMIC_ACQ_REL);
          // editors note: weak pointer handling removed for brevity
          _M_destroy();
       }
    }

}

class __shared_count {

    __shared_count(const __shared_count& __r) noexcept 
        : _M_pi(__r._M_pi)
    {
        if (_M_pi != nullptr)
           _M_pi->_M_add_ref_copy();
    }
    
    ~__shared_count() noexcept
    {
        if (_M_pi != nullptr)
           _M_pi->_M_release();
    }

    _Sp_counted_base* _M_pi;

}

class __shared_ptr {

    __shared_ptr(const __shared_ptr&) noexcept = default;

    ~__shared_ptr() = default;

    element_type*   _M_ptr;         // Contained pointer.
    __shared_count  _M_refcount;    // Reference counter.

}

关于最后一个问题的一些解释,因为它有点间接:

  • 复制: __shared_ptr 默认的复制构造函数复制了_M_refcount,其复制构造函数共享相同的_Sp_counted_base作为原始对象,并原子性地增加引用计数。
  • 删除: __shared_ptr 默认的析构函数销毁_M_refcount,其析构函数调用_M_release_Sp_counted_base上,该函数原子性地减少并可能释放引用计数。

总之,这归结为一个问题,这是我无法理解为什么这些工作(即使那篇旧的Dr. Dobbs文章和相关的SO问题[我觉得答案可能在这里但我看不到]对我来说提出了更多的问题):

复制操作中,有什么可以防止另一个线程在最后一个引用计数上执行删除操作(可能通过共享对象的另一个视图),然后在复制操作开始和原子计数器增量开始之间释放对象,因此,从我(误)理解的情况来看,复制将增加一个已分配的计数器并在各处崩溃或转储核心?

也就是说,参考上面的opencv实现(因为检查它实际上启动了我整个小型研究项目):

Mat::Mat(const Mat& m)
    : flags(m.flags), dims(m.dims), rows(m.rows), cols(m.cols), data(m.data),
      datastart(m.datastart), dataend(m.dataend), datalimit(m.datalimit), allocator(m.allocator),
      u(m.u),   // <--- START OF DANGER ZONE 
      size(&rows), step(0)
{
    if( u )     
                // <--- ALMOST AT END OF DANGER ZONE 
        CV_XADD(&u->refcount, 1);
    ...
}

void Mat::release()
{
    if( u && CV_XADD(&u->refcount, -1) == 1 )
        deallocate();
    u = NULL;
    ...
}

另一个线程如何防止在复制构造函数中通过 m 在标记为“危险区”的位置释放最后一个引用,从而使 u 非空但指向已释放的垃圾内存?

我错过了什么?我觉得这可能很明显,也许是因为我已经醒太久了。特别是,libstdc++ 实现似乎也遵循这种模式,给它带来了很多信誉;只是我无法理解细节。


1
通常,这归结于操作所期望具有的安全程度。这些RC对象被期望是线程安全的,而不是原子性的。因此,避免对同一引用进行多个并发写入(或并发读取和写入)仍然是用户的责任,否则就会出现竞争条件。 - undefined
1
@JasonC我认为你不能在两个线程中使用相同的shared_ptr对象(在你的情况中是x)。这意味着你不能在一个线程中"复制"它并在另一个线程中"销毁"它。 - undefined
3
如果您在一个线程中使用shared_ptr,另一个线程就不能拥有其唯一引用。 - undefined
1
如果您在一个线程中使用shared_ptr,那么另一个线程要么有自己的shared_ptr并且对引用计数有一个“股份”,即其>=2 或者其他同步机制确保了“借用”。如果是借用的话,首选方法是传递原始指针(shared_ptr::get())来表示它具有对存储对象的引用,但不直接参与其引用计数。 - undefined
1
我认为伪代码有点模糊。怎么样改成 was=Atomically_Decrement_Counter(shared);,然后下一行变成 if(was==INIT_VALUE),其中 INIT_VALUE 是在构造过程中共享变量的初始值,并且如果存在特殊生命周期,可能不是1。was 必须是原子递减期间共享变量被替换的值。 - undefined
显示剩余2条评论
1个回答

5

简短回答,你很累!

这是经典的“引用计数模型”。当对象被创建时,它的引用计数被初始化(经典地为1)。创建线程拥有该引用。当创建新引用时,该线程可以增加或减少该计数,并在最终达到0时删除对象。这都是单线程的,但你的问题涉及多线程。

对于多线程,还有两个额外的规则:

  1. 除非已经拥有一个引用,否则线程不得尝试减少计数器。
  2. 同样,线程不得尝试增加计数器,除非它已经持有一个引用。

因此,在一个线程尝试增加引用数量(这里是复制品)的同时,另一个线程尝试释放对象的竞争条件永远不会发生(也必须永远不会发生)。

因此,如果另一个线程正在释放引用计数,而另一个线程正在增加引用计数,则计数始终应该>=2,因为两个线程都必须持有一个引用。

__gnu_cxx::__exchange_and_add_dispatch(&_M_use_count, -1) 返回被替换的值,因此该行代码 if (__gnu_cxx::__exchange_and_add_dispatch(&_M_use_count, -1)==1 的意思是“如果这个线程交换了10的值,即‘如果这个线程持有最后一个引用’”。

确保没有线程在不持有引用的情况下递减或递增计数器的策略有很多种。但主要的策略是,一个持有引用的线程在将其“传递”给另一个线程或者只是“放弃”它的引用之前先增加它。

在“线程池”中,对象被放置在队列中,可能无法确定引用正在传递给哪个线程,并且需要进一步同步(例如在队列上)以确保只有一个线程会“声明”该“共享”的引用。

通过“持有”,我指的是拥有或借用。拥有意味着拥有引用的线程(将确保其最终释放),而“借用”意味着实际拥有(将确保其最终释放)的线程将与借用者同步,并确保在其他线程获取引用或不再访问对象之前不会释放它。
代码片段似乎有效(我不熟悉所有这些库的细节),但确认__exchange_and_add_dispatch返回旧值第29章并发就足以让我相信这就是正在发生的事情。
然而,这些策略的缺点是,所有增加或减少计数的代码都必须符合该策略,因此,我们不知道代码是否有效,除非密切检查引用对象的所有点的设计和实现。每个人都持有一个参考吗?如果他们增加了它,他们已经持有参考吗?
弱引用是该模型的微妙技巧,表示遵循相同模型的单独计数器。尽管可能会(像使用std :: make_shared的std :: shared_ptr常见一样)与主对象一起分配(new),并且在最后一个弱引用之前主对象可能被销毁,但当两个计数器都达到零时,内存块将被删除,并且如果存在弱引用,则在释放最后一个强引用时标记弱引用以显示已删除主对象。

哦!我明白了(大概)!你说得没错,这肯定是因为我累了的原因。你这样解释清楚了。我一直忘记的那部分本质上是,在进行复制时至少要保留一个引用,不能在没有引用存在的情况下进行复制。类似于那样的东西。但是我明白了。我也相信现在是停下来,明天早上再试的时候了。谢谢。 - undefined
1
@JasonC 这是一个规则的延伸,即如果在整个过程中没有保持引用,那么任何线程都不应该读取或写入对象。晚安!我保证你醒来后会恍然大悟! - undefined
1
你是对的,顺便说一句:我今天早上醒来就觉得,“哦...没错”。再次感谢。 - undefined

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