
1. 无锁编程到底在说什么珠穆朗玛峰是登山者的终极挑战F1无缝换挡是变速箱机械结构的巅峰设计。这两个意象放在一起其实就是在描述并发编程里最让人又爱又恨的一类技术——无锁编程。先说清楚它是什么。无锁编程Lock-Free Programming指的是在多线程共享数据时不使用互斥锁Mutex、读写锁这类传统同步机制而是依赖 CPU 的原子指令比如 CAS直接对内存进行操作从而完成线程之间的安全协作。它的本质不是“不用同步”而是“用另一种同步方式”——把原来由锁承担的阻塞、唤醒、排队逻辑变成一种“要么成功、要么重试”的乐观策略。我最早接触这个概念是在做高并发 IM 消息推送的时候。当时系统每秒钟要处理几万条下行消息连接层和路由层之间有大量共享队列。最开始用的是加了锁的 std::queue上线后发现 CPU 负载并不高但延迟偶发性地飙到几百毫秒。排查到最后锁竞争和上下文切换是元凶。也正是在那个阶段我正式开始研究无锁方案把那套经历过惨烈线上问题换来的经验沉淀了下来。这文章不是写给想学“Hello World”的人看的但也不是只给内核专家看的。你只要写过一段并发代码、被死锁坑过、或者被线上锁竞争搞得焦头烂额过就能看懂。我们会把无锁编程的原理、适用场景、实战方案和踩坑记录全部摊开来讲。像 F1 的无缝换挡一样换挡瞬间动力不中断靠的是机械设计无锁编程的高吞吐、低延迟靠的是对硬件和编译器行为的精确理解。两件事本质都是“在极限状态下追求不中断”。2. 锁为什么成为高并发瓶颈2.1 锁竞争的本质传统互斥锁的工作流程可以用一句话概括想进去先拿钥匙拿不到就在门口排队等着。这个“排队等着”听起来没什么大不了但在高并发场景下问题会累积成雪崩。先说最简单的场景一把锁被两个线程抢。线程 A 拿到了锁线程 B 只能阻塞等待。这里就有两个成本第一是线程 B 从运行态切换到阻塞态操作系统需要做一次上下文切换第二是锁释放后线程 B 被唤醒还要再次上下文切换。一次上下文切换的成本大致在 1 到 10 微秒级别看操作系统和硬件环境。如果锁的临界区本身只需要几百纳秒就能执行完那么线程把 99% 的时间都花在了“排队”上实际干活的时间少得可怜。临界区太小锁本身的开销超过了实际工作收益这就是经典问题。临界区很大锁竞争虽然不明显但并发度上不去吞吐照样被压住。很多系统的并发瓶颈不是代码写得差而是锁的结构性开销太贵。2.2 从数字看锁开销我曾经在测试机上跑过一组基准数据4 路物理 CPU、每路 16 核心的服务器纯用 C 实现共享计数器累加。单线程累加一亿次大概 30 毫秒如果用 std::mutex 保护8 线程并发累加一亿次耗时直接干到 1.8 秒。也就是说多加了 7 个线程性能反而下降了近 60 倍。这个测试后来我换了 Java 版本用 synchronized 和 AtomicLong 对比趋势完全类似只是具体数字不同。读者看到这里可能会说这难道不是测试样例太极端吗没错计数器是锁竞争最极端的例子。但现实业务很少是“只抢一个计数器”更常见的场景是共享队列、共享缓存、热点账户余额、库存扣减。这些场景也一样只要多个线程高频访问同一个受保护资源锁的争抢就会不断放大延迟。除了性能和延迟锁还带来两个非常隐蔽的问题死锁和优先级反转。死锁好理解两个线程互相持有对方需要的锁不释放。优先级反转则更阴险一个低优先级的线程先拿到了锁高优先级线程在后面等结果低优先级线程被其他中等优先级任务频繁抢占高优先级线程迟迟无法执行。这类问题的排查难度远远高于性能调优。2.3 无锁不是银弹但它改变了协作模型锁的核心问题是“阻塞”。无锁编程的核心思想是我不等了。线程直接尝试执行更新操作如果发现共享状态已经被别人改了就重试如果没被改就一次性成功。整个过程没有一个线程被挂起也没有上下文切换。回到 F1 无缝换挡的意象传统换挡要切断动力——踩离合、摘挡、挂挡、松离合动力输出在换挡瞬间是中断的。无缝换挡变速箱通过两组拨叉同时预啮合下一挡位换挡瞬间不切断动力。锁就是那个“切断动力”的离合无锁就是不切断动力的换挡逻辑。当然现在的无锁编程还远未到“人人都能玩转”的程度这也就是为什么它被称为并发领域的珠穆朗玛峰。不是因为它高不可攀而是因为它足够险、足够难而且一旦失手后果往往极其隐蔽。下一节我们来拆解无锁编程最核心的几块基石。3. 无锁编程的灵魂CAS、内存模型与 ABA3.1 CAS 是一把“原子试错”的钥匙无锁编程的底层基础是 CAS 指令全称 Compare And Swap。它做的事情是比较某个内存位置的当前值和期望值如果一致就把它更新为新值不一致什么都不做。整个“读-比较-写”的过程在硬件层面是原子的不可被中断。CAS 的伪代码逻辑如下bool compare_and_swap(int* ptr, int expected, int new_value) { if (*ptr expected) { *ptr new_value; return true; // 替换成功 } return false; // 值已被别人修改 }注意这里的语义它不是先读再判断而是一条完整的 CPU 指令。在 x86 上是LOCK CMPXCHG在 ARM 上通常是LDXR/STXR配对。一个线程要更新共享变量时不断循环执行 CAS一旦成功就退出循环失败则重新读取最新值再试。这种“重试直到成功”的模式叫做乐观并发控制因为大多数时候其实没人和你抢CAS 一次就能成功只有竞争激烈的时候才需要多试几次。3.2 你以为的原子未必是别人看见的原子CAS 只是保证了“单个操作的原子性”但多线程环境下还有一个更隐蔽的问题可见性。一个线程修改了变量其他线程什么时候能看到这个修改在高级语言层面编译器会对代码做指令重排CPU 也可能对指令执行顺序做乱序优化。你以为程序按代码顺序执行实际上在硬件层面可能完全不是这么回事。比如线程 A 先写普通变量 x再写标志位 flag线程 B 如果只看到 flag 变化就默认 x 也一定写好了这个假设可能是不成立的。x 的写入可能还停留在 CPU 缓存里没有同步到内存中或者被编译器重排到了 flag 之后。这就是为什么无锁编程里一定要用原子类型。C 的std::atomic和 Java 的java.util.concurrent.atomic包都封装了内存屏障Memory Barrier语义。std::atomic默认使用memory_order_seq_cst顺序一致这是最严格的内存序保证所有线程看到全局一致的操作顺序代价是性能稍有损耗但在业务代码里通常可以接受。Java 的volatile则是更轻量的内存屏障只保证可见性和单次读写的原子性不保证复合操作原子性。写无锁代码的第一条铁律就是共享数据必须用原子类型别用普通变量搞“你懂的”优化。在这个问题上玩火性能再好也会被线上疑难 bug 烧成灰。3.3 ABA 问题你看到的“没变”真的没变吗CAS 有一个经典的陷阱叫作 ABA 问题。看名字就能猜到线程 A 读到变量值为 A准备更新此时线程 B 把 A 改成 B又改回 A线程 A 再执行 CAS 时发现值还是 A于是认为“没人改过”就成功了。但实际它想保护的数据结构可能已经被动过手脚。举一个真实场景无锁栈的节点复用。线程 A 从栈顶取节点用 CAS 尝试把栈顶指针移向下一个节点。如果栈顶指针从 Node1 变成 Node2 又变回 Node1A 的 CAS 会成功但它移走的新指针可能指向已经被其他线程释放的内存。轻则数据错乱重则内存越界崩溃。解决 ABA 的经典手段是引入版本号或标记位。每一次修改不只是更新值还顺带让版本号自增。CAS 比较的就需要是两个字段的组合——值加上版本号。Java 提供了AtomicStampedReference专门干这个C 则常用std::atomicstd::pairT, uint64_t或者用 128 位 CAS 同时携带版本号。内存回收上也有风险这个话题后面实操部分再展开。4. 实战拆解从无锁队列到高并发 IM4.1 先搞清楚你的真实场景无锁数据结构里最容易入门、也最贴近业务的是无锁队列Lock-Free Queue。网上有很多实现但直接抄往往水土不服。原因很简单无锁队列的使用场景差异极大选错模型实现越精巧越浪费。先问自己三个问题生产者和消费者的数量是多少是单对单SPSC、单对多SPMC、多对单MPSC还是多对多MPMC对延迟的敏感程度有多高队列容量是固定的还是有界可变这里最值得注意的结论是SPSC单生产者单消费者队列的情况最简单连 CAS 很多时候都不需要只需要内存屏障和原子计数。因为有且仅有一个生产者和一个消费者每个角色各自维护自己的读写索引不需要竞争更新同一个位置。FIFO 队列这种模型天然和 SPSC 契合性能可以做到接近数组拷贝级别。而 MPMC多生产者多消费者是最复杂的情况每个线程都可能同时入队和出队所有索引都必须走 CAS还要处理内存复用问题。我在很多项目里见过一个通病场景明明是多个 worker 处理一个输入队列属于典型的 MPSC却有人硬套了一个 MPMC 实现。性能反而比用互斥锁更差因为无锁的竞争重试开销在激烈竞争下可能比锁的阻塞开销更昂贵。4.2 动手写一个 MPSC 无锁队列我挑 MPSC 模型来讲因为这个模型在业务系统里最常用一批生产者线程把任务提交进队列一个消费者线程批量取任务处理。日志收集、异步任务分发、IM 消息缓冲都是这个模型的变体。队列结构可以用一个环形数组实现。环有大小 power of 2方便用位运算取模。核心字段如下templatetypename T class MPSCQueue { // 队列尾部写索引用 atomic 修饰 std::atomicsize_t tail_; // 队列头部读索引消费者线程独占 size_t head_; // 标记每个槽位的状态空/有数据/正在写 std::atomicuint32_t slot_state_[capacity]; T data_[capacity]; };生产者入队流程通过原子操作获取当前 tail 值并尝试 CAS 将其递增为自己预留一个槽位。在预留的槽位写入数据。将该槽位的状态标记为“可读”并让 tail 自增。消费者出队流程检查 head_ 位置槽位状态是否为“可读”。如果是读取数据把头指针向后移。如果 head_ 已经追上了 tail_队列为空消费者可以自旋或者休眠等待。这套设计的关键点在于“写数据”和“标记可读”分两步完成。为什么不能先把数据写进数组再用 CAS 更新 tail因为消费者看到 tail 更新后会立刻去读 head 对应的槽位。如果此时数据还在缓存中没写完整消费者读到的就是半成品。所以生产者必须保证“槽位数据可见”之后再推进尾指针。这里应使用 release 内存序来发布 tail而消费者的读取用 acquire 内存序。C 标准库中store和load的默认顺序是 relaxed不写内存序的后果就是数据竞态。再配合批量出队策略消费者一次可以尝试拿一串相邻槽位的数据减少原子操作频率吞吐量会显著提升。4.3 伪共享性能杀手藏在缓存行里写无锁代码平时没遇到 bug性能却很拉胯第一个要怀疑的就是伪共享False Sharing。现代 CPU 读内存不是按字节读而是以缓存行Cache Line为单位通常是 64 字节。如果两个变量被安排在同一个缓存行里并且被不同线程分别更新CPU 会强制让缓存行在不同核心之间来回“失效-重载”即使两个线程根本没有真正共享任何逻辑数据。这种表面上没共享、实际硬件层面还在疯狂竞争的效应就是伪共享。解决伪共享的手段是缓存行填充Padding。让不同的共享变量分别占据独立的缓存行把 64 字节占满或者在变量周围填充足够的无用字节。我在自己写队列时特意按 64 字节对齐了三个热点字段struct alignas(64) MPSCQueueHot { std::atomicsize_t tail_; char padding_[56]; };这段 padding 的意义是把 tail_ 和 head_ 分别放在不同的缓存行里。不用怀疑就这一个改动在高并发压测下吞吐能提升 20% 到 40%。网上也有不少关于缓存行对齐的测试报告结果都差不多。这类优化不难但前提是你要知道存在这个坑。5. 无锁编程在真实高并发系统的选用策略5.1 什么场景才值得动无锁很多工程师一看到“无锁”两个字就兴奋觉得锁是万恶之源能不用就不用。但我的实际经验是一个好的并发系统90% 的锁问题不是换无锁解决的而是从架构层面消灭锁的。真正值得用无锁的场景大概有这么几类临界区极小且高频执行的操作比如计数器、统计指标、引用计数。高频读低频写的热点数据比如配置中心在内存中的缓存可以用读写锁之外的 double-checked 加载或者用原子指针替换整个对象。高性能中间件、消息队列、游戏服务器的事件循环这类系统对 P99 延迟极度敏感不能容忍一次互斥锁带来的毫秒级抖动。复用率极高的对象池、连接池。锁会让“获取/归还”这两个轻量级操作变成沉重的同步点。反过来以下几种情况我劝你老实加锁临界区逻辑复杂、耗时长比如涉及多次递归调用、IO 操作、数据库访问。这类临界区锁竞争本身占比不大无锁化收益很低。业务逻辑需要强一致性的复杂事务比如账户转账、库存多级扣减。无锁重试在这种场景下不仅难写还容易引发“活锁”问题。你还没有性能瓶颈也没做过 profile。过早的无锁化只会让你提前体验维护地狱。5.2 无锁性能到底强在哪从延迟分布看我跑过一组对比压测模拟一个典型的高并发 IM 离线消息摘要队列8 个线程生产1 个线程消费单条消息大小约 200 字节。用互斥锁实现队列P50 延迟在 50 微秒左右P99 延迟则飙升到 2 毫秒。用 MPSC 无锁队列实现同一套逻辑P50 降到 12 微秒P99 稳定在 80 微秒以内。最让人惊讶的不是平均性能而是长尾效应的改善。锁队列里偶尔出现的 5ms 高峰多是线程被操作系统调度挂起再唤醒造成的无锁队列因为没有线程阻塞延迟分布非常紧凑完全没有了那种“突然卡一下”的体验。这个实验说明无锁编程对高并发场景的核心价值不只是吞吐更高更多是延迟更确定性。在即时消息、交易撮合、广告投放这类对延迟敏感的业务里P99 的稳定性往往比平均延迟更重要。F1 换挡要的就是“没有那一瞬间的顿挫”无锁要的就是“没有那一瞬间的阻塞”。5.3 无锁代码的三个不可告人的痛点第一调试极难。锁的 bug 往往可以通过转储线程栈看出来比如线程 A 等待线程 B 持有的锁。但无锁的 bug 是“数据在极端时序下变了”的幽灵问题测试环境和线上环境很难复现。我见过一个无锁队列的 bug线上每三四天崩一次本地压力测试跑一周都正常。最后是在某次调大并发后偶然复现加上排查了数据指针的回收路径才找到根因——释放的节点被另一线程重新拿到了。第二内存管理逃不掉。无锁数据结构中节点被多个线程共同访问你很难确定什么时候才能真正安全释放内存。经典的解法是 hazard pointer危险指针或 epoch-based reclamation基于时代戳的回收。Java 因为有 GC天然规避了这个大坑所以写无锁代码比 C 要轻松不少。C 用户需要自己设计一套延迟回收机制。第三你无法用单测证明正确性。无锁算法的正确性依赖时序普通单测只能测“毫无竞争”的状态。真正有效的测试是把线程数调高、把临界区尽量压缩并配合 ThreadSanitizer 一类工具跑长时间压力测试。即便如此也仅是增强了信心谈不上证明。6. 常见问题与排查技巧实录6.1 无锁编程的典型故障速查表我把这几年遇到过的典型问题整理成了表格供大家排查参考。每一个问题我都踩过或者亲眼见过。现象可能原因排查方法与解法偶发数据错乱或崩溃CAS 之外的共享变量没有用原子类型编译器重排导致发布序错误检查所有共享字段是否 atomic写发布者用 release读者用 acquire高并发下性能反而下降伪共享或者无锁竞争过于激烈导致大量重试用 perf 查看缓存未命中率热点字段填充到独立缓存行队列丢失元素或重复消费生产者同时推进 tail 和数据状态顺序不对严格保证先写数据、release 发布 tail消费者 acquire 读取指针引用已释放内存ABA 问题或者节点回收过早加版本号使用 hazard pointer 或延迟回收机制长时间卡顿或活锁重试策略设计不当多个线程互相抢占、永远无法成功加入随机退避backoff控制重试频率非 x86 平台行为不一致弱内存模型下内存屏障缺失x86 上侥幸正常在 ARM、PowerPC 架构上用实际机器或模拟器做压测6.2 高并发压测的一个建议流程选型无锁方案后不能只在笔记本上跑个 demo 就了事。我的建议是用以下流程走一遍可以在上线前过滤掉大部分雷第一构造真实的竞争模型。不要只压一个空队列读写要在数据生产侧加入真实业务逻辑比如消息序列化、加密、压缩再放入队列。竞争的程度取决于临界区之外的耗时而不是队列本身的消耗。第二用压测工具覆盖不同并发梯度。启动线程数从 1、2、4、8、16、32 逐步增加记录吞吐和 P99 延迟。重点看在多少并发下开始出现性能拐点以及拐点之后是平稳下降还是断崖式恶化。这里有一个值得参考的曲线无锁队列通常会在核心数附近达到吞吐峰值之后缓慢下降有锁队列则可能在一个很低的并发数就开始掉吞吐再后直接崩溃式抬升延迟。第三用 Google TSan 或者 Java 的 jcstress 工具跑正确性压力测试。线程数要超过 CPU 核心数任务数要尽量大最好能跑到千万级。我当时测 C 无锁队列时用 TSan 找出了两处数据竞态都是平时压测根本测不出来的。第四留足运维观测点。无锁代码越复杂越需要可观测性。我在队列实现里加了原子计数器记录生产者成功入队和重试的次数消费者空转次数配合 Prometheus 指标展示。上线后一旦业务流量异常可以通过重试率、空转率快速判断是竞争失控还是消费速度不足。这个习惯帮我排过不少没有明显报错但性能劣化的问题。6.3 别忽略编译器和 CPU 的差异同一个无锁队列在 x86_64 的服务器上表现正常挪到 ARM 的云上实例就莫名出错。这类经验我已经听到过无数次。x86 CPU 有较强硬件内存模型很多宽松同步问题被硬件掩盖了ARM 和 PowerPC 的模型更弱对内存屏障的要求更严格代码里的架构差异会立刻暴露成偶发 bug。如果你主要跑 x86至少要在交付前拿到一台 ARM 架构的机器或者云实例跑一轮完整回归。更稳妥的做法是核心无锁数据结构必须只依赖语言标准定义的原子内存序不要依赖任何平台上的“刚好能跑”。你写release、acquire不是因为 x86 需要而是为了在全平台都正确。另外编译器优化级别也会影响结果。我在用 GCC 和 Clang 编译同一套无锁代码时发现 Clang 对无锁结构的优化有时比 GCC 更激进原代码在 GCC 上跑得好好的在 Clang 上出现性能下降。排查后发现是编译器自动向量化导致的数据访问stride变化。这不一定是 bug但提醒我们要在最终生产用的编译器上做压测不要开发环境一个编译器版本、线上另一个版本。7. 我给新手的几条实在建议写无锁编程不需要一开始就冲击登峰造极的实现。我更建议按这样的顺序上手先写一个使用std::atomic_flag实现的自旋锁理解了原子操作和内存序的基本语义。接着实现一个 SPSC 队列体会“无锁”在单生产者单消费者场景下可以简单到什么程度。然后再挑战 MPSC 队列这时候你会发现内存序、伪共享、ABA 会像三位老师一样依次到来。最后如果你还有足够的兴趣和耐心再去看 MPMC 实现。我非常不建议一上来就参考网上那种几十行源码的 MPMC 队列直接怼进生产系统。那些代码能在单测里跑过不代表在高并发下正确。里面每一行看似简单的原子操作背后都是作者踩过无数坑之后的取舍。你如果不理解取舍出了问题只能干瞪眼。我在实际项目中使用无锁方案时最大的感触是它不适合当作炫耀技巧的手段它是被真实性能指标逼出来的选择。每当有人问我“无锁能提升多少性能”我都会反问一句“你的锁到底占了多大开销”。没有 profile 就没有发言权。如果锁的开销只占 5%无锁化做得再完美也解决不了多少问题如果锁的开销占 60%哪怕只是把锁换成一个合理的无锁队列效果也是立竿见影的。最后分享一个我后来养成的习惯在架构层面尽量把并发问题拆散。比如 IM 系统里按用户维度做分片让同一个用户的消息路由到固定的 worker 线程这样队列从多对一退化成一对一的 SPSC根本不涉及竞争。无锁编程的终极目标其实不是写出多精巧的并发算法而是把大并发拆成小并发把难问题拆成不需要解决的问题。这个思路比任何 CAS 花活都管用。