具有原子

时间:2017-12-11 14:53:34

标签: c++ data-structures concurrency lock-free

我需要构建一个无锁的堆栈实现。我读了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也会遇到麻烦,对吗?

编辑:更新了问题!

5 个答案:

答案 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::nextatomic<>的{​​{1}}成员,您应该考虑要为该负载使用的内存顺序。但是对于纯栈而言,它不一定是原子的,因为链接列表节点在它们位于堆栈时永远不会被修改,只有才能用<{1}}推送指针。共享只读访问权限不是竞赛。

作为纯栈的用法也减少了删除问题:线程不能“窥视”头节点或遍历列表。他们只能在弹出节点后查看节点内的节点,node算法确保他们拥有该节点的独占所有权(并负责删除它)。

但是next本身需要从pop节点加载。如果另一个线程与我们竞争并将该pop()的内存返回给操作系统,我们可能会出错。所以我们有一个删除问题like RCU does,就像我在评论中提到的那样。

简单地将内存重用于其他内容对大多数 C ++实现不会有问题,但是:我们会读取head的垃圾值,但CAS会失败(因为在释放旧的head对象之前,head指针必须已经改变了。所以我们永远不会对我们加载的虚假值做任何事情。但它仍然是C ++ UB,因为我们的原子载荷与非原子商店竞争。但是编译器必须证明这个竞赛实际上发生之后才允许发出除正常asm以外的任何东西,并且所有主流CPU在asm中都没有任何问题。

但除非你能保证old_head->nexthead只是将内存放在免费列表中,即它们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.loadnullptr比较之间推送一个元素,我的算法不会弹出它。我不知道如何解决这个问题。

2)在push操作中,使用new创建元素。如果我在delete old_head;之前添加return true;,我的代码会崩溃。所以我知道这个算法有内存泄漏。我可以申请this解决方案吗?

答案 4 :(得分:0)

如果您希望多个线程同时执行 pushpop 操作,则无法实现无锁堆栈。

在这种情况下,修改堆栈指针和访问堆栈上的读取或写入数据应该以原子方式完成。如果不是,您有两种情况,对于 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 实现必须自旋,这是一种锁。