" 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算法的实际示例实现:
所以我不明白为什么下一个指针甚至需要是原子的。大多数C ++实现使用带有next
指针的宽松加载/存储,因此在读/写下一个指针时我们不需要任何内存栅栏,但我的想法是它不需要完全是原子的。
从我所看到的,任何时候都没有同时写入的任何节点的下一个指针。相反,下一个指针可以同时加载,但我从未看到算法同时加载+存储或同时存储+存储的任何机会。实际上,在PUSH算法中,根本不会同时访问下一个指针。
所以在我看来,下一个指针实际上是&#34;只读&#34;当同时访问时,我不确定为什么甚至有必要使它们成为原子。
然而,我见过的Treiber Stack的每个C ++实现都使下一个指针成为原子。所以我是正确的,还是有某种原因必须使下一个指针成为原子?
答案 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上),因此避免它是没有好处的。没有任何商店或负载可以被优化掉。