☰
无锁编程实战:从CAS原理到高并发MPSC队列设计
2026/10/2 4:25:19 网站建设 项目流程

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、内存模型与 ABA

3.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 又变回 Node1,A 的 CAS 会成功,但它移走的新指针可能指向已经被其他线程释放的内存。轻则数据错乱,重则内存越界崩溃。

解决 ABA 的经典手段是引入版本号或标记位。每一次修改不只是更新值,还顺带让版本号自增。CAS 比较的就需要是两个字段的组合——值加上版本号。Java 提供了AtomicStampedReference专门干这个;C++ 则常用std::atomic<std::pair<T, uint64_t>>或者用 128 位 CAS 同时携带版本号。内存回收上也有风险,这个话题后面实操部分再展开。

4. 实战拆解:从无锁队列到高并发 IM

4.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,方便用位运算取模。核心字段如下:

template<typename T> class MPSCQueue { // 队列尾部(写索引),用 atomic 修饰 std::atomic<size_t> tail_; // 队列头部(读索引),消费者线程独占 size_t head_; // 标记每个槽位的状态:空/有数据/正在写 std::atomic<uint32_t> slot_state_[capacity]; T data_[capacity]; };

生产者入队流程:

  1. 通过原子操作获取当前 tail 值,并尝试 CAS 将其递增,为自己预留一个槽位。
  2. 在预留的槽位写入数据。
  3. 将该槽位的状态标记为“可读”,并让 tail 自增。

消费者出队流程:

  1. 检查 head_ 位置槽位状态是否为“可读”。
  2. 如果是,读取数据,把头指针向后移。
  3. 如果 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::atomic<size_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 花活都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询