1. 并发竞争的本质:为什么锁会成为性能分水岭
先聊一个几乎所有后端开发者都会遇到的场景。你有一个计数器,多个线程同时往里加一,如果不上锁,最终结果大概率不是你想要的那个数。我第一次在真实项目里抓到这种bug时,计数器少了将近两万次,排查了整整一个下午,最后加了一把锁,问题立刻消失。但紧接着另一个问题浮出水面——加锁之后,吞吐量直接腰斩。从那天起我就明白,锁不是一个简单的"加上就行"的东西,选错锁、用错场景,代价一样惨重。
要真正理解并发控制里的锁,得先搞清楚一件事:多线程为什么会有竞争。现代CPU执行指令时,一个线程在某个时刻只能跑在一个核心上,但它操作的内存是共享的。假如线程A读到count=10,线程B也读到count=10,然后A把它改成11写回去,B也把它改成11写回去,最终结果就是11,而不是12。这中间丢掉的更新,就是经典的"读-改-写"竞态。
解决这个竞态最直接的手段,就是让"读-改-写"这一小段操作变成原子操作,不可分割。硬件层面,CPU提供了xchg、cmpxchg这类指令,保证一个指令周期内的操作不会被其他核心打断。但真实业务逻辑往往不止一条指令,而是一段代码——比如"检查余额、扣款、写日志"这个流程。这时候就需要在代码层面划定一个临界区,同一时间只允许一个线程进入。而这个"只允许一个线程进入"的机制,就是锁。
锁的核心价值就一句话:把并发的无序竞争,变成有秩序的排队。但排队也是有代价的。排队的线程怎么等?是原地打转干等着,还是先去睡一觉等通知?这两种策略正是自旋锁和互斥锁的根本分歧所在。而futex这个机制,则是把两者巧妙地结合起来,让锁既能快速响应,又不至于白白消耗CPU。
这套体系里没有绝对的好坏。自旋锁快,但怕长临界区;互斥锁省CPU,但切换开销高。真正的难点在于,知道它们各自的脾气,然后在不同的竞争强度、不同的临界区长度下,做出合理的选择。下面挨个拆开讲,包括底层实现和我在实际项目中踩过的坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 自旋锁:拿不到锁就原地转圈,到底图什么
自旋锁的设计哲学特别朴素:你进不来临界区,那你就在门口转圈等,边等边不停地问"锁释放了没"。一旦持有锁的线程释放,等待的线程立刻就能抢进去,中间几乎没有延迟。
从实现角度讲,一个最简单的自旋锁长这样:
c复制#include <stdatomic.h>
typedef struct {
atomic_flag flag;
} spinlock_t;
void spinlock_init(spinlock_t *lock) {
atomic_flag_clear(&lock->flag);
}
void spinlock_lock(spinlock_t *lock) {
// 不断尝试把flag从false置为true
// 如果返回false,说明之前是false,抢锁成功
// 如果返回true,说明之前已经是true,继续循环
while (atomic_flag_test_and_set_explicit(&lock->flag, memory_order_acquire)) {
// 空转
}
}
void spinlock_unlock(spinlock_t *lock) {
atomic_flag_clear_explicit(&lock->flag, memory_order_release);
}
atomic_flag_test_and_set是C11标准里专门为自旋锁设计的原子操作,它把"读取旧值"和"写入新值"合并成一个不可分割的硬件指令。在x86平台上,编译出来通常对应lock xchg或lock bts,这些指令本身就是原子的,所以多个核心同时执行也不会乱。
这个实现确实能用,但实际上没人会直接这么写,因为它的空转太"傻"了。每个等待的线程都在疯狂执行原子指令,大量请求会去抢占内存总线的锁,造成缓存一致性流量暴涨。在多核高竞争场景下,这种写法会让系统整体性能下降,甚至抢锁线程反而不如等待线程跑得快。
实际工程里通常会在自旋循环里加一个退避策略,最常见的是pause指令。x86的pause会让CPU将流水线中的指令暂停几个周期,减少对总线带宽的占用。Intel官方文档里说,在自旋循环中使用pause能显著降低功耗和总线竞争。Linux内核的自旋锁实现里也有类似处理。
2.1 什么场景适合自旋锁
自旋锁最适合临界区极短、锁持有时间远小于线程调度开销的场景。比如内核里修改一个链表节点、更新一个计数器、设置一个标志位,这种操作通常只有几十到几百纳秒,用自旋锁非常合适。
为什么这时不能换互斥锁?因为互斥锁的获取和释放涉及到线程状态切换,这个切换是要进内核的,而一次系统调用的开销差不多在微秒级别。如果临界区只需要100纳秒,结果你花1000纳秒去做锁操作,相当于90%的CPU时间都浪费在锁机制本身上了。自旋锁在这种情况下能把开销压缩到几十纳秒,优势是数量级的。
再举个生活中的例子。自旋锁就像排队打饭时人不多、窗口出餐极快的情况,你就站在队伍里等着,几秒钟就轮到了,没必要先找个座位坐下刷半小时手机再回来重新排。互斥锁则是队伍特别长、每个人都要花很长时间点餐的情况,你硬站着等纯粹浪费时间,不如拿个号去旁边坐着,等叫号再过来。
Java社区里有个典型的案例就是ConcurrentHashMap。Java 8的实现在链表长度小于8的时候用CAS自旋处理冲突,超过8才转成Node加锁。因为hash冲突时,链表头部的CAS操作本身耗时极短,用重量级锁反而得不偿失。
2.2 JDK 8之后默认用自旋锁了吗
这个热搜问题我在不少技术群里见过。先说答案:不是。JDK 8之后并没有"默认使用自旋锁"这回事。很多人混淆了几个不同的概念。
synchronized从JDK 6开始引入了锁升级的机制:无锁 → 偏向锁 → 轻量级锁(CAS自旋)→ 重量级锁。在轻量级锁阶段,确实会使用自旋策略,但这个自旋不是无限自旋,而是有次数限制的。JDK 8里,自旋次数默认是10次,超过10次还没拿到锁,就会升级成重量级锁,让线程进入阻塞状态。
所以准确的说法是:JDK 8之后的synchronized会根据竞争情况,在小规模竞争时使用自旋,竞争激烈时升级为重量级锁。这和Linux内核里"自旋锁和互斥锁二选一"的逻辑有所不同,Java是让两种策略动态组合在一条锁的完整生命周期里。
另外还有一个容易混淆的点。JUC包里的原子类(AtomicInteger、AtomicReference这些),底层的CAS操作本质上也属于一种"自旋重试"。AtomicInteger.incrementAndGet内部如果CAS失败就会进循环重试,这可以被理解成一种用户态的自旋。但它不是锁,它没有临界区的概念,只是一个原子操作的重试循环。
所以如果你问JDK 8之后是不是默认用自旋锁,准确回答是:在轻量级锁竞争和原子类CAS层面上有自旋,但在高竞争、长临界区场景下,系统会自动切到重量级锁。Java的这个设计恰恰说明了一个关键点——没有任何一种锁是万能的,组合策略才是出路。
2.3 自旋锁的致命伤
自旋锁最大的问题就是CPU空转。一个线程在自旋等待时,它占用的CPU核心什么正事都没干,就是不停地执行比较指令。如果临界区很长,或者持有锁的线程被操作系统调度出去了(比如发生页错误、被中断),那么等待的线程可能在好几个毫秒里都在空转,CPU资源被白白烧掉。
更麻烦的是优先级反转问题。假设线程A优先级很高,正在自旋等待锁;线程B优先级很低,正持有一把锁。因为B的优先级低,操作系统可能把B从CPU上调度出去,让高优先级的A先跑。结果A在自旋等B释放锁,而B根本没在被执行,锁永远释放不了。经典解决方案是优先级继承,但这大大增加了复杂度。
所以选择自旋锁之前,一定要想清楚三件事:临界区是不是足够短?临界区里有没有可能会阻塞的操作(比如磁盘IO、系统调用、加锁嵌套)?持有锁的线程会不会被调度器打断?如果任何一个答案是"会",请老老实实用互斥锁。
3. 互斥锁:让线程睡一觉,代价与收益如何权衡
互斥锁的哲学和自旋锁完全不同。它讲究"让出CPU"。当线程无法获得锁时,它会把自己从运行状态切换到睡眠状态,主动让出CPU,等持有锁的线程释放锁之后,再由内核唤醒它继续执行。
Linux下经典的互斥锁是pthread_mutex,底层依赖futex机制实现。先看用户态的调用方式:
c复制#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void critical_section() {
pthread_mutex_lock(&mutex);
// 临界区代码
pthread_mutex_unlock(&mutex);
}
这行pthread_mutex_lock看起来简单,背后的流程却很值得抠一抠。在这里,三阶段锁定算是比较清晰的理解框架。
3.1 第一次竞争:用户态的快速路径
pthread_mutex_lock会先尝试一次原子的compare-and-swap操作,看看能不能直接把锁拿到手。如果没人持有这把锁,这个CAS操作会成功,线程直接进入临界区,整个过程不涉及系统调用,耗时只有几十纳秒。
这一层优化极其关键。因为在实际业务中,大多数锁竞争并不激烈,大部分时候锁是空闲的。如果每次上锁都要进内核,哪怕线程没竞争也要付出微秒级的系统调用开销,那锁对性能的影响就太大了。快速路径保证了"无竞争时上锁几乎和原子操作一样快"。
3.2 竞争出现:futex决定你要不要睡
如果CAS失败,说明锁被别人拿走了。这时候pthread_mutex_lock不会立刻把线程塞进睡眠,而是会再尝试几次自旋。这个自旋次数在不同glibc版本里不一样,通常是一个很小的值,比如几十上百次。如果自旋期间锁被释放了,线程就不需要睡眠了,直接抢锁成功。这个优化利用了一个经验规律:大部分临界区非常短,持锁线程很快就会释放,短暂自旋能避免睡眠唤醒的高昂代价。
如果自旋了一段时间还是拿不到锁,线程就会调用futex系统调用进入睡眠。futex(FUTEX_WAIT)会告诉内核:我在等这个地址的值发生变化,如果没变化就让我睡过去。
3.3 唤醒:内核的精准叫醒服务
当持锁线程执行pthread_mutex_unlock释放锁时,会先执行原子操作把锁状态改为未持有,然后调用futex(FUTEX_WAKE),内核会唤醒一个正在等待这把锁的线程。被唤醒的线程从内核态返回用户态,再次尝试获取锁。
这里有个很实际的性能问题:唤醒的延迟。从线程A释放锁,到线程B真正被调度执行,中间涉及系统调用、内核态切换到用户态、调度器重新选择任务,整个过程大约需要5到10微秒。如果临界区本身只有几百纳秒,那么睡眠唤醒的代价就是临界区的几十倍。这就是互斥锁最大的软肋。
但互斥锁换来的好处是:没有拿到锁的线程不占CPU。如果临界区很长,或者持锁线程因为某种原因被卡住很久,等待线程全程处于睡眠状态,不会消耗CPU资源。这种特性让互斥锁成为长临界区、高竞争场景下的安全选择。
3.4 为什么不能只看名字选锁
很多人对互斥锁有个误解,觉得它"重",所以性能差。实际上pthread_mutex的快速路径非常快,无竞争时和原子操作差不多。真正慢的是竞争激烈时睡眠唤醒的路径。所以锁的选型从来不是"自旋好还是互斥好",而是"我的场景里竞争到底有多激烈、临界区到底有多长"。
这里画一个粗略的决策思路:如果临界区只有几十条指令,且持有时间通常在纳秒级,优先考虑自旋;如果临界区有系统调用、文件读写、网络请求,或者持锁时间可能超过10微秒,果断用互斥;如果竞争率极低(绝大多数时候锁是空闲的),两者性能差异其实不大,选简单的那个就行。我自己的经验是,把一个高频短临界区从互斥锁换成自旋锁,单机QPS能从8万提到15万左右,但同样的锁换到有网络IO的临界区上,性能反而会崩。
4. futex:用户态自旋和内核睡眠之间的那座桥
前面提到互斥锁内部会先自旋再睡眠,这个"先自旋再睡眠"的机制就是futex的核心设计。futex的完整拼写是Fast Userspace Mutex,直译过来是"快速用户态互斥体",但它解决的问题远不止互斥锁。
futex的设计初衷是为了避免锁操作每次都进内核。在Linux早期的锁实现里,所有锁操作都要通过系统调用进入内核,哪怕没有竞争也要付出系统调用开销。但绝大多数锁操作其实没有竞争,甚至很多锁大多数时间根本没人抢。于是就有了futex这个混合方案:把"锁是否被持有"这个状态放在用户态共享内存里,线程先快速尝试原子操作。只有真正发生竞争、需要等待时,才通过系统调用通知内核。
futex的接口只有两个核心操作:
4.1 futex_wait:睡眠的触发条件
进程或线程调用futex_wait(addr, expected)时,内核会检查addr地址处的值是否等于expected。如果等于,就把当前线程挂起,直到有人futex_wake这个地址;如果不等于,说明锁状态已经变了,futex_wait直接返回,线程不需要睡眠。
这里的关键是那个expected参数。它的存在让"检查锁状态"和"进入睡眠"这两个操作在用户态和内核态之间形成了一种原子的联动。线程先读取锁状态,确认锁被占用,然后调用futex_wait把读到的值作为expected传给内核。如果在这个间隙里锁被释放了,futex_wake会把锁状态改掉,内核发现addr处的值不等于expected,就不让线程睡,而是直接让它回去重新抢锁。这样就避免了一个经典竞态:线程刚决定睡眠,锁恰好被释放,结果线程睡过去了、没人唤醒。
4.2 futex_wake:精准唤醒还是广播
futex_wake(addr, n)用来唤醒指定地址上正在等待的线程。n=1表示只唤醒一个线程,n=INT_MAX表示唤醒所有等待线程。在互斥锁的实现里,释放锁时通常唤醒一个线程就行;而在条件变量里,pthread_cond_broadcast就会用n=INT_MAX把所有人都叫起来。
有个细节值得注意:futex_wake不保证唤醒的线程立刻就能抢到锁,它只是把线程从睡眠状态变成可运行状态。真正的锁竞争还是要在用户态重新进行。所以从睡眠中被唤醒的线程,还要再走一遍自旋和CAS的流程,才能进入临界区。这个过程也叫"惊群"问题——如果同时唤醒多个线程,但锁最终只能被一个线程拿到,其他线程就白抢了,还要再次睡眠。因此唤醒策略很重要:对于锁来说,唤醒一个就够了。
4.3 一次完整互斥锁竞争的时间线
为了把futex和其他概念串起来,我按时间顺序梳理一个高竞争场景下的完整流程:
- 线程A进入临界区,通过CAS把锁变为"持有"状态,耗时约20纳秒;
- 线程B尝试获取同一把锁,CAS失败,进入自旋;
- 线程B自旋了约10微秒,A还没释放锁,B决定调用
futex_wait; - B的内核进入等待队列,状态变为睡眠,不占CPU;
- 线程A完成临界区操作,CAS把锁改回"空闲"状态,调用
futex_wake(addr, 1); - 内核唤醒队列中的线程B,B从系统调用返回;
- B再次尝试CAS,这次成功抢到锁,进入临界区。
整个过程里,最贵的是第3到第6步,往返内核态带来的延迟在5到10微秒。这也是为什么很多性能敏感的应用会尽量避免锁竞争——不只是锁本身慢,而是竞争导致的上下文切换代价极高。
4.4 futex把锁的性能边界推到哪了
futex的引入让Linux下的锁在无竞争时几乎零成本,在低竞争时通过用户态自旋规避内核切换,只有高竞争时才让线程睡眠。这套设计把锁的性能边界推到了接近理论的极限:无竞争时和原子操作同级,高竞争时又不浪费CPU。包括Java在内的很多跨平台语言,底层在Linux上实现synchronized重量级锁时,也依赖pthread和futex。
所以当你再听到"互斥锁底层是futex"这句话时,不要把它理解成互斥锁就是futex,而应该理解成:互斥锁利用futex实现了"自旋与睡眠的混合策略"。这个混合策略,才是现代锁性能的根基。
5. 工程里的锁选型:实测数据、典型误区和我的处理思路
说了这么多原理,最后聊点实际的。我在后台服务、中间件、嵌入式几个方向都做过并发优化,总结出一套自己的选型和处理思路,不一定适合所有场景,但可以给同样被锁问题困扰的人一个参考。
5.1 一次实测对比
前几年我做过一个内存型KV存储引擎,核心操作是更新一个哈希桶里的链表。临界区极短,只有十几条指令。最初用pthread_mutex实现,单线程压测没问题,但并发到8线程时,吞吐量出现明显瓶颈。因为锁竞争太频繁,8个线程反复在自旋和睡眠之间切换,光系统调用就吃掉了一半CPU。
后来我做了三组对比实验:
| 方案 | 8线程吞吐量 | CPU总占用 | 平均延迟 |
|---|---|---|---|
| pthread_mutex | 420万 ops/s | 780% | 18.2 us |
| 自旋锁(含pause) | 680万 ops/s | 800% | 11.5 us |
| 自适应mutex | 590万 ops/s | 790% | 13.8 us |
自旋锁在这个场景下胜出,因为临界区极短,线程不需要睡眠,自旋等待的时间远小于睡眠唤醒的时间。但CPU占用率并没有下降,因为自旋本质是拿CPU换延迟。如果场景换成临界区里有文件IO,自旋锁性能会直接崩盘,因为等待线程会把CPU烧在无意义的循环上,而IO操作的耗时是纳秒级的临界区无法比拟的。
这个实验也验证了一个道理:锁的性能必须结合临界区耗时和竞争强度来看,脱离场景谈哪种锁更好,都是耍流氓。
5.2 几个常见误区
误区一:自旋锁比互斥锁快。这个说法只在短临界区成立。临界区一长,自旋锁会变成性能灾难。
误区二:Java里synchronized是重量级锁,性能差。实际上现代JVM的synchronized已经经过了非常复杂的优化,无竞争时几乎无开销,低竞争时通过CAS和偏向锁快速处理,只有高竞争才走系统调用。很多时候它比手动用ReentrantLock更合适。
误区三:锁粒度越细越好。粒度细到一定程度后,锁本身的获取开销开始占比变大,性能反而会下降。比如用一个锁保护一个整数,还不如直接用原子变量。
误区四:无锁一定比有锁快。无锁(比如CAS循环)在高竞争下会出现大量重试,CPU资源浪费可能比锁还严重。无锁方案适合低竞争或只更新单一变量的场景,复杂的共享数据结构还是老老实实加锁。
5.3 我在代码里怎么判断该用哪种锁
我通常会按这套顺序来做判断,这也是文章写给工程师的核心价值所在。
第一步,看临界区里有没有可以阻塞的操作。IO、网络请求、动态内存分配加锁重入、函数调用里嵌套其他锁,这些都是危险信号。只要有一个,就可以排除自旋锁,直接考虑互斥锁。自旋锁在阻塞操作面前是灾难:持锁线程被阻塞,等待线程原地空转,CPU白白烧掉。
第二步,评估临界区的实际耗时。可以写个基准测试,跑一百万次临界区操作算平均延迟。如果低于10微秒,自旋锁有优势;如果高于100微秒,直接用互斥锁。中间地带,建议用自适应方案,比如Java的ReentrantLock或者glibc内部对pthread_mutex的默认实现。
第三步,看竞争概率。每100次进入临界区,有多少次会遇到锁被占用?如果不到1%,用什么锁都无所谓,选最简单的。如果超过50%,需要认真优化临界区长度,或者考虑分段锁、读写锁这类更精细的并发控制手段。
最后,选完锁一定要在真实负载下压测。锁的性能曲线往往不是线性的,有时候线程数翻倍,性能反而骤降,这就是锁竞争的临界点。建议用8线程、16线程、32线程分别压一遍,找出瓶颈点再调优。
6. 锁之外的思考:并发控制的下一步是什么
自旋锁、互斥锁、futex这套体系,本质上解决的是"多线程如何安全共享状态"的问题。但锁本身只是手段,不是目的。真正的高手不会只研究怎么选锁,而是会想方设法减少对锁的依赖。
我见过太多代码,明明可以用原子变量解决的问题,非要用Lock;明明可以设计成不可变对象避免共享,非要用全局可变状态;明明可以用CopyOnWrite策略减少锁竞争,非要在每次读操作时加锁。这些设计从根源上就错了,后面再怎么优化锁都是治标不治本。
从更大的视角看,现在并发控制的方向已经越来越多元化。读写锁(rwlock)把读和写分开,适合读多写少的场景;RCU(Read-Copy-Update)在Linux内核里实现了读操作不需要锁的效果;无锁数据结构如ConcurrentHashMap在工程中广泛应用;STO(Software Transactional Memory)则把数据库的事务思想搬到了内存编程里。但不管这些技术怎么演进,自旋和睡眠这对基本矛盾始终存在。futex的精华,就是把这两者动态调和起来;而未来的锁设计,大概率还是会沿用这个思路,只是把自适应的能力做得更智能。
我在实际项目里最深的一个体会是:调试并发问题的难度,通常和锁的使用复杂度成正比。如果你的代码里到处是锁,排查竞态问题会极其痛苦。与其追求复杂的锁方案,不如先追求简单的共享模型。能用不可变对象就用不可变对象,能拆分成独立子任务就别共享状态,这些看起来"不上档次"的做法,在真实工程里往往比任何花哨的锁都可靠。
