Atomic shared_ptr用于无锁单链表

时间:2015-07-07 18:59:06

标签: c++ data-structures shared-ptr lock-free stdatomic

我想知道是否可以为任何“常见”架构(如x64或ARMv7 / ARMv8)创建无锁,线程安全的共享指针。

在关于lock-free programming at cppcon2014的讨论中,Herb Sutter提出了一个(部分)无锁单链表的实现。实现看起来很简单,但它依赖于标准库中不存在或使用专用shared_ptr函数的原子std::atomic...实现。这一点尤为重要,因为单个push / pop调用可能会调用多个原子加载/存储和compare_exchange操作。

我看到的问题(我认为谈话中的一些问题是朝着相同的方向)是因为这是一个实际的无锁数据结构,那些原子操作本身就必须是无锁的。我不知道任何标准库实现的std::atomic...函数是无锁的 - 至少有一个简短的google / SO搜索 - 我也没有找到如何实现锁定的建议 - std::atomic<std::shared_ptr>的免费专业化。

在我浪费时间之前,我想问:

  • 你知道吗,是否可以编写一个无锁的原子共享指针?
  • 是否已经有任何我忽略的实现 - 理想情况 - 甚至与std::atomic<std::shared_ptr>所期望的实现兼容?对于上面提到的队列,它尤其需要CAS - 操作。
  • 如果无法在当前体系结构上实现此功能,那么与具有锁保护的“普通”链接列表相比,Herb的实现中是否还有其他好处?

供参考,以下是Herb Sutter的代码(可能包含来自我的错别字):

template<class T> 
class slist {
    struct Node { T t;  std::shared_ptr<Node> next; };
    std::atomic<std::shared_ptr<Node>> head;        
public:
    class reference{
        std::shared_ptr<Node> p;
    public:
        reference(std::shared_ptr<Node> p_){}
        T& operator*(){ return p->t; }
        T* operator->(){ return &p->t; }
    };
    auto find(T t) const {
        auto p = head.load();
        while (p && p-> != t) {
            p = p - next;
        }
        return reference(move(p));
    }
    void push_front(T t) {
        auto p = std::make_shared<Node>();
        p->t = t;
        p->next = head;
        while (!head.compare_exchange_weak(p->next, p)) {}
    }
    void pop_front() {
        auto p = head.load();
        while (p && !head.compare_exchange_weak(p, p - next)) { ; }
    }
};

注意,在此实现中,shared_ptr的单个实例可以由多个不同的线程访问/修改。它可以被读取/复制,重置甚至删除(作为节点的一部分)。所以这不是关于多个不同的shared_ptr对象(管理同一个对象)是否可以由多个线程使用而没有竞争条件 - 这对于当前实现已经是正确的并且是标准所要求的 - 但它是关于并发的访问单个指针实例 - 对于标准共享指针 - 没有比原始指针上的相同操作更多的线程安全。

解释我的动机:
这主要是一个学术问题。我不打算在生产代码中实现我自己的锁定免费列表,但我发现这个主题很有趣,乍一看,Herb的演示似乎是一个很好的介绍。然而,在考虑this question和@ sehe对我的回答的评论时,我记得这个话题,再看看它,并意识到如果它是原始操作,调用Herb的实现无锁是没有多大意义的需要锁(他们目前正在做)。所以我想知道,这只是当前实现的限制还是设计中的一个根本缺陷。

3 个答案:

答案 0 :(得分:4)

我添加此作为答案,因为它太长而无法发表评论:

要考虑的事情。实现无锁/无等待数据结构需要无锁的shared_ptr

Sutter在他的演示文稿中使用shared_ptr的原因是因为编写无锁数据结构最复杂的部分不是同步,而是内存回收:我们无法删除其他线程可能访问的节点,所以我们必须泄漏它们并在以后回收。无锁的shared_ptr实现基本上提供了#34; free&#34;内存回收并使无锁代码的示例变得可口,尤其是在有时间限制的表示的上下文中。

当然,将无锁的atomic_shared_ptr作为标准的一部分将是一个巨大的帮助。但它不是对无锁数据结构进行内存回收的唯一方法,在执行中的静态点维护要删除的节点列表的天真实现(适用于低争用场景)只有),危险指针,使用拆分计数滚动你自己的原子引用计数。

至于性能,@ mksteve是正确的,无锁的代码不能保证优于基于锁的替代方案,除非它可以在提供真正并发性的高度并行系统上运行。它的目标是实现最大的并发性,因此我们通常得到的是线程在执行更多工作的同时不那么等待。

PS如果这是你感兴趣的东西,你应该考虑看一下Anthony Williams的C ++ Concurrency in Action。它专门写了一整章来编写无锁/无等待代码,它提供了一个很好的起点,遍历无锁堆栈和队列的实现。

答案 1 :(得分:1)

  •   

    你知道吗,如果有可能写一个无锁,原子共享   指针呢?

  •   

    我是否已经有任何实施   被忽视的 - 理想情况 - 甚至与你想要的东西兼容   期待来自std :: atomic?

我认为std::atomic_...提供了一种实现形式,其中slist将对shared_ptr执行特殊的atomic_查询。将它分成两个类(std :: atomic和std :: shared_ptr)的问题在于它们每个都有需要遵守的约束才能运行。阶级分离使得共享圣徒的知识变得不可能。

在知道两个项目的slist中,它可以帮助解决问题,因此原子_...函数可能会起作用。

  •   

    如果没有办法在当前体系结构上实现这一点,那么你呢?   看到Herb实施中的任何其他好处与“正常”相比   受锁保护的链表?

Wikipedia : Non blocking algorithm锁定自由的目的,是为了保证至少一个线程正在取得一些进展。

这并不能保证比锁定的实现更好的性能,但确实可以保证不会发生死锁。

想象一下T需要一个锁来执行一个副本,这也可能是列表之外的一些操作所拥有的。然后就可能出现死锁,如果它已经拥有,则会调用基于锁的slist实现。

我认为CAS是在std::compare_exchange_weak中实现的,因此将独立于实现。

复杂结构的当前无锁算法(例如矢量,映射)往往比锁定算法Dr Dobbs : lock-free data structures效率低得多,但提供的好处(改进的线程性能)将显着提高计算机的性能,有大量的空闲cpus。

对算法的进一步研究可以识别可以在未来的CPU中实现的新指令,以便为我们提供无等待的性能并提高计算资源的利用率。

答案 2 :(得分:-1)

可以编写一个无锁共享ptr,因为唯一需要更改的是计数。 ptr本身只是复制,所以这里不需要特别小心。删除时,这必须是最后一个实例,因此其他线程中不存在其他副本,因此没有人会在同一时间内增加 但话说回来,std :: atomic&gt;因为它不是一个原始类型,所以它是一个非常专业的东西。

我已经看到了一些无锁列表的实现,但没有一个是共享指针。这些容器通常具有特殊用途,因此就它们的使用(何时/谁创建/删除)达成一致,因此不需要使用共享指针。
此外,共享指针引入的开销与我们的低延迟目标相反,这些目标首先使我们进入无锁域。

所以,回到你的问题 - 我认为这是可能的,但我不明白为什么这样做。
如果你真的需要这样的东西, refCount 成员变量会更好。(

我认为Herb的具体实施没有特别的好处,可能除了学术上的一个,但无锁列表有明显的动机,没有锁定。它们通常用作队列或仅用于在对锁过敏的线程之间共享节点集合 也许我们应该问Herb .. Herb?你在听吗?

修改 根据以下所有评论,我已经实现了一个无锁的单链表。该列表相当复杂,以防止共享ptrs在访问时被删除。这里发布的内容太大了,但这里有一些主要的想法:
- 主要思想是将已删除的条目存储在一个单独的位置 - 垃圾收集器 - 以使后续操作无法访问它们。
- 进入每个函数( push_front pop_front )时,原子引用计数会递增,并在退出时自动递减。在递减到零时,版本计数器递增。一体化的原子指令 - 当需要删除共享的ptrs时,在 pop_front 中,它会被推送到GC中。每个版本号都有一个GC。 GC使用更简单的无锁列表实现,该列表只能 push_front pop_all 。我已经创建了256个GC的循环缓冲区,但是可以应用其他一些方案 - 版本的GC在版本增量上刷新,然后共享ptrs删除持有者 所以,如果你调用pop_front,没有其​​他任何东西在运行,则ref计数增加到1,前面的共享ptr被推送到GC [0],ref计数回零,版本为1,GC [0]被刷新 - 它减少我们弹出的共享ptr,并可能删除它拥有的对象。

现在,wrt是一个无锁的shared_ptr。我相信这是可行的。以下是我想到的想法:
- 您可以使用指针的低位来进行各种旋转锁定,因此只有在您锁定它之后才能取消引用它。你可以为inc / dec等使用不同的位。这比锁定整个东西要好得多 这里的问题是共享ptr本身可以被删除,因此包含它的任何东西都必须提供一些外部保护,比如链表。
- 您可以拥有一些共享指针的中央注册表。这不会受到上述问题的影响,但如果在没有延迟峰值的情况下进行扩展将会很难实现。

总而言之,我目前认为这个想法没有实际意义。如果你发现其他方法没有遇到大问题 - 我会非常好奇地了解它:) 谢谢!