|
|
这几年在项目里反复踩过并修过 C++ 原子与内存序的坑,我越来越确信:能只用顺序一致就别乱用,更别指望“编译器会帮我优化”。atomic 的内存模型看似学术,其实是工程稳定性的底座。下面从几个常见误区与实践切面聊聊。
很多人初识 std::atomic,会默认 memory_order_seq_cst,一句“最强保证”就带过。但一旦追求性能,读写上来两把 memory_order_relaxed,问题就埋下了。relaxed 的本意是“只保证单个原子变量的原子性,不管可见性与排序”,这意味着你在一个线程写入 data,再 relaxed 写一个 flag,另一个线程 relaxed 读到 flag 为 true,不保证读到的是最新 data。这不是“概率问题”,而是模型允许的行为。要让“先写数据,再发信号”成立,需要写端 store-release,读端 load-acquire,形成对称的同步边,才能把前后的普通读写“拎”过来。C++ 标准对这套关系定义得很细,建议认真读 N4861 相关章节,或者 cppreference 的内存序条目,链接随手贴一个:https://en.cppreference.com/w/cpp/atomic/memory_order
再来说常被滥用的 memory_order_consume。标准里的 consume 多年处于“语义悬而未决”的尴尬状态,主流编译器基本把它按 acquire 实现或直接退化,想靠它捞到性能的人十有八九是在自欺。团队里统一约定:不用 consume。需要依赖传播语义的地方,使用清晰的 acquire/release。
至于 memory_order_acq_rel,常见于 read-modify-write(如 fetch_add、compare_exchange)。注意 compare_exchange 的两个 memory order 参数:成功与失败不同序。失败时你至少需要一个 acquire(读取到了新值),否则有些模式下会让后续读写越过观察点。另外小坑:compare_exchange_weak 允许伪失败,循环要写对;而 compare_exchange_strong 省去自旋次数,但在弱内存架构上也可能更慢。我的经验是:非性能敏感先用 strong,确认热点后再基于实测切 weak。
很多人问:能不能用 relaxed 实现计数器?可以,但要看用途。如果只是做统计,最终聚合不依赖精确时序,relaxed 是合适的;如果计数变化同时隐含“有新任务入队”这类语义,就必须把“可见性”纳入设计,通常队列 push 用 release,pop 用 acquire,更常见的做法是让结构化的同步原语(条件变量、mpsc 队列)承载这份复杂度。顺手贴个无锁队列参考实现讨论:https://www.1024cores.net
顺序一致 seq_cst 是否“过度保守”?在 x86 上,很多场景下它几乎不多花成本(x86 TSO 自带强序),可读性也最好。真正需要细抠到 acq_rel/relaxed 的,多发生在 ARM/POWER 这类弱序平台或极致性能路径。我的做法是:默认 seq_cst,发现瓶颈后基于 perf/pmu 证据再降级,并写下理由与契约。别忘了跨平台测试:有些竞态只在 ARM 线上放大复现。
还有两个工程化建议。第一,给每个原子变量写清“同步协议”,比如“flag 由 A 线程 store-release,B 线程 load-acquire 读取后再读 payload”。把这句话写进代码注释与文档,减少未来维护者游走在“看似能跑”的错觉里。第二,用工具和模型思维校验:TSAN 在很多数据竞争上很有用,但别把它当真理,原子与内存序相关的错误它不一定抓到;可以配合小型模型程序做压力测试,甚至把关键路径用 litmus test 在不同 CPU 上跑验证。ARM 提供的 herd7/litmus 资料很值得一看:https://github.com/herd/herdtools7
最后强调一个理念:内存序是沟通手段,不是“黑魔法”。当你发现自己开始在注释里解释“这个 relaxed 其实安全,因为...”时,八成就该退回 acquire/release 或 seq_cst,用更直接的同步原语表达意图。能被新同事一眼看懂、能在弱序平台稳定跑、能被工具部分验证的代码,才是好并发代码。 |
|