C ++ Treiber Stack和原子下一个指针

时间:2017-09-25 22:19:02

标签: c++ multithreading algorithm atomic lock-free

" virtualenv"通常是最简单的无锁数据结构之一,因此在教授无锁算法的介绍时经常使用它。

我已经看到许多使用C ++原子的Treiber Stacks的实现。算法本身是微不足道的,因此真正的挑战是处理无锁数据结构的所有其他附带细节,例如提供执行安全内存回收的某种方式,避免ABA问题,以及以无锁方式分配节点。这可以通过各种方式解决,例如使用原子引用计数,危险指针,计数/标记指针以避免ABA,以及使用无锁内存池。

但忽略所有这些细节并专注于简单的算法本身,我想到的一个问题是,我可以回想起的Treiber Stacks的每个实现都使用原子下一个指针来实现节点类 。例如:

struct Node
{
  T value;
  std::atomic<Node*> next;
};

但在考虑算法之后,我不确定为什么下一个指针需要是原子的。

通用PUSH算法(忽略无锁分配,安全内存回收,退避,ABA避免等)是:

Node* n = new Node();
Node* front = m_front.load();
n->next.store(front);
while (!m_front.compare_exchange_weak(front, n))
{
  n->next.store(front);
}

一般POP算法(同样,忽略除实际算法逻辑之外的所有细节)是:

Node* front = m_front.load();
Node* next = front->next.load();
while (!m_front.compare_exchange_weak(front, next))
{
  next = front->next.load();
}

以下是PUSH算法的实际示例实现:

Treiber Stack

所以我不明白为什么下一个指针甚至需要是原子的。大多数C ++实现使用带有next指针的宽松加载/存储,因此在读/写下一个指针时我们不需要任何内存栅栏,但我的想法是它不需要完全是原子的。

从我所看到的,任何时候都没有同时写入的任何节点的下一个指针。相反,下一个指针可以同时加载,但我从未看到算法同时加载+存储或同时存储+存储的任何机会。实际上,在PUSH算法中,根本不会同时访问下一个指针。

所以在我看来,下一个指针实际上是&#34;只读&#34;当同时访问时,我不确定为什么甚至有必要使它们成为原子。

然而,我见过的Treiber Stack的每个C ++实现都使下一个指针成为原子。所以我是正确的,还是有某种原因必须使下一个指针成为原子?

1 个答案:

答案 0 :(得分:5)

如果它与您展示的代码一样简单,那么您是对的。在发布指向它的指针后,永远不会修改Node。但是你遗漏了清理节点的部分,因此它们可以被垃圾收集。 (弹出后你不能只是delete;另一个线程仍然可以指向它,但还没有读过它。对于RCU来说这也是一个棘手的问题。)

这是你遗漏的功能,在pop成功的CAS之后调用:

protected:
    void clear_links( node_type * pNode ) CDS_NOEXCEPT
    {
        pNode->m_pNext.store( nullptr, memory_model::memory_order_relaxed );
    }

以下是读者在撰写next时阅读的顺序:

A: Node* front = m_front.load();
                                 B: Node* front = m_front.load();  // same value
A: Node* next = front->next.load();
A: m_front.compare_exchange_weak(front, next) // succeeds, no loop
A: clear_links(front);  // i.e. front->next.store(nullptr);

                                 B: front->next.load();

因此,就标准符合性而言,C ++未定义行为,故事结束。

实际上,大多数CPU架构上的非原子载荷will happen to be atomic anyway,或者最糟糕的经验撕裂。 (任何ISA的IDK,除了值之外,它会产生任何不可预测的内容,但C ++会打开此选项)。

我不确定是否有任何情况下实际可以使用使用(放入m_front),因为clear_links()可以&# 39;运行到CAS成功后。如果CAS在一个线程中成功,它将在另一个线程中失败,因为它只会尝试使用旧的next作为CAS front arg的expected值。

在实践中,几乎所有人都关心的实现都没有为轻松的原子加载/存储而不是指针大小的对象的常规成本。事实上,如果atomicity isn't "free" for a pointer,这个堆栈非常糟糕。

e.g。在AVR(使用16位指针的8位RISC微控制器)上,对数据结构进行锁定会更便宜,而不是让std::atomic对此算法中的每个加载/存储使用锁定。 (特别是因为没有多核AVR CPU,所以锁的实现可能非常便宜。)

atomic<>也让编译器假设某个值可以被另一个线程异步修改。所以它阻止它优化掉负载或存储,有点像volatile。 (但也请参阅Why don't compilers merge redundant std::atomic writes?。)我不认为这里有什么需要,而且不会发生。

非原子操作按原子获取和释放操作排序,类似于轻松的原子操作,CAS修改front,因此前面&gt;下一个has a new前面`所以非原子负载无法优化。

在将atomic <Node*> next替换为Node *next之后,查看是否从编译器获得相同的asm输出可能是一个有趣的实验。 (或者使用non_atomic包装器类仍然具有加载/存储成员函数,因此您不必修改很多代码。)

使用放松的原子商店对我来说很好看。你肯定希望以你展示的方式实现它,seq_cst商店作为初始化一个尚未发布任何指针的新对象的一部分。在那时,不需要原子性,但它是免费的(在普通的CPU上),因此避免它是没有好处的。没有任何商店或负载可以被优化掉。