
1. 为什么大家都在谈“无锁”和“原子操作”先抛个场景。你写了一个多线程计数器count一行代码压测时发现性能上不去甚至偶尔结果不对。然后把代码改成lock_guard加锁结果缓下来了但正确了。再后来你听说“无锁编程”可以兼得性能和正确性于是搜了一堆文章发现动不动就是CAS、ABA、内存屏障、内存序看得头皮发麻。我最早接触这块也是这样。搞了几年业务代码自认为并发编程就是“加锁嘛锁住临界区就完事了”直到有一天领导让我优化一个高频交易撮合系统的热点路径锁的竞争导致吞吐量上不去我才被迫把无锁编程和原子操作认认真真啃了一遍。现在回头看无锁编程没有网上吹得那么神秘但它确实有一套完全不同于加锁模型的思维范式你一旦用加锁的思路去理解它只会越来越糊涂。先说结论无锁编程的核心目标不是“去掉锁”本身而是去掉锁导致的线程阻塞、上下文切换和锁竞争带来的排队开销。加锁模型保护共享资源的方式是“互斥访问”同一时刻只有一个线程能碰数据其他线程都得排队等无锁模型则允许所有线程同时冲上来操作数据通过 CPU 提供的原子指令比如 CASCompare-And-Swap保证操作的正确性冲突的线程不是阻塞等待而是重试再试一次。适合看这篇文章的人我觉得有三类写后台服务、中间件、基础组件遇到高并发瓶颈想给热点路径提速的开发者面试前想彻底搞懂“无锁编程”“原子操作”“CAS”“内存序”这些概念的求职者看了一堆源码比如 Java 并发包、Go 的 sync 包底层、某些高性能队列内核但总觉得隔层纱想补上底层机制的人。这篇文章不会用教科书式的方式给你罗列概念而是按我自己的理解从硬件指令讲到内存模型再用真实的代码一步步实现无锁数据结构最后把那些文档里不写的坑全部摊开给你看。2. 原子操作的底牌从 CPU 指令到内存模型2.1 为什么一条“读-改-写”不是原子的你写的count在 CPU 层面其实对应三条操作把count从内存读到 CPU 寄存器在寄存器里加 1再把新值写回内存。单线程下没问题但两个线程同时执行这一套流程就可能出现“读-加-写”的中间状态被覆盖。用生活类比就是银行的账户余额更新。柜员 A 读出余额是 100准备存入 50柜员 B 同时读出余额也是 100准备取出 20。如果操作不是原子的A 写回 150B 写回 80最终余额取决于谁最后写可能完全错误。银行面对这个问题的方法是给每笔交易上锁数据库用行锁编程里我们用互斥锁。但锁是有代价的——如果一个线程持锁时间稍长其他线程全部阻塞极端情况下锁竞争变成性能瓶颈。原子操作解决这个问题的思路完全不同它把“读-改-写”合并成一条 CPU 指令让整条操作不能被线程调度打断。最典型的就是 CASbool compare_and_swap(uint32_t* addr, uint32_t expected, uint32_t desired);如果*addr当前值等于expected就把*addr改成desired并返回 true否则不做任何修改返回 false。这条指令在 x86 上是lock cmpxchg在 ARM 上是LDXR/STXR循环或者较新架构的CAS指令。它保证整个比较和交换过程是原子的不会被其他线程插入。2.2 x86 和 ARM 的原子指令差异我最初有个误解以为所有平台的原子指令都一样。实际上 x86 和 ARM 的内存模型差异相当大这也直接影响你写无锁代码时选择什么层级的原子操作。x86 是强内存模型TSOTotal Store Order你写的普通 store 操作不会被后面读操作重排到前面CPU 会严格按程序顺序对外可见。这也是为什么很多 x86 下跑得好好的无锁代码到了 ARM 上却出诡异问题的原因之一。ARM 是弱内存模型CPU 和编译器可以做大量重排序优化。同一个线程内两个无依赖的 store实际执行顺序可能翻转更糟糕的是其他线程观察到的顺序可能不一致。这就引出内存屏障Memory Barrier和内存序Memory Order的概念我现在把这两样东西的底牌先摆出来后面第四节会详细说。在实际开发中我们不会直接写lock cmpxchg这样的汇编而是用语言提供的原子库——C 的std::atomic、Java 的AtomicXxx、Go 的sync/atomic。这些库本质上封装了 CPU 指令并告诉你默认使用什么级别的内存序保证。2.3 原子操作的三种常用语义用 C 的std::atomic举例最常用的是这三种relaxed宽松只保证原子性不保证重排序限制。适合做计数器这种不需要跨线程同步别的数据的场景。acquire/release保证“先 release 写入的数据对后面 acquire 读取的线程可见”。适合传递指针、发布状态等场景。seq_cst顺序一致最严格不仅保证释放-获取的可见性还保证所有线程观察到的全局操作顺序一致。如果用 Java对应的是AtomicInteger默认的getAndIncrement等价于 seq_cst 层面以及VarHandle里的getVolatile、getOpaque、getAcquire。Go 的sync/atomic在 1.19 之后也加入了更细粒度的atomic.Int32和相关 API。看到这里你会发现一个关键点原子操作只是解决了“单个操作的原子性”并没有解决“多个操作组合起来的原子性”。这就是为什么我们需要无锁数据结构来编排这些原子操作而不是简单地用几个原子变量拼拼凑凑就完事。3. 无锁数据结构实战用 CAS 徒手实现一个并发安全栈3.1 核心设计思想基于链表 头指针 CAS无锁栈Lock-Free Stack是学习无锁编程最好的起点因为它直观、经典而且纯粹用 CAS 就能实现。核心数据结构是一个单向链表和头指针入栈和出栈都只操作头指针。// 以 C 伪代码风格展示核心逻辑 struct Node { void* data; Node* next; }; struct Stack { Node* head; }; void push(Stack* s, Node* n) { Node* old_head; do { old_head s-head; n-next old_head; } while (!CAS(s-head, old_head, n)); }关键就在do-while循环里每次先把当前头指针读出来把自己的next指向它然后用 CAS 尝试把头指针换成自己。如果 CAS 成功说明没有人和你竞争插入完成失败说明有其他线程抢先改了头指针你重新读一遍新的头指针再试。这是一种典型的乐观并发策略——假设冲突少真冲突就重试而不是阻塞等待。3.2 为什么这个设计在高竞争下性能反而更好很多人会问CAS 失败要重试万一竞争很激烈重试次数多那不还是慢吗答案是相比上下文切换重试循环的代价小得多。加锁时如果锁被占用线程进入内核态睡眠将来被唤醒又是一次上下文切换一次切换大概需要几百纳秒到微秒级而 CAS 失败后的循环重试耗的是 CPU 自旋时间纳秒级到几十纳秒量级本质上是“用 CPU 空转换线程调度开销”。在临界区极短的场景下比如计数器、栈指针更新、队列入队无锁方案的优势非常明显。我实测过一个场景8 线程并发入队出队队列操作本身只有几十纳秒加锁队列因为锁竞争激烈吞吐量出现明显下降无锁队列在低竞争时和加锁差不多竞争加剧后仍然保持稳定。但这不意味着无锁永远更快如果临界区很重锁反而更好这一点我在第六节细讲。3.3 出栈 pop 的隐藏陷阱入栈很简单出栈就没那么省心了。朴素的出栈逻辑是Node* pop(Stack* s) { Node* old_head; do { old_head s-head; if (old_head nullptr) return nullptr; } while (!CAS(s-head, old_head, old_head-next)); return old_head; }看起来也没问题但有一个致命隐患当线程 A 执行old_head-next之后、CAS 之前如果另一个线程把old_head出栈并且释放了这块内存那么 A 再访问old_head-next就成了访问已释放的内存轻则读到脏数据重则直接崩溃。这个问题的专业名头就是无锁编程最出名的坑ABA 问题下一节详细拆。如果你只是想跑通最基础的无锁栈建议暂时别释放节点内存全部用预分配的对象池来避免这个问题。真正的业务代码里空间释放策略必须在设计阶段就解决否则无锁代码再优雅也会在凌晨三点翻车。4. 内存序的“深浅玄机”宽松序、释放-获取与顺序一致4.1 为什么只靠 CAS 还不够你可能会想有了 CAS再保证入栈出栈操作正确是不是就完事了远远不够。CAS 只解决了“单个指针变量的原子更新”但一个线程在 CAS 之前写的数据比如栈节点的data字段另一个线程在 CAS 成功看到新头指针之后去读data这个数据是否可见不是 CAS 能保证的取决于你使用的内存序。这是初学无锁编程最容易忽略的地方把std::atomic用得跟普通变量一样或者所有原子操作都用默认的seq_cst然后在 ARM 之类的弱内存模型上出了问题不知道去哪排查。4.2 三种内存序怎么选用生活化场景理解我可以把内存序理解成收发快递的标准relaxed就是你直接把快递丢给快递员他可能今天送也可能明天送你不管只要包裹本身没碎就行。适合你只关心“这个数对不对”不关心“它和其他数据之间的顺序”。release是你发快递时明确要求“我这批货里有一个特殊的袋子里装了发货单其他货必须和发货单一起先打包好再发出”。对应到代码里就有点像store(release)之前的普通写操作不允许被重排到store(release)之后。acquire是你收到快递时确保“拿到这个包裹之前的所有相关货物我都已经全部收到了”。对应load(acquire)之后的操作不允许被重排到load(acquire)之前。最经典的使用场景就是“发布者-消费者”模式。发布者先准备好数据然后通过一个原子变量发布指针// 发布者线程>