
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、什么时候 bhspinlock_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 与 rwsem3.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 当互斥锁也就是 count1 的用法后来这种用法被淘汰新代码都改 mutex 了。那 semaphore 还有了解价值吗当然。它仍然是内核锁家族里的成员很多老驱动和特定子系统还在用另外down_interruptible这套可以被信号打断的等待机制非常适合需要长时间等待外部事件的路径。还有一个经常被问到的点semaphore 没有 owner 概念任何一个线程都能 up 它。代码可读性和安全性都不如 mutex。所以我的建议是除非明确需要一个“计数资源池”否则新代码直接绕过它不纠结。3.3 rwsem读写锁的睡眠版本rwsem 把读写锁语义搬到可睡眠的锁上多个读者可以同时持有写者要等所有读者退出才能进入锁被占用时执行流可以睡。它主要用在持锁时间比较长、又不能忙等的内核路径。最典型的例子是 mm 结构体里的 mmap_lock它保护整个进程的地址空间页表操作持锁时间可能很长绝不能忙等。rwsem 早期有个知名问题读者多的时候写者可能一直等不到锁造成写者饥饿。后来内核在实现里加入了乐观自旋和保护写者的机制情况好转不少。但它和 rwlock 一样读者并发极高时仍然存在锁变量 cacheline 争用问题。选它而不是 rwlock核心就一条场景必须睡眠且持锁时间长比如要遍历大量页表、等待用户态内存操作完成这种场景自旋是扛不住的。比较项rwlock_trwsem等待方式忙等不睡眠睡眠可用上下文中断/软中断/进程上下文仅进程上下文典型持有时间纳秒~微秒微秒~毫秒读者并发竞争激烈时受 cacheline 限制同样受 cacheline 限制适用场景嵌入式短路径、简单读写保护内存管理、文件系统等长持锁路径4. 读多写少的终极打法seqlock 和 RCU4.1 seqlock用“可能重读”换取写者永远不被阻塞顺序锁 seqlock 的机制值得仔细品味它不为读者加锁只有一个递增的序号。读者进入临界区前记录一下序号读完之后再读一次序号如果变了说明写者来过读者就重新读一遍。写者的行为是进入临界区时把序号推进一位退出时再推一位中间如果有读者读就能通过序号变化发现“自己被写打断了”。这个机制意味着写者永远不会因为读者而等待代价是读者可能读到一半的数据、重试一遍。这个特性决定了 seqlock 适合“数据量小、更新频繁、读者多、写者对延迟敏感”的场景。最经典的例子是 jiffies_64 这种全局时间戳——几乎所有内核路径都要读当前时间而时间又在不断更新。用 seqlock 包住读者重试概率极低写者又完全不会被阻塞。用 seqlock 最容易踩的坑是读者临界区里不能有副作用。你无法预知这次读会不会被判定为无效重试如果在读临界区里偷偷改了某个统计变量它可能被执行两次甚至多次。另外如果写者非常频繁读者可能陷入长期重试最坏情况下演变成活锁。所以写者高频、读者要求稳定低延迟的场景不适合 seqlock。4.2 RCU 的实现思路指针替换、宽限期与延迟回收RCURead-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 副本时有一组很方便的 APIthis_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 教做人。