1. 为什么我劝你先别急着上锁:无锁编程到底解决什么问题
凡是写过两年以上并发代码的人,迟早会碰见这样一个场景:某个热点变量被几十个线程疯狂读写,你用 std::mutex 保护它是没错,但性能就是上不去。你用 perf 一看,光等锁就烧掉了 40% 的 CPU 时间片。这时候你大概率会开始琢磨无锁编程——也就是标题里那个听着很酷、用起来很烫手的领域。
先说清楚无锁编程(lock-free programming)到底在解决什么问题。传统加锁方案的核心矛盾是,锁把“保护数据”和“阻塞线程”绑在了一起。一旦线程在临界区里被切换出去,或者发生锁竞争,其他线程就得进入睡眠-唤醒的调度流程,这里面的开销包括系统调用、上下文切换、缓存失效,一套走下来几十微秒就没了。而现代 CPU 一个 CAS 指令(Compare-And-Swap)也就是几个纳秒的事。所以无锁编程的核心思路就一句话:用原子操作代替锁来协调共享数据的访问,把“可能阻塞的等待”变成“不会阻塞的重试”。
但这里必须泼一盆冷水。无锁编程不是银弹,它解决的是“锁竞争激烈、临界区极短”这个特定场景。如果你的临界区本来就很长,比如里面要做磁盘 IO 或者复杂的计算,无锁方案并不会让你起飞,反而会让代码复杂度成倍上升。我见过不少团队把简单的生产消费队列强行改成无锁版本,结果线上 Bug 一个接一个,最后又灰溜溜改回带锁。真正适合无锁的场景通常是:共享计数器、指针发布、高频状态标志、简单数据结构的并发读写,一句话总结就是“操作极短、频率极高、竞争不可控”。
另一个需要提前建立的认知是,无锁意味着用“自旋重试”代替“阻塞等待”。多线程同时抢一个资源时,赢家直接改完走人,输家立刻循环重试,而不是被内核挂起。这套机制在高竞争下可能造成 CPU 空转,但在低竞争或中等竞争下,比起锁的上下文切换开销,无锁通常能赢得非常明显。
这篇文章会围绕“无锁编程与原子操作”尽量讲透两层东西。第一层是语言层面,std::atomic 的接口、内存序(memory order)的语义,以及不同内存序在编译器层面和 CPU 层面的真实代价。第二层是工程层面,怎么从零搭建一个可用的无锁栈,会遇到哪些坑(ABA、内存回收、假共享),以及我实测下来的一些结论。
顺带回答一下搜索热词里的那个问题:C++11 的内存序是专门为原子操作准备的吗? 结论是不完全是。内存序是内存模型(memory model)的一部分,它通过原子操作这把钥匙暴露给程序员,但它约束的对象并不仅仅是原子操作本身,还包括原子操作周围的普通内存访问。后面我会专门用一整章来讲清楚这个关系,这里先不剧透太多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 锁的代价到底有多大:从“不进临界区的等待”说起
2.1 一次互斥锁等待到底慢在哪里
很多人对锁的性能问题没有体感,觉得 std::mutex 的 lock 不就是几条指令吗?实际远没有那么简单。当一个线程拿不到锁时,在 Linux 上,pthread_mutex_lock 会先尝试几次自旋(取决于 glibc 的配置),失败后调用 futex 系统调用进入睡眠,这个路径包含了用户态到内核态的切换、线程状态变更、红黑树操作、调度器重新分配 CPU,等到持锁线程 unlock 时还得再次唤醒等待的线程。一来一回两个完整的上下文切换,在现在的硬件上大概需要 2 到 5 微秒。
如果锁竞争率为 30%,每个事务只持锁 100 纳秒,那么平均每次操作可能摊上几百纳秒的调度开销,这个数字对于高频交易、游戏服务器、数据库引擎来说已经非常刺眼了。更阴间的是锁竞争带来的优先级反转和死锁风险,这类问题不会稳定复现,一旦出现在生产环境就是灾难。
再往底层说,锁的语义天然要求“独占”和“互斥”,这会让 CPU 的缓存行失效。线程 A 修改了共享变量并释放锁,线程 B 拿到锁后去读变量,发现缓存行已经被标记为 invalid,只能重新从内存或者通过缓存一致性协议拉取。这个代价叫 cache miss,一次主内存访问大约是几十纳秒。锁加上缓存失效再加上上下文切换,就是对性能的“三连击”。
2.2 原子操作为什么能绕过锁
原子操作的底层不是“不锁”,而是把锁的粒度压缩到了硬件指令级别。在 x86 上,std::atomic 的很多操作会落到 lock cmpxchg、lock xadd 这样的指令,lock 前缀会锁住系统总线或缓存行,保证指令在执行期间不会被其他核打断。在 ARM 上则是用 LL/SC(Load-Linked / Store-Conditional)指令对来实现。
这类指令不会让线程睡眠,也不会触发系统调用,执行完要么成功要么失败,失败了你就在用户态立刻重试。所以从效果上看,一个原子操作替代的是“加锁-读-改-写-解锁”整条路径,而代价只有一条指令或一个短循环。
2.3 无锁编程适用场景自检清单
在决定要不要上无锁方案之前,我建议你先过一遍这个清单:
- 临界区代码是否只有几条指令?如果临界区里有堆分配、加锁嵌套、系统调用,无锁很难有优势。
- 共享数据的并发读多写少还是写多?读多写少可以用原子读代替整把锁;写多的情况要仔细衡量 CAS 竞争。
- 能不能接受代码复杂度上升?无锁代码的调试难度远高于带锁代码,团队里至少要有一两个能 hold 住的人。
- 有没有现成的无锁库可以复用?比如
moodycamel::ConcurrentQueue、boost::lockfree,先看库再看自己造轮子。
从我的实践来看,无锁最舒服的使用方式不是“全套无锁”,而是“把锁粒度缩小到原子级别”。比如以前你用 mutex 保护一个计数器,现在改成 std::atomic<int> 直接自增;以前你用条件变量等一个状态翻转,现在改成 std::atomic<bool> 配合自旋等待。这种微改造又稳又见效,比一上来就手写无锁队列靠谱得多。
3. 原子操作:从硬件指令到 C++ 接口的完整映射
3.1 一条 CAS 指令到底做了什么
先看 C++ 里最常用的 CAS 接口,它的签名长这样:
cpp复制bool compare_exchange_weak(T& expected, T desired,
std::memory_order success,
std::memory_order failure);
语义是:比较原子变量的当前值是否等于 expected,如果相等,就把它替换成 desired,返回 true;如果不相等,把 expected 更新成当前值,返回 false。这个“比较-交换”的过程在硬件层面是一个不可分割的序列。
在 x86 上,`comp
