[英]C++ Treiber Stack and atomic next pointers
“ Treiber Stack ”通常是最简单的无锁数据结构之一,因此在教授无锁算法的介绍时经常使用它。
我已经看到许多使用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算法的真实示例实现:
https://github.com/khizmax/libcds/blob/master/cds/intrusive/treiber_stack.h#L736
所以我不明白为什么下一个指针甚至需要是原子的。 大多数C ++实现使用带有next
指针的宽松加载/存储,因此在读/写下一个指针时我们不需要任何内存屏障,但我的想法是它根本不需要是原子的。
从我所看到的,任何时候都没有同时写入的任何节点的下一个指针。 相反,可以同时加载下一个指针,但我从未看到算法同时加载+存储或同时存储+存储的任何机会。 实际上,在PUSH算法中,根本不会同时访问下一个指针。
所以在我看来,当并发访问时,下一个指针实际上是“只读”的,所以我不确定为什么甚至有必要让它们成为原子。
然而,我见过的Treiber Stack的每个C ++实现都使下一个指针成为原子。 所以我是正确的,还是有某种原因下一个指针必须是原子的?
如果它像你展示的代码一样简单,那你就是对的。 在发布指向它的指针之后,永远不会修改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 ++ Undefined Behavior,故事的结尾。
实际上,在大多数CPU架构中,或者在最糟糕的经验撕裂时,非原子负载将恰好是原子的 。 (任何ISA的IDK,除了值之外,它会导致任何不可预测的内容,但C ++会打开此选项)。
我不确定是否有任何情况下实际可以使用撕裂的值(放入m_front
),因为clear_links()
在成功CAS之后才能运行。 如果CAS在一个线程中成功,它将在另一个线程中失败,因为它只会尝试使用旧front
作为CAS的expected
arg撕裂next
值。
在实践中,几乎每个人都关心的实现都没有为轻松的原子加载/存储而不是指针大小的对象的常规成本。 实际上,如果指针的原子性不是“自由”的话,这个堆栈非常糟糕。
例如,在AVR(使用16位指针的8位RISC微控制器)上,只需对数据结构进行锁定就可以更便宜,而不是让std::atomic
在此算法中为每个加载/存储使用锁定。 (特别是因为没有多核AVR CPU,所以锁的实现可能非常便宜。)
atomic<>
还使编译器假定某个值可以被另一个线程异步修改。 因此它阻止它优化掉负载或存储,有点像volatile
。 (但也请参阅为什么编译器没有合并多余的std :: atomic写入? )我不认为这里有什么东西需要,而且不会发生。
非原子操作按原子获取和释放操作排序,类似于放松原子操作,CAS修改front
,因此前 - >接下来has a new
前沿,因此非原子负载无法优化。
这可能是一个有趣的实验,看看在atomic <Node*> next
用Node *next
替换atomic <Node*> next
之后是否从编译器获得相同的asm输出。 (或者使用仍具有加载/存储成员函数的非non_atomic
包装类,因此您不必修改很多代码)。
使用放松的原子商店对我来说很好。 你绝对不希望以你展示的方式实现它, seq_cst
存储是初始化一个新对象的一部分,该对象尚未发布任何指针。 在那时,不需要原子性,但它是免费的(在普通的CPU上)所以避免它没有任何好处。 没有任何商店或负载可以被优化掉。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.