PAYATHON 2026

如何修复基于原子操作的 MPSC 队列数据竞争?

数据小陈

结论

当前实现无法保证多个生产者安全地将节点加入队列,也无法保证消费者删除节点时,没有生产者仍在访问该节点。单独把某个 memory_order_relaxed 改成 memory_order_acquire 无法解决问题,节点链接顺序和生命周期都需要重新设计。

日志将冲突位置指向 push 中对节点链接的写入,并显示地址属于堆上分配的节点。结合代码,重点应检查 m_tail 的发布和读取:生产者用 memory_order_release 更新 m_tail,其他生产者却用 memory_order_relaxed 读取它,之后再访问该节点的 next。这种读取不会与发布线程建立同步关系,因此不能保证节点初始化先于后续访问。日志不足以还原每次运行中的具体交错,但代码本身还存在独立的并发逻辑缺陷。

当前生产者逻辑的问题

生产者先读取尾节点,再执行:

auto tail_ptr = m_tail.load(std::memory_order_relaxed);
if (tail_ptr != nullptr) {
    tail_ptr->next.store(node, std::memory_order_release);
}

多个生产者可能同时读到同一个尾节点,并向它的 next 存储值。next 是原子变量,因此不会对这个原子对象产生非原子写冲突;但后一次存储可能覆盖前一次链接,使节点从队列中丢失。之后尾指针的 CAS 操作即使失败,也无法撤销已经写入的链接。

生产者也可能先把新节点写入旧尾节点的 next,再竞争更新 m_tail。因此,链表链接和尾指针更新并没有构成一个协调好的入队步骤。只调整内存序,无法修正这个操作顺序。

消费者删除节点的风险

消费者推进 m_head 后会立即删除旧头节点。此时生产者可能已经将该节点读作尾节点,正准备访问它的 next。如果消费者释放了节点,生产者就可能访问已释放的对象。

常见的无锁 MPSC 队列会使用哨兵节点,并明确设计节点回收策略。生产者通常通过原子交换取得前驱节点,再连接新节点。消费者则需要处理生产者已经更新尾指针、但还没有完成前驱节点链接的短暂状态。采用这种入队方式时,消费者与节点回收之间仍然需要协调。

先采用互斥锁的安全方案

如果当前目标是实现正确的 MPSC 队列,可以先用互斥锁保护队列。标准库的 std::queue 提供了所需的 FIFO 容器:

#include <mutex>
#include <optional>
#include <queue>
#include <utility>

template <typename T>
class mpsc_queue {
    std::mutex mutex_;
    std::queue<T> queue_;

public:
    template <typename... Args>
    void emplace(Args&&... args) {
        std::lock_guard<std::mutex> lock(mutex_);
        queue_.emplace(std::forward<Args>(args)...);
    }

    void push(const T& value) {
        std::lock_guard<std::mutex> lock(mutex_);
        queue_.push(value);
    }

    void push(T&& value) {
        std::lock_guard<std::mutex> lock(mutex_);
        queue_.push(std::move(value));
    }

    std::optional<T> pop() {
        std::lock_guard<std::mutex> lock(mutex_);
        if (queue_.empty()) {
            return std::nullopt;
        }

        T value = std::move(queue_.front());
        queue_.pop();
        return value;
    }
};

这个版本允许多个线程调用 push 或 emplace,并由一个线程调用 pop。锁保护容器访问和元素生命周期,因此这个实现不是 lock-free。若吞吐量之后成为瓶颈,可以先测量,再考虑经过验证的并发队列实现。

如果必须使用无锁实现

应根据完整分析过的 MPSC 算法重新实现,避免在当前代码上零散调整内存序。设计至少要明确:

  • 生产者如何唯一取得入队位置,并按正确顺序发布节点链接。
  • 消费者如何处理尾指针已更新、链表链接尚未完成的情况。
  • 消费者何时可以安全释放旧节点,以及如何避免生产者持有悬空指针。
  • 空队列与单节点队列如何转换,哨兵节点如何参与这些转换。

std::atomic 指针只保证指针变量的访问是原子的,无法自动防止多个生产者覆盖链接,也无法防止对象被提前释放。memory_order_release 和 memory_order_acquire 可以建立发布与获取之间的同步关系,但不能代替队列算法本身的正确性。

额外检查

示例中的 i 在创建线程前由主线程顺序递增,因此这段代码里的 i 本身不存在并发读写。还应确认队列对象在所有生产者和消费者线程结束前始终存活,且析构函数不会与仍在运行的线程并发执行。修复后可以继续用 ThreadSanitizer 检查实现;一次检查通过,不能证明无锁算法在所有交错下都正确。

备注:内容仅供参考。