ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

std::atomic<T>的四大铁律:从CPU原子指令到无锁编程的陷阱

std::atomic<T>的四大铁律:从CPU原子指令到无锁编程的陷阱 写这篇文章的起因是我最近在review一个无锁队列的实现时被同事问了一个问题std::atomicstd::string这种代码编译器为什么不给过当时我下意识地回了一句“因为原子变量要做成lock-freestring做不到”。但真往深了想std::atomicT的底层约束远不止“做不到lock-free”这么简单它背后藏着的是CPU原子指令的能力边界。我在多线程编程上踩过的坑不算少从volatile救不了并发到无锁队列在自己机器上跑得好好的、一上ARM就废到最后发现整条链路都卡在“CPU到底支持哪些原子指令”上。这篇文章就把我总结出来的std::atomicT的4大铁律讲清楚为什么有这些限制、这些限制从哪来、实际编码时该怎么绕。适合正在学C多线程、准备写无锁数据结构、或者被面试官问过“atomic为什么不能放自定义类型”的读者。1. 铁律一lock-free不是默认属性CPU原子指令只覆盖特定长度1.1 标准里唯一保证lock-free的只有std::atomic_flag很多新手包括当年的我会把std::atomicT和“无锁”画等号这个误解非常危险。C标准里真正无条件保证lock-free的只有std::atomic_flag这一个类型它专门用来实现自旋锁之类的底层同步原语只有test_and_set、clear两个操作连load和store都不提供。至于std::atomicT标准用的是枚举值memory_order来表示操作性质而“无锁”这件事标准的态度是对每个类型平台可以选择提供lock-free实现也可以选择用内部锁兜底还可以做成一个类型在某些CPU上是lock-free、在另一些CPU上不是。标准只承诺在某个特定头文件宏ATOMIC_*_LOCK_FREE里告诉你是哪种情况。1.2 is_always_lock_free与is_lock_free编译期和运行期的两个视角C17引入了is_always_lock_free这个编译期常量它是std::atomicT的一个静态成员用来表示“这个类型在当前平台上是不是总是lock-free”。而成员函数is_lock_free()是运行期查询这两个的区别在x86平台上有一个非常经典的例子16字节类型。当你在x86-64上用std::atomic__int128或者std::atomic一个16字节的结构体时is_lock_free()返回什么取决于运行时的CPU是否支持CMPXCHG16B指令。Intel早期的x86-64处理器不支持这条指令但后来基本都支持了。所以同一份二进制在支持CMPXCHG16B的CPU上is_lock_free()返回true在不支持的CPU上返回false。而is_always_lock_free这个编译期常量在编译时就要对所有目标CPU给出统一答案所以它极有可能是false。实操的时候我的建议是如果需要依赖某类型一定lock-free就用static_assert(std::atomicT::is_always_lock_free)把它钉死在编译期。如果只在运行期判断那就要写分支处理“不是lock-free”的降级路径或者干脆直接拒绝使用。#include atomic #include iostream struct alignas(16) MyStruct { unsigned long long a; unsigned long long b; }; int main() { std::atomicMyStruct obj; std::atomicuint64_t i64; std::atomic_flag flag; std::cout std::boolalpha; std::cout atomic_flag is_always_lock_free: decltype(flag)::is_always_lock_free \n; std::cout atomicuint64_t is_always_lock_free: decltype(i64)::is_always_lock_free \n; std::cout atomicMyStruct is_always_lock_free: decltype(obj)::is_always_lock_free \n; std::cout atomicMyStruct is_lock_free(): obj.is_lock_free() \n; }1.3 为什么CPU原子指令有长度上限——从LOCK前缀到CMPXCHG16B要理解为什么不是所有类型都能lock-free得先看清楚CPU原子指令长什么样。以x86为例所谓原子指令本质是“在指令执行期间锁住内存总线或缓存行”让其他核心无法插入中间状态。最典型的几条指令作用典型C操作LOCK XADD原子加并返回旧值fetch_addLOCK XCHG原子交换exchangeLOCK CMPXCHG原子比较并交换compare_exchange_*LOCK INC/DEC原子自增/自减fetch_add(1)这几条指令的操作数长度是有硬件上限的。x86-64下标量整数原子指令通常能覆盖1/2/4/8字节16字节需要CMPXCHG16B配合LOCK前缀再大的类型x86就没有原生指令可用了。ARM那边走的则是LDREX/STREX这套LL/SCLoad-Linked/Store-Conditional机制同样只支持有限长度的数据访问。这就是std::atomicT不能随心所欲放任何类型的最底层原因如果T的大小超出了CPU原子指令能覆盖的范围编译器唯一的选择就是在库内部塞一把锁把原子操作的语义用临界区模拟出来。1.4 别在bool和自定义类型上踩lock-free的坑有一种类型特别容易让人放松警惕超过一个机器字长的小结构体。比如struct Point { int x; int y; };它只有8字节。在很多64位平台上std::atomicPoint确实能通过CMPXCHG16B之类的指令做到lock-free但注意是“在64位平台上”。如果这套代码被编译到32位平台8字节对象的原子操作可能就会退化为锁实现。你写的时候觉得“这struct挺小的肯定没问题”实际上完全取决于目标架构。所以我的经验是除非你明确知道目标平台和CPU指令集并且用is_always_lock_free验证过否则不要把lock-free当成std::atomicT的默认属性。它是CPU给的礼物不是C标准给的承诺。2. 铁律二trivially copyable是门槛复制语义决定CPU指令能级2.1 标准要求与编译报错实例std::atomicT的应用条件标准里写得很清楚T必须是trivially copyable。违反了这个条件编译期直接报错。下面这段代码就是典型的错误示范#include atomic #include string struct BadType { std::string name; int value; }; int main() { std::atomicBadType atomicBad; // 编译错误 return 0; }报错信息大致是“static assertion failed: std::atomic requires a trivially copyable type”。很多人第一次看到这个错误是懵的我这么小的结构体怎么就不trivially copyable了因为里面有std::string而std::string内部有堆内存指针和引用计数取决于实现不是trivially copyable的。2.2 CPU原子指令的本质是内存总线上的单一读改写为什么标准要做这个限制还是得回到CPU原子指令的机制。LOCK XCHG这类指令对硬件的意义是一个指令周期内对某块内存完成“读出旧值、修改、写回新值”全程不允许其他核心看到中间状态。硬件层面没有所谓“构造函数”“拷贝赋值”的概念它就是物理地读写内存里的字节。如果T有自己的拷贝构造逻辑比如引用计数shared_ptr那种——拷贝时要把旧对象的计数值减一、新对象的计数值加一——那么CPU原子指令对这个对象直接做内存覆盖写这些逻辑根本没机会执行对象的内部不变量瞬间被破坏。而且这种破坏是无声无息的调试器都很难抓到现场。2.3 为什么有用户定义拷贝构造不行——从实现角度剖析可以理解为std::atomicT希望T能退化成一堆裸字节。对trivially copyable的类型memcpy和memmove直接拷贝字节序列就能保持语义原始对象和目标对象“值相等”。而一旦用户定义了拷贝构造类型就携带了“自定义行为”不再是纯字节序列。std::atomicT的底层实现如果要操作用户自定义的拷贝逻辑要么在每个原子操作里调用拷贝构造那就必然引入锁不可能是lock-free要么干脆不让编译——C标准的抉择是后者拒绝编译。提示这里的“trivially copyable”可以在编译期用std::is_trivially_copyable_vT检查。编码时把这个检查写进static_assert报错信息比编译器默认的静态断言更友好static_assert(std::is_trivially_copyable_vT, MyAtomicWrapper requires trivially copyable T);还有一点经常被忽略trivially copyable只保证复制是字节级拷贝不保证类型没有padding填充位。这意味着std::atomicT内部比较两个值是否相等时可能因为padding字节的随机内容而出现“值看起来没变、但compare_exchange失败”的问题。C20的std::atomic_ref对这个场景有专门讨论但对std::atomicT来说这条很难查的bug我建议你直接用“别让结构体带padding”来规避比如给字段排好序或显式补齐大小。3. 铁律三内存序的代价藏在CPU指令里不只是编译器屏障3.1 从x86强内存序到ARM弱内存序理解std::atomicT的内存序参数之前得先建立一个认知C内存模型是抽象层CPU指令集是物理层两者之间隔着编译器优化和硬件重排。x86是TSOTotal Store Order强内存序架构普通mov指令就有比较好的顺序保证读操作天然带acquire语义、写操作天然带release语义。ARM则完全不同是弱内存序架构普通ldr/str之间几乎没有任何顺序保证CPU和编译器都可能自由重排。这带来了一个非常反直觉的事实在x86上你用memory_order_relaxed和用memory_order_seq_cst最终生成的CPU指令可能一模一样都是普通的mov。但是在ARM上两者生成的机器码天差地别——seq_cst可能要插入昂贵的dmb ish全屏障指令。3.2 memory_order各等级在两大架构上的实际代价我整理了一张实操对照表这张表我贴在工位旁边很久了memory_orderx86-64实际指令ARM64实际指令典型延迟感受relaxed普通mov普通ldr/str几乎零额外开销acquire普通mov编译器限制LDAR略高release普通mov编译器限制STLR略高seq_cststore可能变xchg或movmfence需要dmb ish可能高一个数量级注意x86-64上seq_cst的store并非完全无代价。由于TSO模型下store不会立刻被其他核心看到而C要求的seq_cst全局顺序又不允许store被延迟暴露所以编译器有时会把seq_cst的store替换成XCHG指令或者store之后加一条MFENCE。实测下来在高频store场景里这个差距是能明显感知的。3.3 跨平台性能陷阱seq_cst默认值在弱内存序架构上的代价C缺省的内存序是memory_order_seq_cst而它恰好是最贵的。如果你在ARM上跑一个高频读写的计数器fetch_add(1, std::memory_order_relaxed)和fetch_add(1, std::memory_order_seq_cst)的性能差距可能超过一个数量级。我有一次在ARM开发板上压测无锁队列随手写了默认内存序结果吞吐比预期慢了三倍多把内存序改成acquire/release配对之后立刻恢复正常。问题不在于默认值错而在于默认值是为“绝对正确”设计的不是为“性能最优”设计的。那怎么选内存序我的判断依据很简单先问自己这个原子操作到底需要什么顺序保证只是统计一个次数不关心其他变量顺序用memory_order_relaxed。需要“读到别人写的数据”也就是要建立happens-before关系用acquire读和release写配对。需要“所有线程看到的操作顺序完全一致”比如实现无锁队列的head/tail指针管理才用memory_order_seq_cst。3.4 一个实测内存序选择的小例子我写过一个小工具统计不同内存序下fetch_add的耗时在x86上跑差不多在ARM上差距非常直观。代码逻辑很简单#include atomic #include chrono #include iostream constexpr int N 10000000; template memory_order order void bench(const char* name) { std::atomicuint64_t counter{0}; auto start std::chrono::steady_clock::now(); for (int i 0; i N; i) { counter.fetch_add(1, order); } auto end std::chrono::steady_clock::now(); double ms std::chrono::durationdouble, std::milli(end - start).count(); std::cout name : ms ms\n; } int main() { benchmemory_order_relaxed(relaxed); benchmemory_order_seq_cst(seq_cst); return 0; }在x86上跑两个结果几乎一样在ARM上跑差距肉眼可见。用这个工具跟团队讲解“内存序不是玄学是实打实的CPU指令代价”比贴标准文档好使多了。4. 铁律四atomic的大小和对齐不保证与T一致CAS不是万能4.1 size与alignof被向上取整——padding的真相std::atomicT内部不是简单包一个T完事。为了满足原子操作的硬件对齐要求编译器经常会把std::atomicT的大小和对齐向上取整。最典型的例子是一个只有3个字节的小结构体struct ThreeByte { char a; char b; char c; };sizeof(ThreeByte)是3但sizeof(std::atomicThreeByte)很可能变成4、8甚至更大。因为CPU原子指令通常要求操作数按自身大小对齐3字节对齐在很多平台上没法直接对应到某条原子指令编译器只能补齐或者干脆用锁。更隐蔽的问题是缓存行伪共享false sharing。当你用std::atomicT作为队列中的槽位时如果两个原子变量恰好落在同一个缓存行里两个核心各自修改它们会导致缓存行在核心之间反复横跳性能断崖式下跌。这时候不管你内存序选得多好硬件层面已经打成一片了。解决办法也简单把高频写入的原子变量按缓存行大小通常64字节对齐分隔或者用alignas(64)把它们分散到不同缓存行。4.2 CAS循环的代价compare_exchange_weak的LL/SC与ABAcompare_exchange是std::atomicT提供的最强操作但也是性能最容易被高估的操作。先看compare_exchange_weak和compare_exchange_strong的区别在x86CAS指令上两者差距不大在ARMLL/SC机制上weak版本允许“伪失败”——也就是没有任何其他线程修改硬件也可能因为中断、异常等原因导致SC失败。所以weak版本必须放在循环里配合重试而这么做正是LL/SC语义的要求。如果你在ARM上用strong版本实现一个需要反复CAS的循环性能会明显劣化因为它内部会自动帮你重试而每次重试都包含完整的内存屏障开销。拿无锁栈做例子一个典型的CAS循环长这样template typename T class LockFreeStack { struct Node { T value; Node* next; }; std::atomicNode* head; public: void push(T val) { Node* node new Node{std::move(val), nullptr}; Node* expected head.load(std::memory_order_relaxed); do { node-next expected; } while (!head.compare_exchange_weak(expected, node, std::memory_order_release, std::memory_order_relaxed)); } };这个循环在低竞争下没问题一旦竞争激烈失败的线程会反复循环重试CPU占用率飙升。更痛苦的是ABA问题线程A读到头指针是X线程B弹出X、释放X、又压入一个地址恰好相同的X线程A的CAS成功——但此时X对应的节点内容已经变了栈结构被破坏。这就是所谓的ABA问题无锁数据结构里必须用带标记的指针或者hazard pointer之类机制解决而std::atomicT本身不会替你处理。4.3 什么时候别用atomic而要用锁——其实锁也未必差很多人一听说“无锁”就默认它比互斥锁快。这个直觉在低竞争场景是对的在竞争激烈时则往往相反。CAS循环在竞争激烈时的行为类似于自旋锁每个失败者都在拼命重试缓存行在多个核心之间反复失效总吞吐可能远低于一个简单的std::mutex。std::mutex在无竞争时的开销大概就是几十纳秒级别futex快速路径已经非常轻量。我的经验法则是先写简单正确的锁版本压测之后用profiler确认瓶颈确实在锁竞争上再考虑换无锁。除非是高频交易、游戏引擎核心循环这种对确定性要求极高的场景否则无锁的收益很容易被复杂度吃掉。4.4 这四条铁律在实际工程中的应对策略总结下来我组内现在写原子相关代码会强制走这么一套检查流程先确认这个类型的大小1/2/4/8字节优先16字节谨慎超过16字节直接放弃lock-free。用static_assert(std::is_trivially_copyable_vT)和static_assert(std::atomicT::is_always_lock_free)把不满足条件的组合拦在编译期。明确每个原子操作需要的内存序能不seq_cst就不seq_cst能relaxed就relaxed。结构体字段注意对齐和padding高频访问的原子变量手动按缓存行对齐。涉及CAS循环的无锁结构先画清楚ABA问题的处理方案写不出方案就回去用锁。这套流程帮我们省掉过至少三次线上级别的崩溃排查。有一次一个同事把std::atomicuint64_t放在两个线程各写一个相邻字段的同一个结构体里结果就是缓存行颠簸服务端延迟涨了10倍。他把代码给我看的时候我已经能闻到伪共享的味道了——alignas(64)加上去延迟立刻恢复。5. 一个完整排查案例atomic结构体在ARM上的神秘退化5.1 现场同样的代码x86 20nsARM 2us有一次我在ARM开发板上移植一个自研的无锁MPSC队列x86上每个操作稳定在20ns左右ARM上直接飙到2us差了100倍。一开始以为是ARM本身慢但一个简单的std::atomicuint64_tfetch_add明明只要几十ns问题肯定不在硬件。5.2 排查拆到最小复现发现是is_lock_free()在作怪我一点点拆分代码最后发现队列的Node里存的是一个16字节的payload结构体。在x86-64的开发机上CMPXCHG16B让std::atomicPayload保持了lock-free但ARM64平台上虽然理论上LDXP/STXP可以处理16字节但C标准库实现可能因为对齐策略或指令集选择直接对这个类型使用了内部锁——is_lock_free()返回false。也就是说我天真地以为自己在写无锁队列实际在ARM上每个操作都要抢一把内部libatomic的全局锁大量线程在那里排队。2us的延迟不是原子指令的延迟而是锁的延迟。5.3 修复用8字节指针替代16字节载荷修复方式并不复杂把Node里直接存放的16字节数据改成8字节指针指向真正存数据的内存块。这样原子对象变成std::atomicNode*8字节在所有主流64位平台上都是lock-freeARM上的性能立刻回到几十纳秒级别。这个案例是四条铁律的活教材不只是“能不能lock-free”还要在目标平台上实测is_lock_free()不只是“类型大小在x86上没问题”还要考虑ARM等不同指令集架构的差异化支持。5.4 引申std::atomic_ref与无锁世界的边界C20引入了std::atomic_ref允许对非原子对象执行原子操作。它在实际工程里可以避免拷贝比如直接原子操作一个大数组的某个元素或者把两个线程共享的普通int临时当作原子变量访问。但注意std::atomic_ref同样要遵守上面所有限制类型必须trivially copyable、大小必须在硬件可原子访问范围内、并且对象生命周期内不能有其他线程同时对它做非原子访问。我也踩过一个std::atomic_ref的坑对一个普通结构体的字段做原子操作但另一个线程直接普通赋值。这算是数据竞争属于未定义行为但在x86上跑得很“正常”换到ARM上就出现了怪异的偶发崩溃。无锁世界的边界很多时候就是这样硬件没那么可靠标准模型才是准绳。写在最后的一些体会玩C多线程这么多年我对std::atomicT的态度从最初的“什么都能原子化”变成现在非常谨慎的“先查指令集再写代码”。它本质上是一个跨平台的抽象层但抽象层不意味着magic——CPU原生不支持的大小、自定拷贝语义、弱内存序屏障成本、对齐与padding问题这些都是藏在“现代C很安全”表象下的暗礁。如果让我给一个结论那就是能用std::atomic的场景优先用最简单基础类型的std::atomic需要无锁自定义结构体时先验证is_always_lock_free再动手任何CAS循环先想清楚ABA问题内存序别默认seq_cst写到底。这些习惯看起来很怂但正是它们帮我在线上躲过了一颗又一颗的雷。最后再分享一个小技巧写原子类型相关代码前先编一个只输出宏的小程序看一眼ATOMIC_*_LOCK_FREE宏的值再决定要不要继续。这个小动作花不了两分钟省下来的查bug时间是按天算的。
返回列表