1. 先从一场“锁的灾难”说起:并发到底在保护什么
前几年我调一个多队列网卡驱动的性能,把保护描述符表的普通自旋锁换成读写锁,本来想着读者多、写者少,用读写锁应该更友好。结果 8 核一压测,吞吐量反而掉了一半,perf top 里清一色是_raw_read_lock在自旋。那次踩坑之后我才彻底想明白:Linux 内核里的这些锁,不是背一背 API 就能随便用的,每一把锁背后都对应着一套对应用场景的假设。这篇文章就把内核里最常见的 8 种并发原语一次讲透——原子操作、自旋锁、读写自旋锁、顺序锁、互斥锁、信号量、读写信号量、RCU——以及它们各自到底该在什么场景下手。
1.1 多核、中断、抢占:三个并发源
要聊选锁,得先搞清楚并发到底是从哪里来的。Linux 内核跑在服务器、嵌入式设备、网关、手机这些环境里,一个共享数据结构同时被多个执行流访问几乎是常态。并发源可以归纳成三类。
第一是 SMP 多核。两个 CPU 同时在执行内核代码,可能两核在同一时刻调进同一个驱动函数,也可能两个线程同时遍历同一个全局链表。第二是中断,包括硬中断、软中断、tasklet。中断可以随时打断正在运行的进程上下文,如果进程持锁到一半,中断里也来抢这把锁,顺序没控制好,系统直接死给你看。第三是抢占。内核开启 CONFIG_PREEMPT 之后,进程在内核态执行临界区时也可能被更高优先级的任务抢走 CPU。
这三个源叠加在一起,就构成了“竞态”。竞态听起来很抽象,其实就是“一段访问共享数据的代码,同时进入了多个执行流”。所谓加锁,本质上就是把共享数据结构的访问包起来,让同一时刻只有一个执行流进入,或者保证多个进入者之间按约定互不影响。注意,它不是把出错的概率降低,而是要把出错的概率变成零。任何“我这个场景概率小所以不锁”的想法,在内核里都是定时炸弹。
1.2 内核锁的核心指标:临界区长度与能否睡眠
选锁之前先看两个硬指标:临界区有多长、执行流能不能睡眠。
临界区短,比如只是几条 CPU 指令、几十纳秒到几百纳秒,用忙等的自旋锁很合理。临界区长,比如要遍历一个磁盘请求队列、要等待 I/O 完成,这时候还让 CPU 在原地空转就太浪费了,应该让线程睡下去,于是选 mutex 或信号量。能不能睡眠是一票否决项:硬中断上下文里绝对不能用睡眠锁,中断处理程序本身也没有进程上下文可以去睡,一用就是 oops 或者直接崩溃。
第三个指标是访问模式。这个数据是读多写少,还是读写一样多?写者能不能接受读者太多导致的饥饿?数据能不能整体替换而不是原地做字段修改?这几个问题直接决定是不是该上读写类的锁、顺序锁或者 RCU。还有一个很多新手容易忽视的指标:对 CPU 缓存的影响。多核上所有执行流争同一个锁变量,哪怕只是读锁,也会让锁变量的 cacheline 在各 CPU 之间被反复搬运,性能损耗比你想象的严重得多。
1.3 为什么锁不是越多越好:BKL 与 lockdep 的启示
很多刚开始学内核并发的人会想:并发这么复杂,那把锁加细一点、多上几把锁,是不是就安全了?早期 Linux 内核其实走过完全相反的路。很久以前,内核有一把大内核锁 BKL,一把锁保护几乎所有的核心路径。代码确实好写,多核扩展性却极差。2.6 时代开始大规模拆锁,把大锁细化为各个子系统自己的锁。
但拆锁不是把锁数量变多就完事。锁多了,新的问题跟着来:锁与锁之间出现顺序关系,两个执行流各自拿到一把锁,再去拿对方手里的锁,就可能形成循环等待,这就是典型的死锁路径。所以要养成一个习惯:开 lockdep。内核开启 CONFIG_PROVE_LOCKING 后,它会维护一张已知的加锁顺序图,一旦检测到可能的环路,立刻打印出 “possible circular locking dependency detected”,把整个加锁链全扒出来。我在实际开发里的习惯是,凡是改动到内核锁相关代码,提交前至少跑一遍带 lockdep 的压力测试,否则死锁经常要等客户现场才爆出来,那种排查成本实在太高。
那为什么内核刚好是这 8 种并发原语、而不是一把锁包打天下?答案已经很清楚:不同锁是对临界区长度、睡眠能力、读写比例、缓存行为、写者优先级这些因素的不同妥协。接下来从最轻的开始逐个拆。
2. 短临界区三件套:原子操作、自旋锁与读写自旋锁
2.1 原子操作:最轻的特权操作
原子操作严格说不是锁,但做并发选型时,第一个要看的总是它。原子操作把“读-改-写”这一段逻辑变成硬件保证的单条指令或指令序列,中途不会被其他执行流打断,也不需要锁变量。
内核里最常见的用法是引用计数。比如一个网络包 skb,多个 CPU 可能同时在引用它,释放时必须保证只有当最后一个引用被减掉时才真正 free。代码就是一句atomic_dec_and_test(&skb->users),判断返回值,是真才释放。类似的还有 page 的 _refcount、inode 引用计数,以及 mutex 和信号量内部的状态切换——它们是各种锁的底层积木。
除了计数,还有atomic_cmpxchg、atomic_fetch_add这类原语,能用来做无锁的栈、队列、甚至无锁感知。这里要提醒一下:原子操作并不是零成本,多核上它仍然要锁定缓存行甚至锁总线,竞争激烈时一样会性能崩坏。所以它只适用于“临界区就是一条指令”的场景,千万别拿它去保护一大段业务逻辑。真到需要保护一段逻辑的时候,老老实实选锁。
2.2 自旋锁与中断上下文:什么时候 irqsave、什么时候 bh
spinlock_t 是内核里最常用的短临界区锁。它忙等,拿不到锁时 CPU 一直在原地转圈检查锁变量;它不可睡眠,所以能用在中断上下文;它持锁时间必须控制在几百纳秒到几微米量级,再长,等待的 CPU 就在白烧电。
用自旋锁最重要的一条经验是:进程上下文和中断上下文同时访问同一份共享数据时,用spin_lock_irqsave/spin_unlock_irqrestore。为什么要关中断?想象一下:进程 A 在普通上下文拿到一把锁,处理到一半来了硬中断,中断处理程序也去拿同一把锁。进程 A 要等中断返回后才有机会继续执行、释放锁,而中断处理程序永远等不到锁——整个系统直接挂死。先关中断再拿锁,就是从根上切断这个死锁闭环。
如果共享数据只在软中断/下半部上下文和进程上下文之间共享,那用spin_lock_bh/spin_unlock_bh,它临时关掉软中断。如果纯粹在内核线程之间共享,普通spin_lock就够。我实操时的最实用建议是:拿不准当前路径是否会被中断打断时,优先用_irqsave版本。多存一个 flags 的开销可以忽略,换来的是不用背着一把锁去推断调用路径到底会不会开中断。
还有个现代内核特有的细节:开启 CONFIG_PREEMPT_RT 后,普通spin_lock会被改成可睡眠实现,而raw_spin_lock才是真正不可睡眠的底层锁。驱动代码几乎不该碰 raw 版本,它留给 core 代码使用。面试时如果被问到“自旋锁能不能在中断上下文用”,我会把这段背景补上,单纯回答“能”是不完整的。
2.3 rwlock 的反直觉表现:读者怎么会比写者还贵
rwlock_t 允许多个读者同时进入、写者独占,听起来特别适合读多写少的场景。但先别急着用,这正是我开头提到的坑。
在小规模多核系统上,rwlock 确实能带来吞吐提升。但在核心数较多、读者并发又高的时候,它有一个致命问题:每个读者在进入临界区前都要去修改锁本身的 reader 计数,哪怕只是 +1,也会让锁变量的 cacheline 在所有 CPU 之间来回 bounce。读者越多,争这条锁 cacheline 比争数据本身还严重。实际表现就是:读者不会像想象中那样并行读到飞起,而是全部排队在锁变量上,搞不好比用一个普通自旋锁更慢。
所以我的结论是:别下意识把“读多写少”和 rwlock 划等号。想减少锁语义带来的阻塞时,优先思考两个替代方案:数据能不能按 CPU 拆分,走 per-cpu;读路径是否真的可以完全无锁,走 RCU。rwlock 更适合的场景通常是并发度不高、但确实需要读者并行的中小规模嵌入式系统,这时候它实现简单、代码好写,比一上来就上 RCU 划算得多。
3. 睡不睡是个哲学问题:mutex、semaphore 与 rwsem
3.1 mutex 为什么是现代 Linux 的默认互斥选择
如果临界区动不动几百微秒、甚至要等待 I/O,自旋就不合适了。mutex 会让拿不到锁的线程把自己放进等待队列,睡眠让出 CPU,等持有者释放时再唤醒它。睡眠和唤醒本身有调度成本,所以使用 mutex 的铁律是:持锁时间要够长,长到睡眠被唤醒的成本比白白空转划算。
mutex 的现代实现远不是“睡眠+唤醒”那么简单。它有一个 fastpath,用一条原子 cmpxchg 尝试直接拿锁,拿不到才进入慢路径;慢路径里如果发现持有者正在某个 CPU 上运行且快要释放,还会先乐观自旋一小会儿,避免立刻睡下去。这套设计让它对短临界区和长临界区都有不错的容忍度,所以内核里互斥场景默认选它。
mutex 的语义也比信号量严格得多。它有 owner,释放锁的必须是持有者自己,不能让别的线程代放;它不允许递归加锁,同一个线程连续两次mutex_lock必死锁;它还能和 lockdep 深度配合,锁顺序问题很容易在早期暴露。正因如此,内核文档才明确建议:互斥场景的新代码不要用 semaphore,直接用 mutex。
3.2 semaphore 的计数语义还剩哪里在用
信号量 semaphore 自带一个计数,允许最多 count 个执行流同时进入临界区。它天然表达“限流”语义,比如某个硬件资源最多允许 4 个任务并发访问。早年内核大量用 semaphore 当互斥锁,也就是 count=1 的用法,后来这种用法被淘汰,新代码都改 mutex 了。
那 semaphore 还有了解价值吗?当然。它仍然是内核锁家族里的成员,很多老驱动和特定子系统还在用;另外down_interruptible这套可以被信号打断的等待机制,非常适合需要长时间等待外部事件的路径。还有一个经常被问到的点:semaphore 没有 owner 概念,任何一个线程都能 up 它。代码可读性和安全性都不如 mutex。所以我的建议是,除非明确需要一个“计数资源池”,否则新代码直接绕过它,不纠结。
3.3 rwsem:读写锁的睡眠版本
rwsem 把读写锁语义搬到可睡眠的锁上:多个读者可以同时持有,写者要等所有读者退出才能进入;锁被占用时,执行流可以睡。它主要用在持锁时间比较长、又不能忙等的内核路径。最典型的例子是 mm 结构体里的 mmap_lock,它保护整个进程的地址空间页表操作,持锁时间可能很长,绝不能忙等。
rwsem 早期有个知名问题:读者多的时候写者可能一直等不到锁,造成写者饥饿。后来内核在实现里加入了乐观自旋和保护写者的机制,情况好转不少。但它和 rwlock 一样,读者并发极高时仍然存在锁变量 cacheline 争用问题。选它而不是 rwlock,核心就一条:场景必须睡眠且持锁时间长,比如要遍历大量页表、等待用户态内存操作完成,这种场景自旋是扛不住的。
| 比较项 | rwlock_t | rwsem |
|---|---|---|
| 等待方式 | 忙等,不睡眠 | 睡眠 |
| 可用上下文 | 中断/软中断/进程上下文 | 仅进程上下文 |
| 典型持有时间 | 纳秒~微秒 | 微秒~毫秒 |
| 读者并发 | 竞争激烈时受 cacheline 限制 | 同样受 cacheline 限制 |
| 适用场景 | 嵌入式短路径、简单读写保护 | 内存管理、文件系统等长持锁路径 |
4. 读多写少的终极打法:seqlock 和 RCU
4.1 seqlock:用“可能重读”换取写者永远不被阻塞
顺序锁 seqlock 的机制值得仔细品味:它不为读者加锁,只有一个递增的序号。读者进入临界区前记录一下序号,读完之后再读一次序号,如果变了,说明写者来过,读者就重新读一遍。写者的行为是进入临界区时把序号推进一位,退出时再推一位,中间如果有读者读,就能通过序号变化发现“自己被写打断了”。
这个机制意味着:写者永远不会因为读者而等待,代价是读者可能读到一半的数据、重试一遍。这个特性决定了 seqlock 适合“数据量小、更新频繁、读者多、写者对延迟敏感”的场景。最经典的例子是 jiffies_64 这种全局时间戳——几乎所有内核路径都要读当前时间,而时间又在不断更新。用 seqlock 包住,读者重试概率极低,写者又完全不会被阻塞。
用 seqlock 最容易踩的坑是:读者临界区里不能有副作用。你无法预知这次读会不会被判定为无效重试,如果在读临界区里偷偷改了某个统计变量,它可能被执行两次甚至多次。另外,如果写者非常频繁,读者可能陷入长期重试,最坏情况下演变成活锁。所以写者高频、读者要求稳定低延迟的场景,不适合 seqlock。
4.2 RCU 的实现思路:指针替换、宽限期与延迟回收
RCU(Read-Copy-Update)是读多写少场景的终极方案,也是这 8 种原语里理解门槛最高的一个。它的核心思路可以压缩成一个动作:写者不修改旧对象,而是构造一个新对象,然后把共享指针原子地替换成新对象。旧对象呢,一直留到所有读者都确认不再引用它之后,再释放。
读者侧几乎零开销:rcu_read_lock在经典实现里通常只做禁抢占,rcu_dereference配合适当的屏障读指针。这意味着读路径没有原子指令、没有 cacheline 争用,性能接近裸读。写者侧要麻烦一些:先用rcu_assign_pointer发布新指针,然后调用synchronize_rcu同步等待宽限期结束,或者用call_rcu让内核在宽限期结束后异步执行回调。所谓宽限期,就是保证每个 CPU 都经历过一次 quiescent state——比如发生过一次用户态切换、进入 idle、或显式退出 RCU 读侧临界区——此时才能真正确认:没有读者还握着旧指针了。
RCU 也因此对“对象生命周期”有很严格的要求。最常见的实践是保护链表和哈希表节点:查询时rcu_read_lock包住遍历,删除节点时把节点从链表摘下来,通过call_rcu延迟释放。现代内核还衍生出 RCU-bh、SRCU、Tasks RCU 等一堆变种,分别适配软中断、可睡眠读者等特殊场景。新手不需要一上来全掌握,把经典 RCU 的思路吃透,大部分驱动场景就够用了。
4.3 RCU 的边界:哪些场景千万别硬上
RCU 是读多写少的答案,但它的边界条件很多,不满足任何一个都别硬上。
数据必须能整体替换。如果共享数据结构由多个字段组成,写者只想改其中一个字段,RCU 就帮不上忙——你没法让读者一边读旧字段、一边读新字段,又期望数据是一致的。这时要么把整个结构体复制一份再做指针替换,要么退回 seqlock 或 rwsem。
读者临界区不能睡眠,这是经典 RCU 的红线。如果在rcu_read_lock保护范围里调用可能睡眠的函数,比如kmalloc的 GFP_KERNEL 变体或者某些 wait_event,内核会发出警告,行为也不可预期。
宽限期时间不可控。synchronize_rcu等待时间可能短则几毫秒,长则几十毫秒甚至更久,取决于每个 CPU 是否快速经过 quiescent state。实时性要求极高的路径用它会出问题。
回调函数不能做重活。call_rcu的回调是在软中断上下文执行的,你在回调里做磁盘 I/O、睡眠等待,一做一个不吱声。
我在项目里见过最典型的 RCU 误用,是有人想保护一个大结构体里的多个字段,但没有整体替换,只给其中几个指针加了rcu_dereference,结果读者能看到“字段 A 是新的、字段 B 还是旧的”这种半新半旧状态,业务逻辑完全乱掉。这类 bug 最难排查,因为它不是必现,而是概率出现。
5. 第8种“锁”:per-cpu 数据与本地锁
5.1 让每个 CPU 拿自己那份数据
如果数据天然能按 CPU 拆分,那根本不需要锁。per-cpu 变量的思路就在这:每个 CPU 维护一份自己的副本,线程只需要访问自己所在 CPU 的那一份,完全不需要跨 CPU 同步。这么做的最大收益,是从根上消除了 cacheline bouncing,也消除了多核争用带来的性能损耗。
内核里大量使用 per-cpu 变量保存统计计数,比如网络协议栈的包计数、调度器的运行队列统计、各种 slab 缓存。访问本 CPU 副本时有一组很方便的 API:this_cpu_inc()、this_cpu_add()、get_cpu_ptr()等。它们保证在当前 CPU 上的访问是原子的,并且会处理抢占状态。
那它为什么能算作第 8 种“锁”?因为它是一种用空间换时间、用“不共享”解决“共享冲突”的并发策略。在选型表里,它和真正的锁站在同一个位置:遇到一个数据结构被多个 CPU 高频改写的场景,per-cpu 往往是优先级最高的解法,优于任何锁。
5.2 local_lock 与 preempt_disable 的取舍
但要小心,per-cpu 变量被多级上下文访问时,依然需要本地同步。假设你在一个 per-cpu 变量上做“检查再修改”的复合操作,中间被抢占切换走了,另一个执行流又对这个变量做了操作,那数据照样被破坏。经典的解决方式是preempt_disable(),把这段代码包起来,保证这段时间内调度器不会切换出去。
后来内核为了可读性和 lockdep 检查,提供了 local_lock(本地锁)。它不是全局锁,每个 CPU 有自己的锁实例,只约定“保护本 CPU 的数据”。用法上接近一把只有本 CPU 能拿到的锁,但它本质上就是关闭抢占的一种显式表达。
我个人的经验是:能明确表示“这段临界区只访问本 CPU 数据”的时候,用 local_lock 比手动preempt_disable要好,因为 lockdep 能帮你在后续代码改动时发现跨 CPU 误用;而在极短、极高频、性能第一的路径里,直接this_cpu_inc加注释,往往是更实际的选择。没有绝对的最好,只有当前场景最合适。
5.3 访问别人家的 percpu:同步问题
真正容易出 bug 的是跨 CPU 读 per-cpu 数据。per_cpu_ptr(ptr, cpu)可以拿到任意 CPU 的副本,但对方 CPU 可能正在并发修改它,这时读到的数据就是不确定的。要安全地做全局汇总,一种常见方式是通过 IPI 让目标 CPU 进入静默状态,另一种是配合 seqlock 或 RCU 保护整个遍历过程。
举一个我处理过的真实场景:每隔几秒要汇总所有 CPU 的包计数。如果某个 CPU 正被热插拔移除,它的 per-cpu 变量可能已经不存在,直接访问就是 use-after-free。所以正规写法里,遍历 per-cpu 之前要注册 CPU 热插拔通知,或者持有对应的同步机制。这部分的坑我踩过不止一次:在虚拟机上做 CPU 热插拔压测,几十次之后才崩溃,最后通过日志定位到是热插拔期间的跨 CPU 访问没加保护。所以别以为用了 per-cpu 就可以把同步思路丢掉,同步只是换了个地方、换了个形式。
6. 实战选型:一把锁对应一种场景
6.1 选锁决策:先回答四个问题
把上面这些原语放一起,选型逻辑其实可以收敛成一张决策流程。我接到新需求,习惯先问自己四个问题:
- 临界区到底多短?只有几条指令——先想原子操作;几十到几百纳秒——自旋锁;几微秒以上、可能要等 I/O 或调度——睡眠派锁。
- 这个执行流能不能睡眠?中断上下文、NMI、严格原子上下文——只能用原子和自旋锁这组;进程上下文且持锁时间长——mutex 或信号量。
- 读写模式是什么样?读远多于写、数据能整体换成指针——RCU 优先;数据必须原地改但写者不想等读者——seqlock;能接受睡眠的读写锁语义——rwsem。
- 数据能不能按 CPU 拆开?能拆,拆开就没人抢了——per-cpu 优先。
这张图是我给团队写代码时的门槛。四个问题过完,锁基本定下来,剩下的都是实现细节。如果四个问题问完还是犹豫,那多半是需求本身对并发模式理解不到位,先把数据流画清楚再选锁,不要凭感觉照搬之前的方案。
6.2 三个真实案例:驱动并发、内核统计、中断与进程共享
第一个案例是字符设备驱动里的环形缓冲。用户进程通过 ioctl 读数据,网卡中断不断往环形缓冲写。进程上下文的读端要等数据,所以用 mutex 加等待队列很合适:读线程拿不到数据就睡下,中断里写入数据后唤醒。而中断到环形缓冲的写保护,则用spin_lock_irqsave保护,绝不能在中端里碰同一个 mutex。换句话说,一个需求里往往不止一把锁,而是要分清不同锁的使用边界,该睡的地方睡,该自旋的地方自旋。
第二个案例是网关里的业务跟踪哈希表。读请求每秒几十万次,插入和删除可能每分钟才几次,节点又支持整体释放。这是教科书级的 RCU 场景。改完之后,读路径从read_lock变成rcu_read_lock加rcu_dereference,压测性能提升接近一倍,赢在读者不再反复踩同一个锁变量的 cacheline。
第三个案例是设备上的包统计计数。每收到一个包,CPU 都要把收到字节数累加一次,原来用一把全局自旋锁保护,高流量下锁竞争非常明显。后来改成 per-cpu 变量,每个包只做一次this_cpu_inc,完全无锁;定期汇总统计时遍历所有 CPU,并配合 CPU 热插拔回调做防护。这个改动几乎零开销,比在全局锁上继续调参数要本质得多。
6.3 排查锁问题:lockdep、ftrace 与 perf 锁统计
最后聊一聊锁用错之后怎么排查。第一道防线是调试选项。CONFIG_PROVE_LOCKING、CONFIG_DEBUG_ATOMIC_SLEEP、CONFIG_DEBUG_SPINLOCK 这几个开关在开发阶段必须开,它们有性能开销,但能换回非常准确的现场信息。只要代码路径产生锁顺序错误、原子上下文睡眠、递归加锁等状况,内核会立刻在日志里打印调用栈,定位精确到具体行号。
lockdep 爆死锁时,日志通常长这样:
====================================================== WARNING: possible circular locking dependency detected 5.15.0-xxx #1 Tainted: G OE ------------------------------------------------------看到 “possible circular locking dependency detected” 这行字,第一步不是急着改代码,而是把完整日志里的锁链保存下来,找出两个执行流各自的加锁序列。然后在代码里约定一个全局统一的加锁顺序,所有路径都按同一个顺序拿锁,循环等待的闭环自然就断了。
ftrace 和 perf 适合回答“锁竞争到底多严重”这个问题。用trace-cmd record -e 'lock:*'记录锁事件,或者用perf lock record抓一段运行数据,再用perf lock report输出锁等待排行,就能定位到哪把锁是热点。如果一把锁占掉 30% 的 CPU,那先别想着优化这把锁的实现,退一步想想访问模式本身是不是该换个原语。热点锁往往意味着防护级别和实际访问模式不匹配,换锁或者换并发策略,才是治本。
从第一次被 rwlock 打脸到现在,我慢慢总结出一个原则:内核里没有“最好的锁”,只有“最不容易出错的锁”。选锁之前先把并发源、临界区长度、睡眠边界、读写模式、缓存影响这几个变量捋清楚,剩下的代码只是照着结论写而已。这 8 种并发原语,每种都清楚适用边界,遇到新场景自然知道该拿哪一把;只背 API 不记边界,迟早会在某个 8 核压测的下午被 perf 教做人。