ARTICLE DETAIL

资讯详情

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

无锁编程实战:从CAS到无锁队列,告别高并发锁瓶颈

无锁编程实战:从CAS到无锁队列,告别高并发锁瓶颈 做过高并发系统的人迟早都会遇到“一把锁压垮整个服务”的时刻。我第一次在代码里试着去掉一把锁花了一整周性能没涨反而凭空冒出来两个无法稳定复现的随机崩溃。但后来真正理解了 CAS、内存序和队列的环形结构之后我把一套高并发 IM 网关里的在线状态广播换成了无锁队列p99 从 80 毫秒降到了十几毫秒最坏情况的 p999 从 3.2 秒压进了 40 毫秒CPU 占用反而掉了三成。这种反差让我彻底理解了为什么有人把无锁编程称为并发的“珠穆朗玛峰”也理解了它为什么像 F1 赛车的“无缝换挡”一样在换挡瞬间动力不中断、车速几乎不掉整个过程顺滑得让人不太适应。这篇文章不是讲术语词条而是把我从“加锁党”到尝试无锁方案这条路上的核心思路、技术选型、代码实现和踩坑记录系统说一遍。它解决什么问题当你的系统负载升高、线程大量阻塞、锁成了瓶颈时无锁编程提供了一种不靠“等待”来协调并发的新思路。适合谁看正在做高并发 IM、消息推送、在线状态服务的后端开发以及想系统了解并发底层原理的初学者。阅读前只需要知道 Java、C 或 Go 里线程和同步的基本概念其它我会尽量用生活里的例子和可以直接参考的代码展开。1. 并发困境一把锁撑不住“万马奔腾”1.1 锁的三重代价等待、切换与缓存失效很多人刚开始做并发编程时觉得加锁是天经地义的事。共享资源访问不了加把锁就行。但真正到了高并发现场锁的代价是藏得很深的。第一重代价是阻塞等待。线程拿不到锁要么原地自旋要么被挂起。挂起意味着线程从运行态切换到等待态等锁释放后再从等待态切换回来。一次线程切换通常要几微秒而你的临界区代码可能只需要几十纳秒两者相差两个数量级。当几千个线程同时抢一把锁大量 CPU 时间不是花在处理业务上而是花在线程反复被挂起和唤醒上。第二重代价是上下文切换导致缓存变冷。每次切换线程CPU 要保存当前线程的寄存器、程序计数器还要刷新各种预测结构。最隐蔽的是缓存原来跑在线程 A 上的数据还在 CPU 缓存里切到线程 B 后这些数据大概率用不上缓存命中率骤降。你感受到的结果就是“CPU 占用 400%业务吞吐反而在下降”。第三重代价是缓存一致性协议的联动。一个核上的线程修改了被锁保护的共享变量其它核上持有这个变量缓存行的核都被迫失效。如果多个线程密集地持有和释放同一把锁这些核的缓存行就会反复失效整个系统的内存访问延迟被拉高。也就是说锁竞争的破坏力远远超出锁本身所在的临界区它会污染整个 CPU 的内存子系统。我负责的 IM 网关就栽在这上面。在线状态表里面有几十万条连接信息每次广播都要遍历这张表。一开始临界区只有几十行代码但广播量一上来单线程持有锁的时间线性变长抢锁的线程越来越多最后几乎所有的 CPU 时间都耗在阻塞、唤醒和缓存同步上。那时候最扎心的感受是瓶颈不在于临界区代码有多快而在于锁本身的争抢成本有多高。1.2 为什么临界区越长系统越慢有人可能会说“那把临界区写短点不就行了锁保护一行代码总该飞起来了吧。”这个说法对但只能缓解不能治本。因为锁竞争不是只发生在临界区内锁的争抢是全局性问题。你可以把临界区从一个完整的列表遍历缩短成一次指针读取但只要还有高频线程在抢同一把锁排队和缓存同步的成本依然在。短临界区只是把“堵车路段”缩短了车流总量没有变早晚高峰依然会堵。更麻烦的是缩短临界区往往意味着要重新设计数据结构。比如你为了在锁内只做一次指针切换可能需要在锁外面预计算好新状态把复杂度外置。这么做的前提是业务允许“延迟更新”但 IM 广播、交易撮合这类场景常常不允许。于是临界区只能越写越长锁的“毒性”越积越重。还有一个容易被忽视的问题锁本身也会引起线程调度抖动。两个线程同时抢一把锁一个拿到另一个被挂起被挂起的线程可能在 1 毫秒后才被唤醒而这个等待本身就是随机的。高并发系统的延迟尖峰很多时候不是业务慢而是锁让线程“睡过头”了。我在压测里看到 p999 延迟高达 3 秒追到底发现很多请求在等待锁的过程中被调度器晾了很久。所以在高并发场景下真正值得思考的问题不是“锁内代码怎么优化”而是“这个锁能不能干掉”。1.3 无锁的换挡哲学不中断、不等待为什么我用 F1 无缝换挡来类比无锁传统手动挡换挡时发动机和变速箱要短暂脱离动力是在那一瞬间中断的普通自家用车的自动挡会好一些但换挡瞬间动力也有明显扰动。F1 的无缝换挡变速箱则是利用齿轮预选机构和精细的转速控制换挡过程中动力传递几乎不断车速曲线平滑到让对手难以利用你的加速间隙。有锁并发就是“换挡断动力”线程在获取锁和释放锁之间计算是“中断”的其它线程只能在外面等待。无锁并发则是“无缝换挡”线程不存在阻塞等待这个状态每个线程都基于最新可见的数据做判断冲突时用原子操作快速重试让系统整体不停地往前推进。需要先泼一盆冷水无锁编程不是“不加锁就安全”。它依然要处理并发冲突只是把“你等我、我等你”的排队模式换成了“先比后换、失败重来”的竞争模式。这带来的好处是高并发下延迟稳定没有阻塞和唤醒抖动代价是代码复杂度显著上升而且一旦写错问题非常隐蔽。这也是为什么它被称作并发领域的珠穆朗玛峰——山顶风景很好但不少人会在半路冻死。2. 无锁编程的核心武器CAS、内存序与ABA2.1 CAS从“排队进闸机”到“先比后换”无锁编程的地基是CASCompare-And-Swap。这个名字听起来很高深本质就是一个不可分割的 CPU 指令你告诉内存一个期望值如果当前内存值确实等于这个期望值就把新值写进去如果不等于什么都不做。无论哪种情况指令都会返回当前真实值。用生活场景解释过地铁闸机时排队的人是一个个刷卡进去的相当于排队拿锁。CAS 则是“你先看一眼闸机显示灯是绿色刷一下卡如果灯还是绿色就进如果刚变成红色说明别人已经占用了你再重新看一次”。它把“检查状态”和“更新状态”打包成了一个原子动作中间没有缝隙让别人插进来。在 Java 里是AtomicInteger.compareAndSet(expected, newValue)在 C 里是std::atomicT::compare_exchange_weak在 Go 里是atomic.CompareAndSwapInt64。这些封装底层都是硬件指令这意味着判断和写入天然不可分割不需要额外加锁。CAS 强大之处在于它可以用来构造任意复杂的并发算法。比如你想实现一个“只更新一次”的计数器#include stdatomic.h atomic_int counter 0; int try_increment_once(void) { int expect 0; int desired 1; if (atomic_compare_exchange_strong(counter, expect, desired)) { return 1; // 我成功把 0 改成了 1 } return 0; // 别人已经改过了counter 不等于 0 }这种“先比后换、失败就重来或放弃”的逻辑就是无锁并发的灵魂。它不阻塞任何线程每个线程都带着自己的判断冲上去能改就改不能改就重试系统不会因为一把锁卡住整个链路。2.2 内存序藏在无锁代码里的隐形规则比 CAS 更隐蔽的是内存序。很多人学会 CAS 之后就开始写无锁代码结果在高并发下偶发遇到“明明 CAS 成功了但别的线程看到的数据是旧的”这种诡异问题。原因几乎都出在内存序上。CPU 和编译器都不是“按代码顺序逐行执行”的机器人。为了让流水线不空转它们会把没有依赖关系的指令重排。单线程看来没问题多线程下就麻烦了线程 A 先写数据、再置标志线程 B 看到标志后去读数据结果可能读到旧数据因为指令重排让数据写入发生在标志写入之后。C 的std::atomic为此提供了几种内存序选项memory_order_relaxed只保证原子性不保证顺序memory_order_acquire这个加载之后的读写不能被重排到它之前memory_order_release这个存储之前的读写不能被重排到它之后memory_order_acq_rel同时具备 acquire 和 release 的效果memory_order_seq_cst全局顺序一致最严格也最慢用生活类比release 有点像“发朋友圈前先把照片调好再说”acquire 像“看到朋友圈更新后才相信照片已经调好”。如果你发布时用的是 release接收时就必须用 acquire 来配对否则两者之间没有同步关系。看一段经典代码#include atomic #include thread std::atomicint ready{0}; std::atomicint data{0}; void producer() { data.store(42, std::memory_order_release); ready.store(1, std::memory_order_release); } void consumer() { while (ready.load(std::memory_order_acquire) 0) {} // 这里读取 data 是安全的 int value data.load(std::memory_order_relaxed); }ready的 release 存储保证data.store(42)的结果在ready1可见时也已经对消费者可见。消费者通过 acquire 加载看到了ready1就能安全读取data。如果你把两处都写成relaxed编译器完全可以把ready.store重排到data.store之前消费者就会看到ready1但data还是 0。这是我踩过最深的坑之一。用了无锁队列但内存序配错症状是“每几十万次请求偶发丢一条数据”这种问题用传统调试手段极难发现。2.3 ABA幽灵你在无锁世界里最大的威胁除了内存序无锁编程还有一个经典陷阱叫ABA 问题。它说的是线程 A 读取共享值得到 A准备 CAS 时被系统调度走线程 B 这时把值从 A 改成 B又改回 A线程 A 恢复后再次 CAS发现值还是 ACAS 成功但它不知道这个 A 已经不是当初那个 A 了。听起来有点绕实际场景却很真实。比如一个无锁链表线程 A 要对Head节点做 CAS期望值是节点 X。线程 B 趁机删除了 X 和它的后继节点 Y又插入了一个新的 X 节点可能地址刚好被内存复用。线程 A 醒来后看到Head还是 X继续执行后续操作结果操作的实际对象已经不是原来的节点了轻则数据错乱重则破坏链表结构。解决 ABA 有几种常见办法一是给每个变量加版本号CAS 时同时比较值和版本二是把单个变量的 CAS 升级为双字 CAS一次比较两个相邻的内存位置三是在数据结构设计层面避免复用同一个“地址标识”比如用位置索引代替指针。Java 的AtomicStampedReference就是为 ABA 而生的它把一个引用和一个整数版本号绑定在一起比较时两个都要相等才算成功。用无锁栈或链表时这类带版本号的原子引用几乎必需。3. 实操从SPSC无锁队列到MPSC扩展3.1 为什么先从单生产者单消费者开始聊完理论直接上一套能跑的代码。但我不建议你一上来就写 MPMC多生产者多消费者队列那是无锁编程里最难的一类。先从SPSC单生产者单消费者开始更实际因为高并发 IM 里最常见的反而是“多个生产者把事件投递给一个消费者”这种形态而 SPSC 是所有队列的基石理解了它再看 MPSC 会轻松很多。为什么 SPSC 简单因为生产者和消费者各自只有一个它们之间不需要互相竞争。生产者的任务是“写入槽位后发布索引”消费者的任务是“看到索引后读取槽位”两者只在一个“队首”和一个“队尾”上打交道。没有多个线程抢同一个位置自然也不需要 CAS。3.2 一个能跑的SPSC环形队列这个例子我按照生产可用级别写过核心思想很简单用一圈定长数组当缓冲head表示消费者读到哪tail表示生产者写到哪。队列空的条件是head tail队列满的条件是tail 1 head故意浪费一个槽位用来区分空和满。写一个简化但可运行的 C 版本#include atomic #include cstddef template typename T, std::size_t CAP class SpscQueue { alignas(64) std::atomicstd::size_t head_{0}; alignas(64) std::atomicstd::size_t tail_{0}; T slots_[CAP]; public: bool push(const T item) { const std::size_t t tail_.load(std::memory_order_relaxed); const std::size_t h head_.load(std::memory_order_acquire); if ((t 1) % CAP h) { return false; // 队列满 } slots_[t] item; tail_.store((t 1) % CAP, std::memory_order_release); return true; } bool pop(T out) { const std::size_t h head_.load(std::memory_order_relaxed); const std::size_t t tail_.load(std::memory_order_acquire); if (h t) { return false; // 队列空 } out slots_[h]; head_.store((h 1) % CAP, std::memory_order_release); return true; } };为什么是“先写数据再发布 tail”这是整个无锁队列的核心约定。生产者把数据写进slots_[t]后必须用 release 语义更新tail_消费者用 acquire 语义读取tail_之后才能读slots_[h]。这样只要消费者看到 tail 变化就能保证槽位里的数据已经写完。反过来如果你先更新 tail 再写数据消费者就会读到还没有初始化的内容。两个alignas(64)是为了让 head 和 tail 落在不同缓存行上防止生产者写 tail 时把消费者读的 head 所在的缓存行也拖下水这就是后面要讲的伪共享问题。这个队列的性能有多好我在 32 核机器上测过单线程生产者、单线程消费者吞吐能到每秒几千万条几乎没有锁竞争带来的延迟毛刺。在 IM 场景里如果只有一个写线程需要投递事件、一个读线程负责批量分发这个结构完全够用。3.3 从SPSC到MPSC挑战与业界解法实际业务里很少只有一个生产者。IM 网关往往是成百上千个连接各自投递事件到同一个分发线程这就是MPSC多生产者单消费者。要扩展成 MPSC核心矛盾在于多个生产者同时想申请槽位并发布 tail如果不加锁怎么保证不会互相覆盖环形缓冲区版本会遇到一个两难如果先写数据再 CAS 推进 tail两个生产者可能同时写同一个槽位其中一个人的数据会被覆盖如果先 CAS 占坑再写数据消费者在 tail 变化后可能立刻读到还没写好的槽位。所以严谨的 MPSC 无锁队列一般不用一次定长的环形数组而是用链表 哨兵节点的思路。哨兵节点方案的核心是让每个生产者先创建一个新节点然后用 CAS 把tail从当前节点推进到新节点成功后再把前一个节点的next指针指向新节点。消费者只在head和tail不相同时沿着head-next取节点。由于每个生产者拿到的是不同的前驱节点写next不会相互覆盖从结构上规避了环形数组的“发布顺序”问题。具体实现里还要处理很多细节节点内存的分配与回收、消费者看到的next可能是空的生产者推进 tail 之后还没来得及更新前驱节点的 next、以及 ABA 问题。生产级别的代码非常讲究我不建议你自己从零造轮子。业界早已有大量经过线上验证的方案C 的boost::lockfree::queue久经考验Java 的ConcurrentLinkedQueueRust 的 crossbeam 系列设计严谨Java 生态里的环形无锁队列 Disruptor在高性能交易系统里有大量案例3.4 什么场景才值得换成无锁队列无锁队列不是银弹它的收益只有特定场景才明显。我从实际项目里总结出的判断标准锁竞争越激烈、锁持有时间越长、线程数越远超 CPU 核数无锁队列的收益越大。反过来说如果并发峰值很低、锁几乎没有争抢无锁队列只会增加复杂度而看不出性能差异。我曾经在一个日活只有几千的小服务里强行换上无锁队列结果代码难看性能提升却不到 1%。后来我把判断口径改成了先压测看上下文切换和锁等待占比。如果perf里可以看到大量_raw_spin_lock、futex这类调用才考虑无锁化否则老老实实用有锁版本就是最好的选择。用表格对比一下两种方案对比维度有锁队列无锁队列实现难度低几行代码搞定高要处理内存序、ABA、内存管理高并发吞吐随着线程数增加先升后降在高竞争下依然能稳定吞吐延迟尖峰明显受阻塞唤醒影响很小几乎没有阻塞等待调试难度相对好排查极难问题多是不可稳定复现的偶发数据错乱适用场景低竞争、对延迟不敏感高竞争、对延迟和抖动敏感4. 高并发IM、Agent服务怎么选型先分清瓶颈4.1 16核32G服务器到底能扛多少并发网上经常有人问“16 核 32G 服务器能扛多少并发”。这个问题的标准答案是取决于你的请求模型和每个连接的内存占用没有统一数字。先看长连接场景。如果每个连接平均占用 1KB 状态32G 内存理论上能支撑几十万到上百万连接但这通常不是瓶颈。瓶颈往往在 IO 线程、事件循环、以及单机网络栈的能力上。一个设计良好的 IM 网关16 核机器撑几十万长连接很常见。再看短请求场景也就是 QPS。如果是纯内存计算、没有外部依赖的接口16 核机器跑到几万 QPS 很正常。如果每个请求都涉及数据库查询、Redis 访问或第三方 API 调用QPS 会明显下降因为瓶颈从 CPU 转移到了 IO 延迟和连接池大小上。所以做选型时别只盯着并发数这个词。先分清你的系统是 IO 密集型还是 CPU 密集型再决定优化方向。无锁队列优化的是多线程访问共享内存的竞争问题它解决不了 IO 等待也解决不了网络带宽。4.2 数据库锁、本地锁、分布式锁各自何时才是真瓶颈高并发系统里会出现多种“锁”它们的优化思路完全不同。数据库锁是持久化和事务并发控制的工具。当数据库行锁竞争激烈时通常不是数据库的问题而是业务没有做好读写分离、分库分表或者请求量已经超过了单库的处理能力。靠无锁编程解决不了数据库锁正确方向是优化 SQL、引入缓存或异步化。本地锁保护的是进程内共享资源比如内存缓存、连接池、状态表。这类锁才是无锁编程的发力点。当本地锁成了瓶颈可以参考前文的方法评估无锁化。分布式锁保护的是跨进程共享资源比如多个服务实例同时处理同一个订单。分布式锁的延时远高于本地锁所以能不开则不开。判断是不是真需要分布式锁的标准是资源冲突概率高不高可不可以让每个服务处理独立分片IM 和 Agent 服务里最容易被误用的是分布式锁。比如用 Redis 分布式锁来控制用户状态更新结果状态更新本来就该按用户维度分片到同一实例本地处理就够了没必要引入跨进程锁。4.3 IM广播和AI Agent调用的并发优化思路再回到热词里的两个具体场景高并发 IM 和 AI Agent。IM 的高并发瓶颈往往在连接管理和广播扇出。海量连接要维持心跳状态变更要通知到在线用户这两个动作都会读写共享连接表。如果连接表用锁保护广播量一上来就容易出现前文提到的崩溃。正确做法是长连接管理尽量用无锁或分片的数据结构广播事件写入无锁队列由一个或少数线程负责扇出。我在实际项目里就是让每台网关维护自己的连接表再通过无锁队列把状态变更事件分批投给分发线程效果非常明显。AI Agent 的并发瓶颈则完全不同。Agent 的请求大部分时间耗在模型推理和网络 IO 上几十毫秒到几秒的延迟非常正常真正的业务并发取决于你在 API 层做了多少并发限制、有没有做请求排队和背压。这种情况下你去优化本地共享变量的锁竞争收益几乎为零。应该做的是把外呼请求并发数控制在一个合理范围避免把第三方模型服务打爆对用户的请求做队列化和超时控制如果要在多线程里共享模型上下文或会话状态那才需要用到并发集合但即便这个环节用锁往往也绰绰有余因为并发本身的瓶颈根本不在 CPU。做选型时最忌讳“拿着锤子找钉子”。无锁编程确实厉害但它的适用边界非常明确本地多线程、高频共享内存、锁竞争已经能测得出来。不在这个范围内性价比就很低。5. 无锁并发的“珠穆朗玛峰”危险清单5.1 活锁CAS重试带来的“死循环”有锁编程最常见的问题是死锁无锁编程却天然没有死锁因为它没有“持有并等待”。但无锁编程有自己的“死锁”变体叫活锁。活锁的表现是线程一直没有阻塞但始终无法取得进展互相谦让或者互相覆盖CPU 消耗满满任务却一个都没完成。最典型的活锁是多线程同时做 CAS 重试。比如两个线程同时想把一个值从 0 改成 1线程 A CAS 成功线程 B 失败后立刻重试重试时它看到了新值 1按逻辑应该做别的处理但如果逻辑设计成“只要失败就无条件重试同一个操作”B 可能会尝试改回 0接着 A 又失败又改回 1两个线程在空中反复“打架”。解决活锁的思路通常是引入随机退避或指数退避。CAS 失败后先随机睡一小段时间再重试把“同步打架”打破。另一个思路是让每个线程竞争的粒度错开比如按线程 ID 分片各自处理自己的区域只在最终合并时用一次 CAS。5.2 伪共享明明没人锁性能还是上不去伪共享是无锁队列里最容易被忽略的性能杀手。CPU 缓存是以“缓存行”为单位加载的通常一行 64 字节。如果两个线程使用的变量恰好落在同一条缓存行里即使这两个变量逻辑上毫无关系只要一个线程写入自己的变量就会导致另一个核上包含这条缓存行的数据失效另一个线程再读时只能回内存重新加载。表面上没有锁实际上双方被缓存行的同步协议“锁”在一起性能可能比加锁还差。经典解决方案是缓存行填充把高频写入的变量用alignas(64)对齐让它单独占一条缓存行。我在 SPSC 队列代码里对 head 和 tail 做对齐就是为了防止这两个索引互相干扰。在实际排查时如果压测发现多线程吞吐随线程数增加不升反降且排除锁竞争后仍然有问题就要优先怀疑伪共享。perf c2c工具可以帮你定位这类缓存行冲突。5.3 调试与排查无锁代码的正确姿势无锁代码出了 bug常规的“打断点-看变量”方式基本没用。因为问题往往发生在极短的时序窗口里等你暂停所有线程再去看状态现场早就变了。我总结的排查顺序是先静态审查内存序和 CAS 逻辑再上动态检测工具最后做长时间压测。工具层面C 可以用 ThreadSanitizerTSAN它对数据竞争非常敏感能在测试阶段就抓到大多数写后读、读后写的问题。Java 可以结合 JFR 和jstack观察线程状态。perf 可以看上下文切换、缓存失效和锁等待指标。如果无锁队列处理的是网络消息可以给消息加序号在消费端校验序号是否连续一出现乱序就能快速缩小范围。另外无锁代码做单元测试时要刻意构造并发压力多个线程同时执行相同操作循环上万次加入随机 sleep 来放大竞态窗口。很多问题在低并发下是测不出来的只有高并发长时间稳定运行才能暴露。5.4 压测无锁队列的注意事项压测的时候也容易踩坑。比如用 jmeter 给接口压测很多人会开十个线程发十个参数完全一样的 POST 请求。如果接口内部有缓存这会测出缓存命中率而不是系统真实的并发能力。正确做法是让每个线程使用不同参数模拟真实业务里的参数分布。jmeter 的 CSV 参数化或“变量线程号”拼接都能实现。压测无锁队列时还有两个关键点一是要把压测时长拉长至少跑十几分钟以上因为无锁问题往往是间歇性的短时间压测测不出来二是要同时监控 CPU 利用率和上下文切换次数如果线程数提升很快但 CPU 利用率不涨说明线程大部分时间在等待而不是在计算。对比有锁和无锁版本时最好在同一台机器上、用同一套参数交替跑避免硬件波动干扰结论。最后再分享一点我的个人体会无锁编程不是用来炫技的它是一把手术刀只在锁竞争确实成为瓶颈时才值得动刀。我在实际项目里保留了一个有锁版本用于低峰期高峰期再切换无锁方案通过配置开关控制两种模式既保证稳定又能在关键时刻释放性能。爬珠穆朗玛峰不需要爬所有山都用这套装备但当你真的遇到那座山时提前熟悉这些工具和陷阱会救你一命。
返回列表