19

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

在cppcon2014 上关于无锁编程的演讲中,Herb Sutter 展示了无锁单链表的(部分)实现。该实现看起来很简单,但它依赖于shared_ptr标准库中尚不存在的原子实现或使用专用std::atomic...函数。这一点尤其重要,因为单个 push/pop 调用可能会调用多个原子加载/存储和compare_exchange操作。

我看到的问题(我认为谈话中的一些问题朝着相同的方向发展)是,要使这成为一个实际的无锁数据结构,这些原子操作本身必须是无锁的。我不知道无std::atomic...锁函数​​的任何标准库实现 - 至少通过简短的 google / SO 搜索 - 我也没有找到关于如何为std::atomic<std::shared_ptr>.

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

  • 你知道,如果有可能写一个无锁的原子共享指针吗?
  • 是否已经存在我忽略的任何实现,并且 - 理想情况下 - 甚至与您对 a 的期望兼容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)) { ; }
    }
};

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


解释我的动机:
这主要是一个学术问题。我无意在生产代码中实现我自己的无锁列表,但我觉得这个话题很有趣,乍一看,Herb 的介绍似乎是一个很好的介绍。然而,在思考这个问题和@sehe 对我的回答的评论时,我想起了这个演讲,又看了一遍,意识到如果原始操作需要锁,那么将 Herb 的实现称为无锁并没有多大意义(他们目前所做的)。所以我想知道,这是否只是当前实现的限制还是设计中的根本缺陷。

4

3 回答 3

5

我将其添加为答案,因为它太长而无法放入评论:

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

Sutter 在他的演讲中使用 shared_ptr 的原因是因为编写无锁数据结构最复杂的部分不是同步,而是内存回收:当节点可能被其他线程访问时,我们不能删除它们,所以我们必须泄漏他们并在以后收回。无锁 shared_ptr 实现本质上提供了“免费”内存回收,并使无锁代码的示例变得可口,尤其是在限时演示的上下文中。

当然,将无锁 atomic_shared_ptr 作为标准的一部分将是一个巨大的帮助。但这不是为无锁数据结构进行内存回收的唯一方法,还有维护在执行中的静止点删除的节点列表(仅适用于低争用场景)、危险指针、roll-使用拆分计数的您自己的原子引用计数。

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

PS 如果您对此感兴趣,您应该考虑看看 Anthony Williams 的 C++ Concurrency in Action。它用一整章来编写无锁/无等待代码,它提供了一个很好的起点,介绍了无锁堆栈和队列的实现。

于 2015-10-27T17:51:52.993 回答
1
  • 你知道,如果有可能写一个无锁的原子共享指针吗?

  • 是否已经有任何我忽略的实现——理想情况下——甚至与你对 std::atomic 的期望兼容?

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

在知道这两个项目的 slist 中,它可以帮助解决这种情况,因此 atomic_... 函数可能会起作用。

  • 如果没有办法在当前架构上实现这一点,那么与受锁保护的“普通”链表相比,您是否看到 Herb 的实现有任何其他好处?

来自维基百科:非阻塞算法无锁性质的目的是保证至少一个线程正在取得一些进展。

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

想象一下T需要一个锁来执行复制,这也可能由列表之外的一些操作拥有。如果它被拥有,那么死锁将是可能的,并且调用了基于锁的 slist 实现。

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

当前用于复杂结构(例如向量、映射)的无锁算法往往比锁定算法效率低得多,Dobbs 博士:无锁数据结构但提供的好处(提高线程性能)将显着提高计算机的性能,这往往有大量的空闲cpu。

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

于 2015-08-31T08:16:31.447 回答
-1

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

我见过一些无锁列表的实现,但没有一个是共享指针的。这些容器通常具有特殊用途,因此围绕它们的使用(何时/谁创建/删除)有一个协议,因此不需要使用共享指针。
此外,共享指针引入了与我们最初将我们带到无锁域的低延迟目标相反的开销。

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

我看不到 Herb 的具体实现有什么特别的好处,也许除了学术上的好处,但是无锁列表的明显动机是没有锁。它们通常用作队列,或者只是在对锁过敏的线程之间共享一组节点。
也许我们应该问Herb.. Herb?你在听么?

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

现在,写一个无锁的 shared_ptr。我相信这是可行的。以下是我想到的想法:
- 您可以使用指向持有者的指针的低位来获得某种自旋锁,因此只有在锁定它之后才能取消引用它。您可以对 inc/dec 等使用不同的位。这比锁定整个事物要好得多。
这里的问题是共享 ptr 本身可以被删除,所以它包含的任何内容都必须提供一些外部保护,比如链表。
- 您可以拥有一些共享指针的中央注册表。这不会受到上述问题的影响,但是偶尔在没有延迟峰值的情况下进行扩展将是一项挑战。

总而言之,我目前认为这整个想法是没有实际意义的。如果您发现其他一些不会遇到大问题的方法 - 我会很想知道它:)
谢谢!

于 2015-07-22T01:04:46.073 回答