一次持续三天才出现的丢包故障——深入解析 DPDK Memory Ordering、rte_ring 与 CPU Memory Barrier (下)
接上文一次持续三天才出现的丢包故障——深入解析 DPDK Memory Ordering、rte_ring 与 CPU Memory Barrier 上-CSDN博客八、DPDK Ring 真正依赖的不是 CAS而是 Memory Ordering很多开发者第一次阅读lib/ring源码时都会把注意力集中在 CASCompare-And-Swap上。例如head atomic_compare_exchange(...);于是得出结论CAS 保证了线程安全。事实上这是一个非常普遍的误区。CAS 能保证的是某一个共享变量的原子更新。例如Producer A tail 100 ↓ CAS ↓ tail 101Producer Btail 100 ↓ CAS失败 ↓ 重新获取TailCAS 保证不会有两个线程同时把tail 100 ↓ 101更新成功。但是CAS并不能保证数据已经准备好了。举一个简单例子obj-session session; obj-pkt pkt; atomic_store(ring-tail, next);如果没有任何 Memory Barrier。CPU 完全可能Tail 已经更新 ↓ Object 还没有完全写入Consumer看到Tail已经变化。立即开始读取Object。结果拿到半初始化对象。这就是为什么很多 Crash都是随机。而且几年才出现一次。九、Release 与 Acquire无锁编程真正的核心现代 DPDK 已经大量采用Acquire/Release语义来保证内存可见性。Producer 的正确执行顺序应当是初始化对象 ↓ Release Store ↓ 更新TailRelease 的含义是在 Release 之前对对象的所有写操作都必须先对其它 CPU 可见。Consumer读取Tail使用Acquire。流程Acquire Load ↓ 读取ObjectAcquire保证后面的所有读取。一定发生在Acquire之后。也就是说ProducerObject ↓ Release ↓ TailConsumerAcquire Tail ↓ Object两边共同形成完整的Memory Ordering。这也是现代Lock-Free算法最重要的思想。十、为什么tail一定要最后更新假设Ring有1024 个槽位。Producer准备放入第257 个对象。如果执行顺序变成Tail ↓ ObjectConsumer看到Tail 257自然认为第257已经准备好了。于是立即读取对象。但是此时Producer实际上还没有完成对象初始化。Consumer读到可能是NULL 旧对象 垃圾数据所以对于所有Lock-Free Queue。都有同一条原则对象必须先完成初始化然后才能发布Publish给其它线程。而Tail就是整个对象对外发布的那个动作。这也是为什么源码里面所有Barrier都围绕Head/Tail更新。而不是对象初始化。十一、DPDK 为什么要区分rte_smp_wmb()、rte_smp_rmb()和rte_smp_mb()很多初学者看到三个 Barrier。都会觉得都是 Memory Barrier其实它们作用完全不同。rte_smp_wmb()Write Memory Barrier。保证之前所有Store必须完成之后才能继续后面的Store。适用于Producer。例如obj-pkt pkt; obj-len len; rte_smp_wmb(); ring-tail next;目的保证Tail最后才可见。rte_smp_rmb()Read Memory Barrier。Consumer读取Tail以后。必须保证后面的Object读取。不会跑到Tail之前。例如tail ring-tail; rte_smp_rmb(); obj ring[idx];否则CPU可能提前读取Object。然后Tail还是旧值。造成逻辑混乱。rte_smp_mb()Full Barrier。读写全部禁止重排序。性能也是最昂贵。通常只在确实需要完全同步时使用。DPDK绝大多数场景都会优先选择Acquire/Release。因为成本更低。十二、为什么 x86 上很多 Bug 永远复现不了很多开发者都会说我的代码在 Intel 上跑了三年没有任何问题。然后部署ARM。第一天Crash。第二天继续Crash。原因就在于x86采用TSO。它天然保证很多Store不会乱序。所以很多隐藏 Bug被掩盖。而ARM更加宽松。很多StoreLoad都可以重新排序。于是所有隐藏问题全部暴露。这也是为什么DPDK源码所有Lock-Free算法都不会依赖x86行为。而是统一使用Memory Barrier。十三、如何定位 Memory Ordering 问题这种 Bug最大的特点就是几乎不能依靠日志定位。因为日志打印的时候。Memory Ordering已经改变。真正有效的方法包括① Thread SanitizerTSAN对于非 DPDK 部分可借助 TSAN 检测普通数据竞争。但需要注意DPDK 大量无锁代码和轮询模型并不一定适合直接开启 TSAN因此更多用于业务层共享数据的检查。② perf 火焰图确认是不是大量时间花在Spin或者Barrier。虽然无法直接证明 Memory Ordering 错误但可以辅助分析同步热点。③ Code ReviewMemory Ordering最大的特点就是源码一眼看不出来。必须逐句分析Object 什么时候 初始化 ↓ 什么时候 Publish ↓ 什么时候 Consumer 开始 读取真正定位90%靠源码。不是日志。十四、从这个故障中我们真正应该学到什么回到文章开头。为什么系统连续运行三天才Crash答案其实非常简单。因为必须满足三个条件同时发生。第一Producer发生Store Buffer延迟。第二Consumer刚好提前读取。第三竞争窗口刚好成立。这种概率非常低。但是每天处理几千亿Packet。最终一定会发生。这也是高性能系统最可怕的一类Bug。它不是必现。而是迟早出现。十五、总结很多人认为Lock-Free编程最重要的是CAS。实际上CAS只是解决共享变量更新。真正保证正确性的是Memory Ordering。DPDK 的rte_ring、rte_mempool、部分rte_hash以及大量无锁数据结构都是建立在正确的内存可见性模型之上的。一个对象只有在数据全部初始化完成通过 Release 语义发布消费者通过 Acquire 语义获取之后才能保证其它 CPU 看到的是一个完整、一致的对象。因此Memory Barrier 并不是为了“让代码变慢”而是为了告诉 CPU哪些顺序绝对不能改变。对于 DPDK 开发者而言真正需要建立的思维方式不是“加不加锁”而是理解对象什么时候构造完成什么时候发布什么时候允许其它核心观察到当你开始用这种视角阅读rte_ring、rte_mempool、RCU、连接跟踪表乃至整个数据平面的无锁设计时就会发现它们背后的设计思想高度一致——先构造再发布先获取再访问依靠 Memory Ordering而不是侥幸依赖某一种 CPU 架构的默认行为。