无锁堆栈 - 这是c ++ 11宽松原子的正确用法吗?可以证明吗?

时间:2014-07-18 18:04:40

标签: c++ multithreading c++11 atomic relaxed-atomics

我已经为一个非常简单的数据写了一个容器,需要跨线程同步。我想要最好的表现。我不想使用锁。

我想使用"轻松"原子能。部分是为了一点点额外的魅力,部分是为了真正理解它们。

我一直在努力解决这个问题,而且我在这个代码通过我抛出的所有测试的时候。那不完全"证明"虽然如此,所以我想知道是否有任何我缺失的东西,或者其他任何我可以测试的方法?

以下是我的前提:

  • 唯一重要的是正确地推送和弹出节点,并且堆栈永远不会失效。
  • 我相信内存中的操作顺序只在一个地方很重要:
    • 在compare_exchange操作之间。 这是有保证的,即使是轻松的原子。
  • " ABA"通过向指针添加标识号来解决问题。在32位系统上,这需要双字compare_exchange,而在64位系统上,未使用的16位指针用id号填充。
  • 因此:堆栈将始终处于有效状态。 (对吗?)

这就是我的想法。 "通常",我们推理我们正在阅读的代码的方式是查看它所写的顺序。记忆可以被读取或写入"乱序"但不能使程序的正确无效。

多线程环境中的变化。这就是记忆围栏的用途 - 这样我们仍然可以查看代码,并能够推断它是如何运作的。

因此,如果所有事情都可以在这里完全无序,那么我在放松原子能做什么呢?这有点太过分了吗?

我不这么认为,但这就是我在这里寻求帮助的原因。

compare_exchange操作本身可以保证彼此之间的连续恒定。

只有读取或写入原子的唯一时间是在compare_exchange之前获取头部的初始值。它被设置为变量初始化的一部分。据我所知,这项行动是否会带回一个“正确的”是无关紧要的。值。

当前代码:

struct node
{
    node *n_;
#if PROCESSOR_BITS == 64
    inline constexpr node() : n_{ nullptr }                 { }
    inline constexpr node(node* n) : n_{ n }                { }
    inline void tag(const stack_tag_t t)                    { reinterpret_cast<stack_tag_t*>(this)[3] = t; }
    inline stack_tag_t read_tag()                           { return reinterpret_cast<stack_tag_t*>(this)[3]; }
    inline void clear_pointer()                             { tag(0); }
#elif PROCESSOR_BITS == 32
    stack_tag_t t_;
    inline constexpr node() : n_{ nullptr }, t_{ 0 }        { }
    inline constexpr node(node* n) : n_{ n }, t_{ 0 }       { }
    inline void tag(const stack_tag_t t)                    { t_ = t; }
    inline stack_tag_t read_tag()                           { return t_; }
    inline void clear_pointer()                             { }
#endif
    inline void set(node* n, const stack_tag_t t)           { n_ = n; tag(t); }
};

using std::memory_order_relaxed;
class stack
{
public:
    constexpr stack() : head_{}{}
    void push(node* n)
    {
        node next{n}, head{head_.load(memory_order_relaxed)};
        do
        {
            n->n_ = head.n_;
            next.tag(head.read_tag() + 1);
        } while (!head_.compare_exchange_weak(head, next, memory_order_relaxed, memory_order_relaxed));
    }

    bool pop(node*& n)
    {
        node clean, next, head{head_.load(memory_order_relaxed)};
        do
        {
            clean.set(head.n_, 0);

            if (!clean.n_)
                return false;

            next.set(clean.n_->n_, head.read_tag() + 1);
        } while (!head_.compare_exchange_weak(head, next, memory_order_relaxed, memory_order_relaxed));

        n = clean.n_;
        return true;
    }
protected:
    std::atomic<node> head_;
};

与其他人相比,这个问题的不同之处是什么?放松的原子。他们对这个问题产生了很大的影响。

那么,您怎么看?我有什么遗失的吗?

3 个答案:

答案 0 :(得分:4)

push已损坏,因为您在node->_next失败后未更新compareAndSwap。当下一次node->setNext尝试成功时,最初与compareAndSwap一起存储的节点可能已被另一个线程从堆栈顶部弹出。因此,某些线程认为它已从堆栈中弹出一个节点,但该线程已将其放回 in 堆栈中。它应该是:

void push(Node* node) noexcept
{
    Node* n = _head.next();
    do {
        node->setNext(n);
    } while (!_head.compareAndSwap(n, node));
}

此外,由于nextsetNext使用memory_order_relaxed,因此无法保证此处_head_.next()返回最近推送的节点。可以从堆栈顶部泄漏节点。同样的问题显然也存在于pop中:_head.next()可能会返回先前但不再位于堆栈顶部的节点。如果返回的值为nullptr,则当堆栈实际上不为空时,您可能无法弹出。

如果两个线程试图同时从堆栈中弹出最后一个节点,则

pop也可能具有未定义的行为。他们都看到_head.next()的相同值,一个线程成功完成pop。另一个线程进入while循环 - 因为观察到的节点指针不是nullptr - 但compareAndSwap循环很快将其更新为nullptr,因为堆栈现在为空。在循环的下一次迭代中,该nullptr被取消以获得其_next指针并且随后会出现大量的欢闹。

pop也明显患有ABA。两个线程可以在堆栈顶部看到相同的节点。假设一个线程到达评估_next指针然后阻塞的点。另一个线程成功弹出节点,推送5个新节点,然后在另一个线程唤醒之前再次推送该原始节点。其他线程的compareAndSwap将成功 - 堆栈顶部节点相同 - 但将旧_next值存储到_head而不是新值memory_order_seq_cst。另一个线程推送的五个节点都被泄露了。这也是{{1}}的情况。

答案 1 :(得分:2)

对于实施弹出操作的困难,我认为memory_order_relaxed是不合适的。在推送节点之前,假设将向其写入一些值,当弹出节点时将读取该值。您需要一些同步机制来确保在读取值之前实际写入了值。 memory_order_relaxed未提供同步... memory_order_acquire / memory_order_release会。

答案 2 :(得分:2)

此代码已完全破解。

这看起来有效的唯一原因是当前的编译器对原子操作的重新排序并不十分积极,x86处理器有很强的保证。

第一个问题是,如果没有同步,则无法保证此数据结构的客户端甚至会看到要初始化的节点对象的字段。下一个问题是,如果没有同步,推送操作可以读取头部标签的任意旧值。

我们开发了一个工具CDSChecker,它模拟了内存模型允许的大多数行为。它是开源和免费的。在您的数据结构上运行它以查看一些有趣的执行。

在这一点上证明使用轻松原子的代码是一个很大的挑战。大多数证明方法都会被破坏,因为它们通常具有归纳性,并且您没有订单可以导入。因此,您将凭空读取问题......