ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux mutex深度剖析:从快路径到慢路径,从源码到踩坑实践

Linux mutex深度剖析:从快路径到慢路径,从源码到踩坑实践

Linux内核里的互斥锁,很多人第一反应是“不就是个锁嘛,加锁解锁两行代码的事”。真到写驱动、调并发、查死锁的时候才发现,mutex背后牵扯的调度、睡眠、优先级、内存屏障,随便拎一个出来都能让人调上一整天。这篇东西我打算把Linux mutex从数据结构到加锁路径、从快路径到慢路径、从正常场景到踩坑现场,完整地捋一遍。不管你是刚接触内核并发的新手,还是已经写过几个模块、被mutex坑过的老手,应该都能从里面找到点有用的东西。

我写这篇的出发点很直接:网上讲mutex的文章,要么停留在“mutex会睡眠、spinlock不会”这种一句话结论,要么直接贴源码不解释为什么这么设计。真正干活的时候,你需要知道的是——什么情况下mutex会走快路径、什么情况下会进慢路径、owner字段到底存了什么、乐观自旋在什么条件下才会发生、为什么有时候明明没竞争却还是慢。这些问题不搞清楚,调优和排错基本靠猜。

1. 先把mutex这层皮扒开:结构体里每个字段都在干什么

1.1 owner字段不是简单的“有没有人持有”

看mutex源码,第一个绕不开的就是struct mutex里的atomic_long_t owner。很多人以为它就是个标志位,0表示没人拿,1表示有人拿。实际上Linux的mutex owner字段塞了不少信息进去,它是一个被编码过的值。

在比较新的内核版本里,owner字段的低位用来存标志,高位存的是task_struct指针。具体来说,MUTEX_FLAG_WAITERS、MUTEX_FLAG_HANDOFF、MUTEX_FLAG_PICKUP这几个标志位被编码在低位。这意味着当你看到owner非零的时候,不能简单认为“锁被持有”,还得看低位标志判断有没有等待者、有没有做handoff。

为什么要这么设计?因为mutex的慢路径需要知道“当前有没有人在等”这个信息,如果单独再搞一个字段存等待者数量,一是浪费空间,二是更新时会有额外的缓存行竞争。把标志和指针压进一个原子变量里,一次CAS就能同时完成“设置owner”和“标记有等待者”两件事,这是内核里非常典型的用位操作换性能的手法。

实际调试的时候有个细节值得注意:如果你在crash dump里看到owner的值看起来像个非法指针,先别急着怀疑内存损坏,很可能只是低位标志位让这个值看起来不对齐。把低几位掩掉再看,往往就是一个正常的task_struct地址。

1.2 wait_lock和wait_list:慢路径的候车室

wait_lock是一个spinlock,保护的是wait_list这个双向链表。注意,wait_lock保护的不是mutex本身,而是等待队列。这个区分很关键,很多人第一次读源码会混淆,以为拿不到mutex就去抢wait_lock,其实wait_lock只在需要操作等待队列的时候才用。

wait_list上挂的是struct mutex_waiter,每个等待者一个节点。节点里存了task_struct指针和一个magic字段(调试用)。当线程决定睡眠等待时,会把自己挂到这个链表上,然后调用调度器让出CPU。

这里有个设计上的取舍值得说:为什么等待队列用链表而不是像futex那样搞哈希或者树结构?因为mutex的等待者数量通常很少,绝大多数场景下就是0个或1个,链表插入删除的O(1)开销完全够用,没必要上更复杂的数据结构。内核里很多设计都是这样,不为极端场景过度设计。

1.3 快路径、中路径、慢路径的三层结构

现代内核的mutex加锁分三层,理解这个分层是理解整个机制的关键。

第一层是快路径(fast path),直接一个atomic_long_try_cmpxchg_acquire,如果owner是0,CAS成功把当前task指针写进去,锁就拿到了。这条路径没有函数调用开销、没有spinlock、没有调度,就是一条原子指令的事。绝大多数无竞争的加锁都走这里。

第二层是中路径(mid path),也叫乐观自旋。当快路径CAS失败,说明锁被占了,但持有者可能马上要释放。这时候不急着睡眠,先自旋一小会儿,同时不断检查owner有没有变成0。这个自旋是有条件的——只有持有者当前正在CPU上运行(mutex_can_spin_on_owner判断),自旋才有意义。如果持有者已经被调度出去了,自旋就是纯浪费CPU,直接进慢路径。

第三层是慢路径(slow path),真正进入睡眠等待。这里要拿wait_lock、挂等待队列、设置WAITERS标志、调用schedule_preempt_disabled让出CPU。被唤醒后还要处理handoff逻辑,判断是自己抢锁还是接力给别人。

这三层的存在,本质是在“无竞争开销”和“有竞争时的公平性/效率”之间做平衡。快路径追求极致轻量,慢路径保证不饿死、不惊群,中路径则是一个赌一把的优化——赌持有者很快释放。

2. 加锁那条路:从一条CAS指令到一次上下文切换

2.1 快路径为什么能这么快

快路径的核心就是这一句:

if (atomic_long_try_cmpxchg_acquire(&lock->owner, &curr, val)) return;

try_cmpxchg_acquire在x86上编译出来就是一条lock cmpxchg指令,acquire语义保证了临界区内的读操作不会被重排到加锁之前。这条路径上没有函数调用(内联了)、没有分支预测失败(无竞争时分支高度可预测)、没有缓存行 bouncing(owner字段被持有者写、释放者写,但无竞争时就是同一个CPU在读写)。

我实测过一个简单的场景:单线程反复加解锁一个mutex,每次加解锁的耗时在十几纳秒量级。这个数字说明快路径确实做到了“接近零开销”,这也是为什么内核里大量使用mutex而不用担心性能——只要没竞争,mutex和spinlock的开销差距没有想象中那么大。

但这里有个容易被忽略的点:快路径的CAS是acquire语义,不是full barrier。这意味着它只保证后续读不被重排到前面,不保证前面的写不被重排到后面。如果你在加锁之前有写操作需要和临界区内的操作保持顺序,得自己加屏障。这个细节在写驱动的时候偶尔会咬人。

2.2 乐观自旋的触发条件和退出条件

快路径失败后进入__mutex_lock_common,第一件事是尝试乐观自旋。触发自旋的条件在mutex_can_spin_on_owner里:

  • 当前没有开启need_resched(没被要求让出CPU)
  • owner非空且owner指向的task处于running状态
  • 当前CPU上没有更高优先级的任务在等

这三个条件缺一不可。为什么?因为自旋的前提是“持有者正在跑,马上会释放”。如果持有者被抢占了、或者当前线程自己该让出了、或者有更高优先级任务在等,自旋就是纯粹的CPU浪费。

自旋过程中每次循环都会重新读owner,一旦发现owner变成0,立刻尝试CAS抢锁。这里有个精妙之处:自旋抢锁用的是atomic_long_try_cmpxchg_acquire,和快路径一样,所以自旋成功的话,走的还是快路径的代码逻辑,没有额外开销。

自旋还有一个退出条件是mutex_spin_on_owner返回false,比如owner变了(锁被释放又被别人抢了)、或者owner指向的task不再running。这时候就得老老实实进慢路径。

实际调优的时候,乐观自旋是一把双刃剑。在持有者临界区很短、且持有者确实在跑的场合,自旋能避免上下文切换,性能提升明显。但如果临界区里有IO或者可能睡眠的操作,自旋就是白烧CPU。我见过有代码在持有mutex的情况下调用可能睡眠的函数,结果等待者自旋空转,CPU利用率飙高但吞吐上不去,查了半天才发现是临界区设计有问题。

2.3 慢路径里那套“先挂队再睡”的流程

进慢路径之后,流程大致是:

  1. 拿wait_lock(spinlock)
  2. 把当前线程的waiter节点挂到wait_list尾部
  3. 设置owner的MUTEX_FLAG_WAITERS标志
  4. 释放wait_lock
  5. 循环检查owner,如果还是拿不到就调用schedule_preempt_disabled睡眠
  6. 被唤醒后重新竞争,处理handoff

这里第3步设置WAITERS标志很关键。持有者在释放锁的时候会检查这个标志,如果发现有等待者,就不会简单地把owner清零,而是走handoff流程,直接把锁交给等待队列里的下一个。这个机制避免了“释放后又被一个新来的线程抢走”的饥饿问题。

schedule_preempt_disabled这个调用也值得说一句。它先关抢占再调schedule,保证从“决定睡眠”到“真正切走”之间不会被抢占打断。如果不关抢占,可能出现刚判断完要睡、还没切走就被抢占,醒来后状态判断出错的情况。

被唤醒之后的逻辑更绕。线程醒来后不是直接拿锁,而是先看自己是不是被handoff了。如果是,直接接管;如果不是,得重新参与竞争,可能又失败再睡回去。这个循环在__mutex_lock_common里是个for(;;),配合各种状态判断,第一次读很容易看晕。

3. 解锁不是“清零”那么简单:handoff与唤醒的博弈

3.1 释放时的三条分支

解锁函数__mutex_unlock_slowpath(快路径解锁在mutex_unlock里内联处理)面对owner字段的不同状态,走不同分支:

owner状态处理方式说明
只有当前task指针,无标志直接CAS清零无等待者,最快路径
有WAITERS标志走handoff流程有等待者,需要唤醒
有HANDOFF标志特殊处理说明之前已经做过handoff

无等待者的情况最简单,一个CAS把owner从当前task指针改成0,完事。这条路径和加锁快路径对称,开销极小。

有等待者的时候,释放者要做的是:把owner设置成MUTEX_FLAG_HANDOFF(可能还带WAITERS),然后唤醒等待队列里的第一个waiter。被唤醒的waiter看到HANDOFF标志,就知道锁是直接给自己的,不需要再竞争。

3.2 为什么要有handoff:公平性的代价

handoff机制的核心目的是防止饥饿。如果没有handoff,释放者把owner清零后,一个刚到的线程可能通过快路径CAS直接把锁抢走,而等待队列里排了半天的线程还在睡。极端情况下,等待者可能永远抢不到。

handoff的做法是:释放者不把锁“扔回池子里”,而是“点名”交给队列头的等待者。这样保证了FIFO的公平性。

但公平是有代价的。handoff意味着释放者要做更多工作(设置标志、唤醒线程),被唤醒的线程还要经历一次上下文切换。在竞争不激烈的时候,这套流程纯属额外开销。所以内核的设计是:只有在确实有等待者(WAITERS标志被设置)的时候才走handoff,无竞争时完全不碰这套逻辑。

这里有个实际经验:如果你的场景里mutex竞争很激烈,handoff带来的上下文切换开销可能成为瓶颈。这种情况下要考虑的是减少临界区长度、或者换用其他同步原语,而不是指望mutex本身变快。mutex的设计目标是“无竞争时极快,有竞争时保证公平”,它不承诺高竞争下的高吞吐。

3.3 唤醒之后的那段“接力”逻辑

被handoff唤醒的线程,醒来后第一件事是检查owner是不是MUTEX_FLAG_HANDOFF。如果是,它要把owner改成自己的task指针(同时清掉HANDOFF标志),然后返回,认为自己拿到了锁。

但这里有个race:在它醒来之前,可能有别的线程通过快路径把锁抢了(如果owner被临时清零的话)。所以handoff流程里,释放者设置HANDOFF标志后并不会把owner清零,而是保持一个非零值,这样快路径的CAS必然失败,新来的线程只能进慢路径排队。这就堵住了“插队”的口子。

被唤醒线程拿到锁之后,还要检查等待队列里还有没有其他人。如果还有,它要负责继续唤醒下一个(接力)。这个接力逻辑保证了队列里的线程能一个接一个地拿到锁,不会因为某个线程拿到锁后忘了唤醒后面的人而卡住。

这段代码第一次读会觉得绕,但想清楚“锁的传递是一个接力过程,每个拿到锁的人都有责任把棒子交给下一个人”这个模型,就顺了。

4. 那些年我被mutex坑过的现场

4.1 在持有mutex时睡眠:最经典也最致命的错误

mutex和spinlock最大的区别就是mutex可以睡眠,但反过来,持有mutex的时候去睡眠是合法的,只是要小心。真正致命的是在原子上下文(中断、软中断、持有spinlock)里用mutex,因为mutex的慢路径会调用schedule,原子上下文里调度会出问题。

我踩过的一个坑是在持有mutex的情况下调用了一个会睡眠的分配函数(带GFP_KERNEL的kmalloc),结果在内存紧张的时候,分配函数内部触发回收,回收路径又要拿同一个mutex,直接死锁。这种问题在压力测试下才暴露,平时跑得好好的。

排查这类问题的思路:先看死锁现场的调用栈,确认是不是同一个mutex被递归获取。如果是,再往上找是谁在持有锁的时候进了可能睡眠的路径。内核开了CONFIG_DEBUG_MUTEXES之后,递归加锁会直接报warning,能省不少事。

4.2 优先级反转:不是理论问题,是真会发生的

优先级反转这个事,教科书上讲得很多,但很多人觉得离自己很远。我遇到过一次真实场景:低优先级线程持有mutex做耗时操作,中优先级线程疯狂抢占CPU,高优先级线程等mutex等不到,整个系统的响应时间被中优先级线程拖垮。

Linux的mutex本身不做优先级继承(priority inheritance),这是和RT-mutex的重要区别。普通mutex没有PI机制,所以优先级反转在普通mutex上是可能发生的。如果你的场景对实时性有要求,要么用RT-mutex,要么从设计上避免“低优先级线程持有锁做长操作”。

判断要不要用RT-mutex的标准很简单:如果高优先级线程的阻塞时间有硬性上限要求,普通mutex满足不了,就得上RT-mutex。RT-mutex通过优先级继承让持有者临时提升优先级,减少被中优先级抢占的窗口。

4.3 调试开关打开后性能骤降:DEBUG_MUTEXES的副作用

CONFIG_DEBUG_MUTEXES打开后,mutex会记录owner、记录加锁点、做各种一致性检查。这些检查在开发阶段非常有用,能提前发现递归加锁、释放非自己持有的锁等问题。但它的开销不小,每次加解锁都有额外的记录和校验。

我见过有团队在性能测试环境忘了关这个选项,测出来的数据比预期差一截,查了半天以为是代码问题,最后发现是调试选项没关。所以记住:性能测试一定要用和生产一致的配置,调试选项该关就关。

另外CONFIG_DEBUG_LOCK_ALLOC(lockdep)也是类似的情况,它能检测锁顺序问题,但开销更大。开发阶段开着,压测和上线前关掉,这是常规操作。

4.4 一个隐蔽的坑:mutex在结构体里的cache line对齐

这个坑比较隐蔽。如果mutex和频繁访问的数据在同一个cache line里,加锁时的cache line写操作会导致其他CPU上读这个数据的缓存失效,产生伪共享(false sharing)。表现是:明明锁竞争不激烈,但性能就是上不去。

解决办法是把mutex单独放到一个cache line里,用____cacheline_aligned之类的属性对齐。内核里很多高性能数据结构都这么做。判断有没有这个问题,可以用perf看cache miss率,如果miss率高但锁竞争统计很低,就要怀疑伪共享。

5. 从源码到实践:怎么读mutex代码才不迷路

5.1 推荐一条由浅入深的阅读路径

直接啃kernel/locking/mutex.c容易劝退,几千行代码加上各种宏和条件编译。我建议的顺序是:

  1. 先看struct mutex定义,把每个字段的作用搞清楚
  2. 看mutex_lock和mutex_unlock的内联快路径,理解无竞争时的行为
  3. 看__mutex_lock_common的主循环,理解慢路径的状态机
  4. 看__mutex_unlock_slowpath,理解handoff
  5. 最后看乐观自旋相关的mutex_can_spin_on_owner和mutex_spin_on_owner

这个顺序的好处是先建立“快路径长什么样”的直觉,再看慢路径时能对照着理解“为什么慢路径要这么复杂”——因为它要处理快路径处理不了的所有情况。

读的时候建议配合Documentation/locking/mutex-design.rst,这份文档解释了mutex的设计取舍,比单纯读代码更容易理解“为什么这么设计”。

5.2 用tracepoint和lockdep辅助理解运行时行为

光读代码不够,得看运行时到底走了哪条路径。内核提供了lock:mutex_lock、lock:mutex_unlock等tracepoint,可以统计加锁次数、等待时间、自旋次数。用ftrace或者perf打开这些tracepoint,跑一段负载,就能看到实际走了多少次快路径、多少次慢路径、平均等待多久。

lockdep则能帮你发现锁顺序问题。它维护一个锁的依赖图,如果检测到可能的死锁环(比如A等B、B等A),会直接报出来。开发阶段强烈建议开着,很多死锁问题在测试阶段就能被lockdep抓到,不用等到线上出事。

5.3 一个可以自己动手的小实验

想直观感受快路径和慢路径的差异,可以写个简单的内核模块做对比实验:一个线程反复加解锁(无竞争,走快路径),另一个场景两个线程抢同一个锁(有竞争,走慢路径),用ktime_get打点统计每次加锁耗时。你会看到无竞争时耗时在几十纳秒,有竞争时因为上下文切换,耗时跳到微秒级甚至更高。

这个实验的价值在于让你对“竞争的成本”有量化的认识。很多性能问题的根源就是锁竞争,但如果没有具体数字,很难判断“这个竞争到底严重不严重”。有了基准数据,调优的时候心里就有底了。

6. 几个容易被问到的细节问题

6.1 mutex和spinlock到底怎么选

这个问题被问得最多,但答案其实不复杂。判断标准就一条:临界区里会不会睡眠。会睡眠(调用可能睡眠的函数、等待IO、分配内存用GFP_KERNEL)就用mutex,不会睡眠且临界区极短就用spinlock。

但实际选择还要考虑竞争程度。spinlock在竞争时会忙等,浪费CPU;mutex在竞争时会睡眠,有上下文切换开销。低竞争、短临界区,两者差距不大;高竞争场景,mutex的睡眠机制反而可能更省CPU(虽然延迟更高)。

还有一个容易忽略的点:spinlock在持有期间是关抢占的(spin_lock会关抢占),这会影响系统的调度延迟。如果临界区稍长,关抢占的时间就长,实时性受影响。mutex不关抢占(快路径除外),对调度更友好。

6.2 mutex能不能递归加锁

不能。Linux的mutex不支持递归加锁,同一个线程对同一个mutex加两次锁会死锁(第二次加锁时owner是自己,快路径失败,慢路径发现自己已经在等待队列里,然后睡死)。

开了CONFIG_DEBUG_MUTEXES的话,递归加锁会被检测到并报warning,但不开的话就是静默死锁。所以设计的时候要避免“可能在多处加同一个锁”的代码结构,或者用别的机制(比如引用计数)来管理重入。

6.3 为什么有时候没竞争也走了慢路径

有几种可能:一是刚好在快路径CAS失败和慢路径之间有个窗口,别的线程短暂持有了一下;二是乐观自旋的条件不满足(比如当前线程的need_resched被设置了);三是owner字段的WAITERS标志被设置了,即使实际没人在等,也会走慢路径的检查逻辑。

第三种情况比较隐蔽。WAITERS标志一旦被设置,在某些路径上不会立即清除,可能导致后续的加锁走慢路径做额外检查。这不是bug,是设计上的保守——宁可多检查一下,也不漏掉可能的等待者。

6.4 mutex的公平性到底怎么保证

靠handoff。前面说过,有等待者时释放者不把锁扔回池子,而是点名交给队列头。但要注意,这个公平性是“近似FIFO”,不是严格FIFO。因为乐观自旋的存在,一个刚到的线程如果在自旋期间抢到了锁,是允许的(它没进等待队列,不算插队)。只有进了等待队列的线程才受FIFO保护。

这个设计是有意的:自旋抢锁的线程通常持有时间很短,让它先拿反而能提高整体吞吐。真正排队的线程才需要公平性保证,避免饿死。

7. 写在后头:我对mutex的一点个人理解

用了这么多年mutex,我最大的体会是:mutex的复杂度几乎全部来自“有竞争”这个场景。无竞争的时候它就是一条CAS指令,简单到不能再简单。所有的wait_list、handoff、乐观自旋、状态机,都是为了在竞争出现时还能保持合理的行为。

所以实际工作中,减少mutex问题的根本办法不是研究怎么调mutex参数(它也没什么可调的),而是从设计上减少竞争:缩短临界区、拆分锁的粒度、用无锁数据结构替代、用per-CPU变量避免共享。这些手段比在mutex本身上做文章有效得多。

另外一个体会是,读mutex源码最大的收获不是记住它的实现细节,而是理解它背后的设计权衡——什么时候该快、什么时候该公平、什么时候该睡眠。这些权衡思路可以迁移到其他同步机制的设计和选择上。理解了mutex为什么长这样,再看rcu、seqlock、futex这些,会发现它们都是在不同的权衡点上做文章。

最后分享一个排查mutex相关问题的习惯:遇到疑似锁问题,先别急着改代码,先把现场信息抓全——调用栈、owner字段的值、wait_list的长度、当时的调度状态。这些信息能帮你快速判断是死锁、活锁、还是单纯的性能问题。凭直觉改代码,往往越改越乱。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进