我需要构建一个无锁的堆栈实现。我读了this page,我理解列出的无锁推送操作的功能。
现在,我必须构建一个类似版本的pop操作。这是我迄今为止所做的,但我认为,存在一些并发问题:
template <class T>
bool CASStack<T>::pop(T& ret) {
node<T>* old_head = head.load(std::memory_order_relaxed);
if(old_head == nullptr) {
return false;
}
// from here on we can assume that there is an element to pop
node<T>* new_head;
do {
new_head = old_head->next;
} while(!head.compare_exchange_weak(old_head, new_head, std::memory_order_acquire, std::memory_order_relaxed));
ret = old_head->data;
return true;
}
我想如果我在交换后删除old_head也会遇到麻烦,对吗?
编辑:更新了问题!
答案 0 :(得分:2)
你的node<T>* new_head = old_head->next;
是一只红鲱鱼;你永远不会使用这个变量。
在我的评论中建议你需要把它放在do{}while(!CAS)
循环中,我以为你在做head.CAS(old_head, new_head)
。如果CAS必须重试,这就会产生我正在谈论的问题,即将一个可能过时的指针放入列表中。
但是你实际上正在做head.CAS(old_head, old_head->next)
,每次循环时都会从更新的old_head
生成“所需”值。这实际上是正确的,但很难遵循,因此我建议使用do{}while()
,如此:
node<T>* pop(std::atomic<node<T>*> &head)
{
// We technically need acquire (or consume) loads of head because we dereference it.
node<T>* old_head = head.load(std::memory_order_acquire);
node<T>* new_head;
do {
if(old_head == nullptr) {
// need to re-check because every retry reloads old_head
// pop in another thread might have emptied the list
return nullptr;
}
new_head = old_head->next;
// if head still equals old_head this implies the same relation for new_head
} while(!head.compare_exchange_weak(old_head, new_head,
std::memory_order_acquire));
// Note the ordering change: acquire for both success and failure
return old_head; // defer deletion until some later time
}
是否允许在compare_exchange_weak中执行
old_head->next
?这仍然是原子的吗?
CAS仍然是原子的。编译的任何compare_exchange_weak
本身都是原子的。但是,编译器在函数调用之前评估args ,因此读取old_head->next
不是CAS所执行的原子事务的一部分。它已经被单独读成一个临时的。 (在do{}while
循环中使用单独的变量显式执行此操作很常见。)
如果node::next
是atomic<>
的{{1}}成员,您应该考虑要为该负载使用的内存顺序。但是对于纯栈而言,它不一定是原子的,因为链接列表节点在它们位于堆栈时永远不会被修改,只有才能用<{1}}推送指针。共享只读访问权限不是竞赛。
作为纯栈的用法也减少了删除问题:线程不能“窥视”头节点或遍历列表。他们只能在弹出节点后查看节点内的节点,node
算法确保他们拥有该节点的独占所有权(并负责删除它)。
但是next
本身需要从pop
节点加载。如果另一个线程与我们竞争并将该pop()
的内存返回给操作系统,我们可能会出错。所以我们做有一个删除问题like RCU does,就像我在评论中提到的那样。
简单地将内存重用于其他内容对大多数 C ++实现不会有问题,但是:我们会读取head
的垃圾值,但CAS会失败(因为在释放旧的head对象之前,head
指针必须已经改变了。所以我们永远不会对我们加载的虚假值做任何事情。但它仍然是C ++ UB,因为我们的原子载荷与非原子商店竞争。但是编译器必须证明这个竞赛实际上发生之后才允许发出除正常asm以外的任何东西,并且所有主流CPU在asm中都没有任何问题。
但除非你能保证old_head->next
或head
只是将内存放在免费列表中,即它们free()
之间没有加载delete
并且munmap
的deref,上述推理并不能使调用者立即删除head
的返回值。它只意味着问题不太可能(并且很难通过简单的测试来检测)。
我们加载old_head->next
然后期望指针指向有用的值。 (即pop
)。这正是head
给我们的。但它很难使用,并且很难优化编译器只需将其加强到old_head->next
,这使得无法测试使用memory_order_consume
的代码。 因此,对acquire
的所有负载,我们确实需要consume
。
请注意,从我们弹出的节点中获取值还取决于内存排序,但如果我们不需要acquire
,我认为我们可以在任何地方放宽,但在{ {1}} CAS的一侧(我们至少需要head
,所以在实践中old_head->next
)。
(在主流的C ++实现中,我们可能会在除了DEC Alpha AXP之外的所有体系结构上使用success
,这是90年代着名的弱排序RISC。编译器几乎肯定会在加载时创建具有数据依赖性的代码指针,因为它没有任何其他方式来访问它所需的值。除Alpha之外的所有“普通”硬件都免费提供consume
样式依赖性排序。因此使用acquire
进行测试永远不会显示问题,除非你有一个罕见的Alpha模型,实际上可以在硬件中产生这种重新排序,并为它提供一个有效的C ++ 11实现。但它仍然是“错误的”,可能会打破编译时重新排序,或者我可能遗漏了某些东西,relaxed
实际上可能会在实践中破坏,而不会内联到更复杂的+常量传播。)
请注意,这些mo_consume
会在推送当前relaxed
指向的对象的线程中加载synchronize-with relaxed
商店。这可以防止mo_acquire
的非原子负载与非原子存储竞争到推动它的线程中的节点。
答案 1 :(得分:2)
想象一下,在加载old_head和解除引用old_head-&gt;之间,cpu被一个中断转移,并且很长时间没有回到这个序列(几天,几周等等)。 与此同时,其他一些线程已从您的堆栈中弹出“old_head”,对其进行处理,并将其返回到堆中,并可能将其重新用于另一个对象。
它适用于'推送'的原因是'推送代码&#39;拥有要推送的对象。对于'pop'来说并非如此 - pop正在发现该对象,然后试图获得它的所有权。要使用“无锁”,您必须能够同时执行这两项操作;这使链接列表很难,如果不是无法使用的话。
相比之下,对于数组,您知道'next'是'top - 1',所以:
do {
x = stack[temp = top];
} while (cswap(&top, temp, temp-1) != temp);
很诱人。需要考虑的是,您需要将生成计数编码为top,以便每个“top”的赋值都是唯一的:
struct uuidx { int index; very_large_int sequence; };
extern (volatile, atomic, whatever) struct uuidx top;
...
struct uuidx temp, next;
do {
x = stack[(temp = top).index];
next = (struct uuidx){.index = temp.index - 1,
.sequence = temp.sequence+1};
} while (cswap(&top, temp, next) != temp)
答案 2 :(得分:2)
@PeterCordes给出的答案很有启发性,但并未解决所有问题。
我要写自己的答案,因为我也必须实现无锁堆栈,并且在弹出操作的重入测试中失败。
Cordes先生给出的实施不依靠底层的ABA problem。
了解重入问题:当尝试弹出堆栈头时,只有在堆栈头“相同”的情况下,CAS(compare_and_exchange)操作才会继续。
此处的“相同”是关键:只要执行CAS指令,相同就意味着指针相同,但数据不一定如此-如果同时出现堆栈从第二个线程遭受适当的弹出? ...,然后,另一个线程(第三个线程)向后推一个新元素,该元素恰好存储在线程1中头部的相同地址了吗?
在这种情况下,线程1中的CAS指令将成功执行,但是要考虑到-> next指针不再有效。
避免这种ABA问题的正确方法似乎是存储一个由头指针和下一个指针组成的ATOMIC HEAD结构。
建议的解决方案在这里实现-MTL's UnorderedArrayBasedReentrantStack
再入测试在这里-https://github.com/zertyz/MTL/blob/master/tests/cpp/UnorderedArrayBasedReentrantStackSpikes.cpp
在x86_64和ARM 32和64位上进行了测试。
希望这对某人有帮助。
答案 3 :(得分:0)
这是我的解决方案:
template <class T>
bool CASStack<T>::pop(T& ret) {
node<T>* new_head;
// get the current head
node<T>* old_head = head.load(std::memory_order_relaxed);
do {
// it is a null pointer iff our stack is empty
if(old_head == nullptr) {
return false;
}
// otherwise, we can dereference it and access its next node
new_head = old_head->next;
} while(!head.compare_exchange_weak(old_head, new_head, std::memory_order_acquire, std::memory_order_relaxed));
// finally write the popped value into ret
ret = old_head->data;
return true;
}
我非常感谢您的评价。 我知道这个代码有两个问题:
1)如果另一个线程在head.load
和nullptr
比较之间推送一个元素,我的算法不会弹出它。我不知道如何解决这个问题。
2)在push
操作中,使用new
创建元素。如果我在delete old_head;
之前添加return true;
,我的代码会崩溃。所以我知道这个算法有内存泄漏。我可以申请this解决方案吗?
答案 4 :(得分:0)
如果您希望多个线程同时执行 push
和 pop
操作,则无法实现无锁堆栈。
在这种情况下,修改堆栈指针和访问堆栈上的读取或写入数据应该以原子方式完成。如果不是,您有两种情况,对于 push
内存操作顺序:
pop
操作并从堆栈中读取垃圾数据。push
可能会使我们在指针递增之前刚刚写入的数据无效同样,如果并发 pop
操作是可能的,读取数据和修改堆栈指针可以与产生无效状态的各种操作交错:
pop
在指针递减后读取数据,并发push
可能会在读取之前覆盖数据pop
在指针递减之前读取数据,并发pop
后跟push
可能会导致旧数据被读取两次,新数据丢失如果只有一个线程在推送,并且只有一个线程在弹出数据,则可以实现(部分)无锁:
push
可以读取带有 acquire
语义的堆栈指针,并安全地覆盖堆栈指针,因为没有其他线程正在使用该内存区域,然后自动更新堆栈指针以向 { {1}} 线程认为指针下方的数据有效(具有 pop
语义/内存排序)release
可以读取带有 pop
语义的堆栈指针值,读取其上的数据,然后将值与 acquire
语义进行比较,以向其他线程发出内存使用的信号弹出的值可再次用于新值。在比较交换失败时,这意味着更多的值被压入,并且它可以读取堆栈顶部的新值。在代码中:
release
这个实现是“部分无锁的”,因为如果并发 void push(T value) {
auto stp = stack_pointer.load(memory_order_acquire);
stack[++stp] = value;
stack_pointer.store(stp, memory_order_release);
}
T pop() {
auto stp = stack_pointer.load(memory_order_acquire);
while(true) {
auto value = stack[stp];
if (stack_pointer.atomic_compare_exchange_weak(stp,stp-1,memory_order_release,memory_order_acquire)) {
return value;
}
}
}
发生,pop
实现必须自旋,这是一种锁。