【问题标题】:Lock-free Reference counting and C++ smart pointers无锁引用计数和 C++ 智能指针
【发布时间】:2016-09-14 04:16:28
【问题描述】:

一般来说,最广为人知的 C++ 中引用计数智能 ptr 类的实现,包括标准 std::shared_ptr,使用原子引用计数,但不提供对同一智能 ptr 实例的原子访问。换句话说,多个线程可以安全地对指向同一个共享对象的单独的shared_ptr 实例进行操作,但是多个线程不能安全地读/写同一个shared_ptr 实例的实例而不提供某种类型诸如互斥锁之类的同步。

称为“atomic_shared_ptr”的shared_ptr 的原子版本是proposed,并且初步的implementations 已经存在。据推测,atomic_shared_ptr 可以通过自旋锁或互斥锁轻松实现,但也可以使用无锁实现。

在研究了其中一些实现之后,有一点很明显:实现无锁std::shared_ptr 非常困难,而且似乎需要很多compare_and_exchange 操作,这让我怀疑简单的自旋锁是否会更好性能。

实现无锁引用计数指针如此困难的主要原因是读取共享控制块(或共享对象本身,如果我们'正在谈论一个侵入式共享指针),并修改引用计数。

换句话说,您甚至无法安全地读取引用计数,因为您永远不知道其他线程何时释放了引用计数所在的内存。

因此,通常会采用各种复杂的策略来创建无锁版本。 implementation here 看起来像使用双引用计数策略,其中有“本地”引用来计算并发访问 shared_ptr 实例的线程数,然后是“共享”或“全局”引用来计算数量指向共享对象的 shared_ptr 实例。

鉴于所有这些复杂性,我真的很惊讶地发现 Dobbs 博士的一篇文章,来自 2004 不少于(在 C++11 原子之前),似乎漫不经心地解决了整个问题:

http://www.drdobbs.com/atomic-reference-counting-pointers/184401888

看起来作者声称能够:

"... [读取] 指向计数器的指针,递增计数器,然后 返回指针——所有其他线程都不能 导致错误的结果”

但我真的不明白他实际实现这一点的方式。他正在使用(非便携式)PowerPC 指令(LL/SC 原语lwarx 和stwcx)来实现这一目标。

执行此操作的相关代码是他所谓的“aIandF”(原子增量和获取),他将其定义为:

addr aIandF(addr r1){
  addr tmp;int c;
  do{
    do{
      tmp = *r1;
      if(!tmp)break;
      c = lwarx(tmp);
    }while(tmp != *r1);
  }while(tmp && !stwcx(tmp,c+1));
  return tmp;
};

显然,addr 是一个指针类型,指向拥有引用计数变量的共享对象。

我的问题是:这是否仅适用于支持 LL/SC 操作的架构? cmpxchg 似乎是不可能的。其次,这究竟是如何工作的?我已经读过这段代码几次了,我真的不明白发生了什么。我了解LL/SC 原语的作用,但我无法理解代码。

我能理解的最好的是addr r1是指向共享对象的指针的地址,也是指向引用计数的指针的地址(我猜是指引用计数变量必须是定义共享对象的struct 的第一个成员)。然后他取消引用addr(获取共享对象的实际地址)。然后,他链接加载存储在tmp中的地址的值,并将结果存储在c中。这是计数器值。然后,他有条件地将递增的值(如果 tmp 已更改,则会失败)存储回 tmp。

我不明白这是如何工作的。共享对象的地址可能永远不会改变,LL/SC 可能会成功 - 但是如果另一个线程同时释放了共享对象,这对我们有什么帮助?

【问题讨论】:

标签: c++ c++11 atomic smart-pointers


【解决方案1】:
addr aIandF(addr r1) {
  addr tmp;
  int c;
  do {
    do {
      // r1 holds the address of the address
      // of the refcount
      tmp = *r1;       // grab the address of the refcount
      if (!tmp) break; // if it's null, bail

      // read current refcount
      // and acquire reservation
      c = lwarx(tmp);

      // now we hold the reservation,
      // check to see if another thread
      // has changed the shared block address
    } while (tmp != *r1); // if so, start over

    // if the store succeeds we know we held
    // the reservation throughout
  } while (tmp && !stwcx(tmp, c+1));
  return tmp;
};

请注意,aIandF 专门用于构造现有共享指针的副本,并声明对副本的引用。

Dobbs 博士的文章将释放引用的操作描述为首先将源共享指针对象中的共享计数器的地址与函数本地的空指针进行原子交换;然后原子地递减计数器;然后测试以查看递减的结果是否为零。这个操作顺序很重要:你说,“共享对象的地址可能永远不会改变,LL/SC 可能会成功——但是如果另一个线程同时释放了共享对象,这对我们有什么帮助? " - 但这永远不会发生,因为如果没有先发生交换,对象将永远不会被释放,这为我们提供了一种观察地址变化的方法。

aIandF 测试输入时计数器地址是否为空。

如果地址发生在lwarx 之前,它可以发现该地址变为空,因为它会在保留后显式测试这一点。

如果递减线程中的交换发生在我们实际上并不关心的 lwarx 之后:如果 aIandF 中的 stwcx 成功,我们知道递减线程将看到新的引用计数并且不会破坏对象,并且我们可以在知道我们已声明引用它的情况下继续进行;而如果另一个线程成功地先递减计数器,我们将失去保留,存储将失败,我们将在下一次循环迭代中检测到对象的破坏。

该算法假定一个高度一致的内存模型(所有线程总是按照程序顺序看到彼此的读写效果)——即使在那些支持 ll/sc 的现代架构上也不一定如此。

编辑:考虑一下,算法也显然假设从曾经有效的内存地址读取总是安全的(例如,没有 MMU/保护;或者,算法坏了):

if (!tmp) break;

// another thread could, at this point, do its swap, 
// decrement *and* destroy the object tmp points to
// before we get to do anything else

c = lwarx(tmp); 

// if that happened, we'll detect this fact and do nothing with c
// but ONLY if the lwarx doesn't trap 
// (due to the memory tmp points to 
// getting unmapped when the other thread frees the object)

【讨论】:

  • 嗯...我明白了。如果从释放的内存中读取,链接的加载也会触发 Valgrind 错误之类的事情。从技术上讲,该算法似乎表现出未定义的行为。我想如果所有节点/共享对象/控制块从池中分配的任何东西都可以工作
猜你喜欢
  • 1970-01-01
  • 2015-10-30
  • 1970-01-01
  • 2014-05-20
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-03-20
  • 1970-01-01
相关资源
最近更新 更多