1. 竞态条件是什么:先搞清楚你面对的问题
做Linux后台开发和运维的朋友,估计都经历过这种至暗时刻:一个跑了好几个月的服务,突然在凌晨三点崩溃,日志里只有一行空指针或者野指针,重启之后又恢复正常,仿佛什么都没发生过。你盯着core dump反复看,代码逻辑明明是对的,单线程跑一百遍也没事,可一旦上了生产环境、流量一大,它就给你来个措手不及。这时候,你大概率遇到的就是竞态条件。
竞态条件(Race Condition)这个词,听起来像是学术界的概念,但它在Linux内核和用户态程序里实实在在每天都在发生。简单说,就是多个执行流——可能是多核CPU上的多个进程,可能是同一个进程里的多个线程,也可能是内核里的中断处理程序和普通进程——同时访问同一份共享数据,并且至少有一个执行流在“读改写”这段数据的过程中被另一个执行流打断了。结果就是数据被写坏、指针被踩踏、状态变得不伦不类,程序行为完全不可预测。
我用一个最直观的例子帮新手理解。假设你有一个全局变量count,代码里只有一行操作:count++。在C语言里这看起来是一条语句,但编译成汇编之后,它其实是三步:把count从内存加载到寄存器,寄存器加一,把寄存器写回内存。如果两个执行流同时走到这里,A加载了count=5,B也加载了count=5,A加一写回变成6,B也加一写回变成6,两次自增只涨了一。这就是最典型的“读-改-写”竞态,丢更新。
内核场景比这个更残酷。因为内核里不仅有多个CPU核心并行跑进程,还有中断、软中断、任务延迟工作队列这些随时可能插入的异步执行流。你在一个驱动里维护了一个全局链表,进程上下文在list_add往里插节点,同一瞬间网卡中断触发,中断处理程序也往这个链表里插节点。两个执行流同时操作同一个链表头,链表指针就被改得乱七八糟,下一次遍历就是野指针崩溃。这种崩溃往往没有任何规律,压力一大就冒出来,压测时调参也很难复现。
做内核开发、写驱动、搞嵌入式Linux的人,对这类问题尤其敏感。因为内核态没有用户态那些花哨的保护机制,一个野指针直接就是panic或者oops,连抢救的机会都不给你。就算你做的是纯用户态开发,多线程程序里的竞态同样会导致数据错乱、死锁、性能诡异下降。
那解决竞态的手段是什么?答案就是标题里的另一半——内核锁。锁的本质就一句话:让多个执行流在进入临界区之前先“排队”,同一时刻只允许一个执行流操作共享资源,其他执行流要么自旋等待,要么睡眠等待,等持锁者走出临界区再接手。听起来很简单,但锁的种类、锁的粒度、锁的顺序、锁的上下文限制,每一项都有讲究。选错锁轻则性能腰斩,重则直接死锁或者内核崩溃,而这些问题比竞态本身更难排查。
这篇文章我就围绕竞态条件追踪和内核锁这两件事展开。前半部分把内核锁家族的成员讲透,每个锁适合什么场景、不能用在什么场景,结合真实案例说明白。后半部分重点讲追踪手段——怎么从一堆表象里锁定竞态点,怎么把偶现的问题变成必现的分析,以及内核自带的锁调试和竞态检测工具怎么用。内容面向的读者是做Linux服务端开发、嵌入式开发、驱动开发以及运维排查方向的从业者,也适合那些被并发bug折磨过、想系统补一下内核并发知识的朋友。我尽量用干活的语气写,尽量贴近实战,不整教科书那套。
2. 内核锁家族全景:每一把锁都有脾气
内核里用来解决竞态问题的机制,比用户态那几把pthread锁要丰富得多。从最简单的原子操作,到自旋锁、互斥锁、读写锁、RCU、percpu锁,每一类都有它存在的理由,也都有它的使用边界。很多人学内核锁,只记住了API名称和功能描述,到了实际写代码的时候却不知道怎么选——因为不同锁的适用场景是互斥的,选错了后果完全不同。我按照“从轻到重”的顺序,把内核锁家族逐个拆开讲。
2.1 原子操作:最轻量的一层防线
原子操作(Atomic Operation)是内核提供的最基础的并发保护手段。它不算是“锁”,因为它的思路不是排他访问,而是让一条读改写指令在硬件层面保证不可分割。x86上有LOCK前缀的指令,ARM上有LDREX/STREX指令,内核把这些封装成了atomic_t、atomic_long_t这些类型,以及atomic_inc、atomic_add_return、atomic_cmpxchg这一组API。
什么时候用原子操作?当临界区只有一条指令,而且这条指令本身就是读改写操作的时候。比如维护一个统计计数器、分配一个全局ID号、管理一个引用计数,这些场景用原子变量就够了,既不需要禁止抢占,也不需要管中断。atomic_inc(&counter)就保证了这个自增在多核环境下不会丢更新。
但原子操作天生有个限制:它只能保护“单个变量”。如果你的临界区涉及多个变量的一致性——比如既要改链表节点的prev指针,又要改next指针,还要改头节点——原子操作就无能为力了,必须上真正的锁。
2.2 自旋锁:宁可空转也不睡觉
自旋锁(Spinlock)是内核里最常见的锁,也是新手最容易用错的一把锁。它的核心行为是:如果一个执行流拿不到锁,它不会睡眠,而是在原地反复循环检测锁是否被释放,直到拿锁成功。这个“原地循环”的行为就叫自旋。
自旋锁适合什么场景?临界区很短、持锁时间极短、且执行流不能睡眠的上下文。最典型的例子就是中断处理程序。中断上下文中不能调用会睡眠的函数,因为睡眠意味着调度器介入,而中断上下文没有进程上下文让调度器去切换。这时候你想要并发保护,只能用自旋锁。
自旋锁的使用有一堆注意事项,每一条都是用血泪换来的:
- 临界区里绝不能调用
sleep、schedule、mutex_lock这类可能睡眠的函数,否则内核会直接打出一个“scheduling while atomic”的panic。 - 持有自旋锁的时间必须尽可能短。因为等待者全在空转,持锁一长,所有等待的CPU核心都在空烧,多核服务器瞬间性能崩盘。
- 如果临界区会被中断处理程序和普通进程同时访问,必须使用
spin_lock_irqsave/spin_unlock_irqrestore这对API,在拿锁的同时关闭本地中断,防止中断处理程序插进来抢锁导致死锁。
最后一个坑我展开说一下。假如你的进程上下文持有了自旋锁A,还没释放,突然一个中断来了,中断处理程序也要去拿锁A,它就会一直自旋等待。而进程上下文被中断打断,要等中断处理程序返回才能继续,中断处理程序又在等锁——死锁达成。解决办法就是拿锁前先关中断:spin_lock_irqsave会在拿锁的同时把中断标志保存并关闭,释放时用spin_unlock_irqrestore恢复现场。这个“关中断配合自旋锁”的组合,几乎是中断上下文和进程上下文共享数据的标准姿势。
2.3 互斥锁和信号量:可以睡觉的锁
和自旋锁相反,互斥锁(Mutex)和信号量(Semaphore)属于“睡眠锁”。当一个执行流拿不到互斥锁时,它会被放入等待队列,调度器把它切出去,让出CPU给别的进程跑,等持有者释放锁之后再被唤醒。
听到这儿你应该明白了:睡眠锁只适用于进程上下文,绝不能用于中断上下文。想想看,中断处理程序里mutex_lock拿不到锁,然后把自己挂起?中断处理程序挂起了,谁来调度它出去、谁来唤醒它?调度器根本管不到中断上下文,所以内核直接禁止这种用法,用了就是panic。
那什么场景用互斥锁?临界区比较长、持锁期间可能需要做IO操作、需要调用其他可能睡眠的函数的时候。比如一个驱动里要往硬件寄存器写一串配置,中间有等待硬件响应的延迟,这种场景就该用mutex_lock,而不是自旋锁。睡眠锁的代价是唤醒开销和上下文切换,但如果临界区本身就要几十微秒,自旋等待反而更亏。
信号量和互斥锁的区别主要有两点。第一,信号量允许设置一个大于1的计数,支持多个执行流同时进入临界区,互斥锁的计数只能是一,一次只放一个执行流进去。第二,互斥锁有所有者概念,只有持有者能释放,信号量则没有这个约束。实践中,如果你是要做“互斥访问”,优先用互斥锁,因为内核为互斥锁做了更多优化,比如手写者锁、优先级继承等机制,信号量的灵活性反而让它更笨重。
2.4 读写锁:读多写少时的性能救星
很多共享数据的访问模式是“读多写少”。比如一个路由表、一个配置项、一个设备状态标志,绝大多数时候大家都在读,只有极少数时候会被更新。如果对这类数据一律用互斥锁,那么所有读者之间也会互相排斥——明明两个读者并行读是不会出问题的,却要被锁串行化,白白损失并发性能。
读写锁把并发访问拆成了两种模式:读模式(共享锁)和写模式(排他锁)。多个读者可以同时持有读锁,大家并行读;写者必须等到所有读者释放之后才能拿写锁,且持写锁期间其他读者和写者都不允许进入。这样在读多写少的场景下,并发度大幅提升。
内核里的读写锁分两种:rwlock_t和rcu机制更推荐使用的seqlock(顺序锁)。rwlock_t的问题是读者和写者之间公平性比较差,写者可能长时间被读者饿死;而seqlock的思路更激进——读者不锁,直接读,通过序列号判断读的过程中有没有被写者打断,如果被打断就重读。seqlock适合读者要求不太严格、数据量小、更新频繁但临界区极短的场景。不过seqlock有它的代价:读者可能会重试多次,如果写者过于频繁,读性能反而会崩。
2.5 RCU和percpu锁:进阶选手的利器
RCU(Read-Copy-Update,读-拷贝-更新)是内核里最优雅也最难掌握的同步机制。它的核心思路是:读者侧完全不需要加锁,直接无锁读取数据;写者要修改数据时,先拷贝一份副本,在副本上做修改,然后发布一个指针切换,把旧数据替换成新数据;之后等待原来所有正在读旧数据的执行流离开临界区(称为宽限期),最后再释放旧数据。
这个机制为什么高效?因为读侧完全没有原子操作、没有自旋等待、没有锁竞争,读操作的成本和单线程读一样。在读者远远多于写者的场景下,RCU几乎是性能最优解。内核里很多高性能路径都用了RCU,比如路由表查找、文件系统dentry缓存、进程链表遍历。
但RCU的使用门槛相当高。读者侧需要包裹rcu_read_lock/rcu_read_unlock,这个锁不是排他锁,而是标记一个读临界区;写侧必须经过rcu_assign_pointer发布新指针、synchronize_rcu等待宽限期、rcu_dereference读取指针等一系列规范操作。违反这些规范,比如在宽限期没结束就释放旧数据,就会导致隐藏的UAF(Use-After-Free)漏洞,而且是极难追踪的那种。
还有一类在SMP系统上特别实用的锁,叫percpu锁。它的思路是:既然问题出在多个CPU争抢同一个变量,那我干脆给每个CPU都准备一份独立变量,各自算各自的,需要汇总时再统一合并。以全局计数器为例,每个CPU维护自己的本地计数,get_cpu_var读取本地值,this_cpu_inc做本地自增,最终需要总数时再遍历所有CPU累加。这种设计彻底消除了锁竞争,性能自然好,缺点是代码复杂度高,且不同CPU看到的数据可能不一致,适合弱一致性的统计类场景。
下表把常用锁的适用场景和关键限制做了个总结,方便你在实际写代码时快速对照:
| 并发机制 | 是否睡眠 | 适用上下文 | 临界区特征 | 最大风险 |
|---|---|---|---|---|
| 原子操作 | 否 | 进程/中断 | 单变量读改写 | 无法保护多变量一致性 |
| 自旋锁 | 否 | 进程/中断(需关中断) | 极短、无睡眠 | 临界区太长导致性能崩盘 |
| 互斥锁 | 是 | 仅进程 | 较长、可能阻塞 | 中断上下文使用即panic |
| 读写锁 | 否/可 | 进程(中断需变体) | 读多写少 | 写者饥饿、读重试开销 |
| RCU | 读者否/写者可睡眠 | 进程/中断读者 | 读极多写极少 | 使用规范严苛,易UAF |
| percpu锁 | 否 | 进程 | 各CPU独立数据 | 数据弱一致、内存开销 |
3. 竞态追踪方法论:把幽灵变成现场可查的证据
说句得罪人的话:很多竞态问题查不出来,不是工具不行,是思路不对。竞态问题最大的特点是偶发性——你盯着代码看它不出来,你用调试器单步跟踪它不出来,你加了日志它偏偏不出来,你一放松警惕,它就在生产环境咬你一口。我见过不少人碰到这种问题,第一反应是“多跑几遍看看能不能复现”,结果复现不了,项目工期又紧,就加个莫名其妙的sleep“绕过”,风险就埋下了。正确做法是:先搞清楚竞态问题的证据特征,再选对追踪工具,把偶发问题变成可分析的问题。
3.1 竞态问题的四个典型信号
经验上,绝大多数竞态bug会呈现出以下四类信号,你在排查时可以对照着判断:
第一类是数据错乱但程序不崩溃。比如两个线程交替写一个共享结构体,读到的数据有时候是新值、有时候是旧值、有时候是拼接的脏数据。这种问题最隐蔽,因为没有崩溃日志,只有业务结果不对。
第二类是偶发崩溃,崩溃点完全随机。今天崩在A函数,明天崩在B函数,但core dump里的崩溃指令往往是对某个指针做解引用,而这个指针的值是一个看起来“不太正常”的地址。这是典型的共享指针被多执行流并发写的后果。
第三类是卡死和死锁。多个执行流互相等待对方释放锁,CPU占用居高不下,但程序没有任何进展。这种问题好排查,gdbattach上去敲thread apply all bt看一下堆栈就能看出几个线程卡在拿锁的点上。
第四类是性能莫名其妙地下降。并发量一上去,吞吐量非但不涨反而暴跌,CPU到处是自旋等待和上下文切换的开销。这种问题也常有竞态因素——比如临界区过大导致大量线程排队,或者锁粒度太细导致频繁竞争。
识别信号之后,下一步就是用工具把问题从“偶发”变成“必现”。
3.2 静态分析:先把代码里的危险模式扫出来
追踪竞态问题,不要一上来就上动态工具,先做一遍静态排查往往效率更高。内核源码里工具链很成熟:sparse可以做静态类型检查,smatch可以检查锁使用错误,Coccinelle可以做模式匹配找常见的并发bug。
但说句实话,靠静态检查工具直接找出竞态问题,成功率并不高。因为竞态本质上是运行时时序问题,静态分析很难判断两个执行流是否真的会同时到达临界区。静态分析的价值更多在于检查“锁使用规范”——比如有没有函数在持锁路径上调用了sleep,有没有在同一个函数里重复加锁,有没有加锁后忘记释放。这类问题哪怕不是你现在遇到的竞态bug,也是潜伏的地雷,扫出来一并修掉是划算的。
对于用户态程序,ThreadSanitizer(TSan)是静态思路的强有力补充。用-fsanitize=thread编译程序,运行时会自动检测数据竞争,精确到文件和行号。它用编译插桩的方式记录每次内存访问,一旦检测到两个线程在没有同步的情况下访问同一块内存,就立即输出报告。代价是程序运行速度会慢5到15倍,不适合长时间跑生产,但用于压测复现竞态问题非常好用。
3.3 内核里的竞态检测神器:KCSAN和KASAN
如果你排查的是内核模块或者驱动的问题,那必须认识KCSAN(Kernel Concurrency Sanitizer)和KASAN(Kernel Address Sanitizer)这两个内核内置工具。它们一起配合,基本就是内核竞态排查的天花板。
KCSAN的思路和TSan类似,也是编译插桩,但它的侧重点是检测“未同步的数据竞争”。在KCSAN的检测模型里,一条内存访问如果没有被显式的锁或者原子操作保护,它就认为是无保护访问。当两个CPU同时访问同一个内存地址,且至少有一个是写操作时,KCSAN就会报出一个数据竞争报告。报告里会包含事发时两个执行流各自的调用栈、访问地址、读写类型。
要在内核里开启KCSAN,需要重新编译内核。在.config里打开CONFIG_KCSAN=y,推荐同时打开CONFIG_KCSAN_REPORT_RACE_PCT=100(100%概率报告,便于复现),然后重新编译安装内核。KCSAN带来的性能损耗大约在5到10倍之间,不适合长期运行,但用来做竞态问题的专项复现,效率极高。我自己的习惯是:怀疑内核里有数据竞争,就编译一个KCSAN内核,跑对应模块的压力测试,跑一个晚上,报告能堆好几页。
KASAN则是用来抓野指针访问、越界读写和UAF的。它和KCSAN可以共存,但代价是性能损耗更狠。KASAN的内存着色机制能精确检测出“这块内存已经释放了,但你还在读”这种问题,而这类问题很多正是竞态导致的:一个执行流释放了共享对象,另一个执行流还持有旧指针往里写,最终触发一个看似随机的崩溃。KASAN能在崩溃之前就准确报告“释放后使用”的完整调用链,省去大量猜谜时间。
我在实际项目里经常遇到这种情况:内核panic的堆栈指向一个不知名的地址,怎么分析都分析不出根因。编译一个带KASAN和KCSAN的内核,重新跑一遍同样的场景,几秒之内工具就把完整的竞争报告打出来了,比人肉翻代码高效太多。
3.4 动态追踪:从外部观察执行时序
除了编译插桩,还可以用动态追踪手段从外部观察执行流时序。Linux内核提供了ftrace、trace-cmd、perf、kprobe这些利器,用来观测函数调用序列、锁等待时间、调度切换点。
举个例子。你怀疑两个线程在某个临界区上发生了竞争,你想观察它们实际进入临界区的时间差。用trace-cmd可以这样操作:
# 开启函数跟踪,筛选出你关心的临界区入口函数 trace-cmd record -p function_graph -l 'your_critical_function' -l 'another_critical_function' # 同时记录调度切换事件,看看执行流是怎么穿插的 trace-cmd record -e sched:sched_switch -e sched:sched_wakeup # 跑完负载后读取跟踪结果 trace-cmd reporttrace输出会告诉你每个CPU上函数的进入和退出时间、进程名、PID、调度切换的记录。如果你看到两个进程交替进入同一个函数,而且时间上有交叉重叠,那么竞态行为就有了实锤证据。
kprobe则更灵活,它可以动态挂钩任意内核函数,在函数入口和出口处插入探针,执行你自定义的探针程序。比如你想观察自旋锁的等待时间,可以挂spin_lock入口记录当前时间,挂spin_unlock出口计算持锁时间分布:
# 挂载kprobe探针,打印spin_lock入口和spin_unlock出口 echo 'p:my_lock_entry spin_lock' > /sys/kernel/debug/tracing/kprobe_events echo 'p:my_lock_exit spin_unlock' >> /sys/kernel/debug/tracing/kprobe_events echo 1 > /sys/kernel/debug/tracing/events/kprobes/my_lock_entry/enable echo 1 > /sys/kernel/debug/tracing/events/kprobes/my_lock_exit/enable cat /sys/kernel/debug/tracing/trace_pipe这种方式不需要重编内核,生产环境也能临时挂探针观测,非常实用。
4. 实战复盘:一个驱动链表竞态问题从发现到修复的全过程
讲了这么多理论,下面用一个我处理过的真实案例,把整个排查链路串起来。这个案例在嵌入式Linux开发里非常典型,场景不复杂,但每一步排查都有信息量。
4.1 故障现象和初步分析
有一个使用内核模块实现的设备驱动,维护了一个全局链表,用来记录设备上报的事件。链表读操作由用户态通过read接口触发,每次读取时遍历链表并清除节点;链表写操作由硬中断触发的中断处理程序完成,每次设备来中断时往里追加一个新节点。
这个模块在实验室单任务测试时一切正常,但一旦设备中断频率提高、同时多个用户态进程并发读取,系统就开始随机崩溃。崩溃点的堆栈频繁变化,有时候在list_add,有时候在list_for_each_entry的遍历点,最气人的是压测半小时都不一定复现一次。
拿到现场core dump后,用crash工具分析vmcore,看到一个关键细节:崩溃时的链表头节点指针指向了一个明显不是有效内存的地址,且这个地址的值有时落在其他模块的全局变量附近。这说明链表结构在崩溃前就已经被破坏了,遍历只是一个“踩雷”的时机而已。到了这一步,基本确定是竞态导致的结构体指针错乱。
4.2 用KCSAN锁定竞争点
接下来我编译了一个带CONFIG_KCSAN=y和CONFIG_KCSAN_REPORT_RACE_PCT=100的调试内核,挂上这个出问题的驱动模块,用同样的压力脚本去压。这次只跑了三分钟,内核日志就打出了一份KCSAN数据竞争报告:
================================================================== BUG: KCSAN:>static DEFINE_SPINLOCK(evt_list_lock); static irqreturn_t device_isr(int irq, void *dev_id) { unsigned long flags; struct event_node *node; node = kzalloc(sizeof(*node), GFP_ATOMIC); if (!node) return IRQ_NONE; node->data = read_device_reg(); spin_lock_irqsave(&evt_list_lock, flags); list_add_tail(&node->list, &evt_list); spin_unlock_irqrestore(&evt_list_lock, flags); return IRQ_HANDLED; } static ssize_t device_read(struct file *filp, char __user *buf, size_t len, loff_t *off) { unsigned long flags; struct event_node *node; int ret = 0; spin_lock_irqsave(&evt_list_lock, flags); if (!list_empty(&evt_list)) { node = list_first_entry(&evt_list, struct event_node, list); list_del(&node->list); ret = copy_to_user(buf, &node->data, sizeof(node->data)); kfree(node); } spin_unlock_irqrestore(&evt_list_lock, flags); return ret ? : -EAGAIN; }这里有两个细节值得强调。第一,中断处理程序里分配内存用了GFP_ATOMIC标志,因为在中断上下文里不能睡眠,普通的内存分配可能触发页面回收导致睡眠,这个标志专门用于不可睡眠上下文的内存申请。第二,整个链表操作(检查非空、取头节点、删除、拷贝、释放)都包在同一把锁里,没有把锁拆成“先检查再加锁”两段——后者是经典的“检查后使用”竞态,几乎每次都会踩坑。如果你不加锁就list_empty判断,判断完了再加锁取节点,两个执行流同时判断并通过,然后都去取头节点,链表一样被弄坏。
4.4 修复后的验证步骤
修复之后不能直接说“好了”,要做三件事验证。
第一件事,用原始压力脚本继续压,确认崩溃消失。这一步至少要连续压48小时以上,因为竞态问题往往是低频偶现的,短时间不出问题不代表修复有效。
第二件事,继续用KCSAN内核跑同样的场景。如果修复到位,KCSAN不应该再报出这个地址上的数据竞争。这个验证比跑压测更直接,因为KCSAN能精确到访问级别,一旦有未同步访问它会立即报告。
第三件事,在修复后的版本上用lockdep做锁一致性验证。lockdep是内核自带的锁调试模块,会在运行时记录每次加锁的依赖关系,检测是否存在死锁风险。内核配置里打开CONFIG_PROVE_LOCKING=y即可。如果新代码引入了锁顺序问题,lockdep会在第一次死锁风险出现时立刻打印警告,告诉你完整的锁获取链和潜在的死锁路径。
这三步都通过,这个竞态问题才算是真正关闭。我见过太多人修完bug不验证,过了两周另一个环境里同样的崩溃又冒出来——多半是同级并发路径上还有第二处竞态,或者锁的使用方式引入了新问题。
5. 锁使用避坑清单:这些坑我替你踩过了
最后一部分,把我在实际开发中反复踩过的锁相关坑做一个汇总。每个坑我都亲身经历过,有些坑付出的代价是半夜被电话吵醒、第二天顶着黑眼圈回滚版本。写出来希望能帮读者少走弯路。
5.1 自旋锁临界区里调了睡眠函数
这是新手最容易犯的错误,但我见过的老手也翻过车。典型场景是在自旋锁持锁期间调用了kmalloc——默认的GFP_KERNEL分配可能睡眠,一个负载稍高,分配器需要回收页面,直接睡眠,内核立刻panic,日志里一句话:“scheduling while atomic”。
规避方法有两个方向:要么把临界区里可能睡眠的操作挪出锁外,比如先分配好内存再进锁操作链表;要么确实必须在锁内分配,那就用GFP_ATOMIC。我个人的习惯是,能挪出去的一律挪出去,锁内只保留纯内存操作。
/* 错误示范:自旋锁里用 GFP_KERNEL 分配内存 */ spin_lock(&lock); node = kmalloc(sizeof(*node), GFP_KERNEL); /* 可能睡眠,panic */ list_add(&node->list, &head); spin_unlock(&lock); /* 正确做法:分配放在锁外 */ node = kmalloc(sizeof(*node), GFP_KERNEL); spin_lock(&lock); list_add(&node->list, &head); spin_unlock(&lock);5.2 锁顺序不一致导致死锁
当代码里存在两把或更多锁时,如果不同路径获取锁的顺序不一致,就会出现经典的AB-BA死锁。路径一先拿锁A再拿锁B,路径二先拿锁B再拿锁A,两个执行流在某个时刻同时持有对方等待的锁,就互相卡死了。
lockdep对这个问题的检测能力极强。开启CONFIG_PROVE_LOCKING=y之后,它会建立锁依赖图,一旦发现新的依赖关系可能形成环形循环,立即报警。所以我的建议是:开发阶段全程开lockdep跑,收到任何“possible circular locking dependency detected”的警告,不要当噪音忽略,一定要查。
5.3 中断上下文误用睡眠锁
中断处理程序里用mutex_lock、down_interruptible这类睡眠锁,直接就是非法操作。中断上下文没有进程上下文可供调度,睡眠调用会导致内核崩溃。中断里需要保护共享数据时,请牢记只能使用spin_lock_irqsave或者原子操作。
判断自己是否处在中断上下文,可以检查in_interrupt()返回值。更稳妥的办法是,在写驱动时养成一个习惯:凡是中断处理函数、tasklet、软中断处理函数里用锁,统一用自旋锁变体,不要抱侥幸心理。
5.4 加锁后忘记释放或提前return
这属于最基础但也最常见的问题。代码在加锁和释放之间有多条错误返回路径时,很容易漏掉某个分支的释放,导致锁永远不释放、所有其他执行流全部卡死。更好一点的做法是统一通过goto跳转到统一的释放出口。
static int foo(void) { unsigned long flags; spin_lock_irqsave(&lock, flags); if (condition_a) { ret = -EINVAL; goto out_unlock; } if (condition_b) { ret = -ENOMEM; goto out_unlock; } ret = 0; out_unlock: spin_unlock_irqrestore(&lock, flags); return ret; }内核编码规范里大量使用这个模式,不是没有道理的。它把所有的释放操作收敛到一个点,从结构上杜绝了“漏释放”。
5.5 临界区过大导致性能瓶颈
锁本身不会让性能变差,锁带来的串行化和缓存失效才会让性能变差。自旋锁持有时间过长,所有等待的CPU都在空转,等于多核并行被锁硬生生变成了单核串行。互斥锁持有时间过长,所有等待进程都反复睡眠唤醒,上下文切换开销暴涨。
优化的原则是临界区尽量小。审视一下持锁代码里哪些操作是为了“用锁保护一致性”所必需的,哪些操作是附带在锁里顺手做的——后者多半可以挪出去。比如要把一份数据拷贝给用户态,先加锁把数据拷贝到临时缓冲区,释放锁,再copy_to_user,而不是锁着内核对用户态的拷贝过程。
5.6 原子操作和锁混用造成语义混乱
有些代码试图用原子变量加自旋锁做双重保护,结果两套机制各有各的入口,有的路径走锁、有的路径走原子变量,反而把并发模型搞乱了。这是个设计问题。原子操作帮你保护的是单变量的读改写,自旋锁保护的是多变量的一致性,两者面向的临界区尺度不同。如果同一份数据既要原子操作又要加锁,先停下来想想是否真的需要两套机制,还是锁粒度设计出了问题。
我在实际项目中总结过一个小原则:共享数据需要用锁保护时,所有访问路径都必须走同一套锁协议。可以有一个快速路径用原子操作、慢速路径用锁,但那是特定场景下的精细设计,需要额外小心地处理阈值切换,新手不建议模仿。
6. 最后说点个人体会
干Linux底层开发这些年,我见过太多人被竞态问题折磨得怀疑人生。其实回头想想,竞态问题最可怕的不是它有多难,而是它足够隐蔽,给人一种“代码看起来没问题”的错觉。任何写并发代码的人,都会在某个时间点觉得自己对并发已经“心中有数”,但几乎所有人都会在某个隐藏的竞态上栽跟头。我见过内核老手在RCU上翻车,也见过驱动专家在中断上下文里用错锁,这不是能力问题,是并发本身对人类的直觉天生不友好。
所以我的经验是:写任何涉及共享数据的代码,第一件事不是写功能逻辑,而是先画清楚并发模型——谁会访问这份数据、哪些执行路径可能同时进来、各自能容忍什么级别的等待。模型画清楚了,锁怎么选、临界区怎么划定,基本就水到渠成了。别上来就随手加一把锁,那是把问题往后推,不是解决问题。排查的时候也记住,工具比直觉可靠。KCSAN、KASAN、lockdep这些工具虽然要花时间配置,但它们能在你肉眼看不到的时序缝隙里替你盯着,价值远超你熬夜“肉眼看代码”的效率。希望这篇文章能帮你在下一次遇到竞态问题时少走几步弯路,早点睡个好觉。