i007.cc

i007.cc

优先队列-降维打击

05.价值资料

发现无锁队列在某些机器上运行的时候并不能保证顺序,可能是什么问题?

这是一个非常典型的真实 Bug,根本原因几乎可以确定是 Memory Order 设置错误,配合不同 CPU 架构的内存模型差异才暴露出来。


先理解:为什么”某些机器”才出问题

不同 CPU 架构对内存操作的允许重排程度完全不同:

x86/x64  → TSO(Total Store Order)
  允许的重排:只有 StoreLoad(写后读可以被重排)
  不允许:StoreStore、LoadLoad、LoadStore

ARM / ARM64 → 弱内存模型(Weakly Ordered)
  几乎所有重排都允许:
  StoreStore(写-写可以被重排)← 这是锁-free queue 最常踩的坑
  LoadLoad
  LoadStore
  StoreLoad

PowerPC → 比 ARM 还弱

 

结论:用 relaxed 或者错误的 memory_order 写出来的无锁队列,在 x86 上可能恰好正确(x86 硬件帮你补了很多 barrier),放到 ARM 服务器或手机 SoC 上就开始出问题。这也是为什么很多 Bug 在开发机(x86)上测不出来,上线(ARM)才暴露。


最常见的具体 Bug:SPSC Queue 里的 StoreStore 重排

回到之前写的 SPSC Queue,看这个版本:

cpp
// 错误版本:tail 用了 relaxed
bool push(const T& item) {
    size_t tail = tail_.load(std::memory_order_relaxed);
    size_t next = (tail + 1) & MASK;
    if (next == head_.load(std::memory_order_acquire))
        return false;

    buffer_[tail] = item;                          // ① 写数据

    tail_.store(next, std::memory_order_relaxed);  // ② 更新 tail(BUG:relaxed)
    return true;
}

bool pop(T& item) {
    size_t head = head_.load(std::memory_order_relaxed);
    if (head == tail_.load(std::memory_order_relaxed))  // ③ 读 tail(BUG:relaxed)
        return false;

    item = buffer_[head];                          // ④ 读数据

    head_.store((head + 1) & MASK, std::memory_order_release);
    return true;
}

 

在 ARM 上的问题时间线:

Producer(核心0)         Consumer(核心1)

① buffer_[tail] = item   
② tail_.store(next)      
                          ③ 看到 tail 更新了(tail != head)
                          ④ 读 buffer_[head]
                             ← 读到的是旧数据或垃圾值!

原因:ARM 允许 StoreStore 重排
CPU 实际执行顺序可能是:
  ② tail_.store(next)    ← 先把 tail 写到其他核可见
  ① buffer_[tail] = item ← 数据写入还没传播过去

 

Consumer 看到 tail 已经更新,以为数据准备好了,实际上数据写入还没有传播到自己的 cache。

正确写法

cpp
Producer(核心0)         Consumer(核心1)

① buffer_[tail] = item   
② tail_.store(next)      
                          ③ 看到 tail 更新了(tail != head)
                          ④ 读 buffer_[head]
                             ← 读到的是旧数据或垃圾值!

原因:ARM 允许 StoreStore 重排
CPU 实际执行顺序可能是:
  ② tail_.store(next)    ← 先把 tail 写到其他核可见
  ① buffer_[tail] = item ← 数据写入还没传播过去

 

cpp
// acquire 语义:保证此行之后的所有读取(包括 buffer_ 的读)
// 能看到配对 release 之前的所有写入
if (head == tail_.load(std::memory_order_acquire))  // ✅
    return false;

item = buffer_[head];  // 现在保证能读到正确数据

 


Acquire-Release 的本质:建立 happens-before 关系

Producer:
  buffer_[tail] = item;              ─┐
  tail_.store(next, release);         ┘ release:这两行有先后顺序,且对外可见

                    ↕  synchronizes-with(跨线程的 happens-before)

Consumer:
  tail_.load(acquire);               ─┐
  item = buffer_[head];              ─┘ acquire:这两行有先后顺序,且能看到上面的写

 

release + acquire 配对创建了一个跨线程的同步点:任何在 release store 之前的写入,对执行了对应 acquire load 的线程都是可见的。


MS Queue 里更隐蔽的 Memory Order 错误

多生产者多消费者的 Michael-Scott Queue 里,CAS 的 memory order 同样容易写错:

cpp
// 错误:CAS 成功时用 relaxed
if (tail->next.compare_exchange_weak(
        next, node,
        std::memory_order_relaxed,   // ← 成功时应该是 release
        std::memory_order_relaxed)) {
    // ...
}

// 正确:
if (tail->next.compare_exchange_weak(
        next, node,
        std::memory_order_release,   // 成功时:发布新节点,需要 release
        std::memory_order_relaxed)) { // 失败时:只是重试,relaxed 够了
    // ...
}

// 出队时读取节点数据:
Node* next = head->next.load(std::memory_order_acquire); // 需要 acquire
val = next->data;  // 能看到入队时写入的 data

 


排查步骤

第一步:检查所有 atomic 操作的 memory_order

bash
# 快速搜索代码里所有用了 relaxed 的地方
grep -n "memory_order_relaxed" your_queue.cpp

 

对每一处 relaxed 问自己:这个操作是否需要和其他线程的某个操作同步?如果是,就需要 acquire/release

第二步:画出 happens-before 链

对每一对"写数据 → 通知 → 读数据"的模式,确认:

写数据            store(release)         load(acquire)       读数据
─────────── ≺ ──────────────── sync ──────────────── ≺ ──────────
必须在 release 之前        这里建立同步        必须在 acquire 之后

 

如果这条链断了(任何一环用了 relaxed),就是 Bug。

第三步:在 ARM 上加压测试

cpp
// 用 TSan(Thread Sanitizer)可以检测 data race
// 编译时加 -fsanitize=thread

// 或者在 ARM 机器上用 stress 工具加大并发压力:
for (int i = 0; i < 1000000; i++) {
    producer_thread();
    consumer_thread();
    assert(consumed_value == produced_value);  // 验证顺序
}

 

第四步:用 C++ 标准保证,不依赖平台行为

cpp
// 危险:依赖 x86 的强内存模型,换平台就出 Bug
std::atomic<int> flag{0};
flag.store(1, std::memory_order_relaxed);  // 在 x86 上恰好不会被重排

// 安全:用标准规定的 memory order,所有平台行为一致
flag.store(1, std::memory_order_release);

 


一个检查 memory order 的思维框架

面对任意一个 atomic 操作,问三个问题:

1. 这个 store 之前,有没有普通内存写入需要对其他线程可见?
   → 如果有,用 release

2. 这个 load 之后,有没有普通内存读取依赖于"看到最新值"?
   → 如果有,用 acquire

3. 这个操作是否需要全局顺序一致(比如实现 mutex)?
   → 用 seq_cst(代价最高,非必要不用)

4. 以上都不是(只是单纯的计数器、统计)?
   → 才能用 relaxed

 


面试里如果被问到”你遇到过锁-free 的 Bug 吗”,这个场景是非常好的案例:在 x86 开发机上测试通过,部署到 ARM 的游戏服务器上出现数据错乱,最终定位到 SPSC Queue 的 tail_.store 用了 relaxed 而不是 release,在 ARM 的 StoreStore 重排下 Consumer 读到了写入前的数据。说清楚根因、排查过程、修复方案,是很有说服力的 Senior 级回答。

发表回复