C Treiber堆栈和原子下一个指针
作者:互联网
“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实现使用带有下一个指针的宽松加载/存储,因此在读/写下一个指针时我们不需要任何内存栅栏,但我的想法是它根本不需要是原子的.
从我所看到的,任何时候都没有同时写入的任何节点的下一个指针.相反,可以同时加载下一个指针,但我从未看到算法同时加载存储或同时存储存储的任何机会.实际上,在PUSH算法中,根本不会同时访问下一个指针.
所以在我看来,当并发访问时,下一个指针实际上是“只读”的,所以我不确定为什么甚至有必要让它们成为原子.
然而,我见过的Treiber Stack的每个C实现都使得下一个指针成为原子.所以我是正确的,还是有某种原因下一个指针必须是原子的?
解决方法:
如果它像你展示的代码一样简单,那你就是对的.在发布指向它的指针之后,永远不会修改节点.但是你遗漏了清理节点的部分,因此它们可以被垃圾收集. (你不能只是在弹出后删除;另一个线程仍然可以有一个指针,但还没有读过它.这对RCU来说也是一个棘手的问题.)
这是你遗漏的功能,在弹出成功的CAS后调用:
protected:
void clear_links( node_type * pNode ) CDS_NOEXCEPT
{
pNode->m_pNext.store( nullptr, memory_model::memory_order_relaxed );
}
这是一个读者在写入时读取下一个的顺序:
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()在成功CAS之后才能运行.如果CAS在一个线程中成功,它将在另一个线程中失败,因为它只会尝试使用旧前端作为CAS的预期arg撕裂下一个值.
在实践中,几乎每个人都关心的实现都没有为轻松的原子加载/存储而不是指针大小的对象的常规成本.事实上,如果atomicity isn’t “free” for a pointer,这个堆栈非常糟糕.
例如在AVR(使用16位指针的8位RISC微控制器)上,只需对数据结构进行锁定就可以更便宜,而不是让std :: atomic在这个算法中为每个加载/存储使用锁定. (特别是因为没有多核AVR CPU,所以锁的实现可能非常便宜.)
原子<>还让编译器假设某个值可以被另一个线程异步修改.因此它阻止它优化掉负载或存储,有点像易失性. (但也参见Why don’t compilers merge redundant std::atomic writes?.)我认为这里没有任何需要的东西,也不会发生.
非原子操作按原子获取和释放操作排序,类似于放松的原子操作,CAS修改前沿,因此前面> next是一个新的前沿,因此非原子负载无法优化.
在替换原子< Node *>之后,看看是否从编译器获得相同的asm输出可能是一个有趣的实验.接下来是Node * (或者使用仍具有加载/存储成员函数的非原子包装类,因此您不必修改很多代码).
使用放松的原子商店对我来说很好.你绝对不希望以你展示的方式实现它,seq_cst存储是初始化一个新对象的一部分,该对象尚未发布任何指针.在那时,不需要原子性,但它是免费的(在普通的CPU上)所以避免它没有任何好处.没有任何商店或负载可以被优化掉.
标签:lock-free,c,multithreading,algorithm,atomic 来源: https://codeday.me/bug/20190828/1750283.html