想象一家深夜食堂的后厨:几位厨师共用一口灶、一把锅铲和一罐盐。A 师傅刚要往锅里加盐,B 师傅同时伸手也加了一勺——菜瞬间咸得没法吃。更糟的是,两人同时去抢锅铲,锅铲直接飞了出去。Linux 内核就是一座这样的后厨:多个 CPU 核心同时招待多个执行流,它们共享全局变量、设备寄存器、缓存链表。谁碰了什么、按什么顺序碰,全靠一套“后厨秩序”来约束,这就是内核同步机制。 内核里有四种常见的“管理制度”:自旋锁像守着锅寸步不离的帮厨——锅一空就抢,锅没空也绝不睡觉,原地转圈等;信号量像发餐票的窗口——餐票用完就坐在长椅上睡,有人还票就叫醒一位;互斥锁像唯一一把卫生间钥匙——一次只借一人,还带主人登记与紧急加急;RCU 像图书馆换新版书——读者照常翻旧书,写者摆上新书后,等所有读旧书的人合上书页,才把旧书收走。读完这篇文章,你会明白四种制度各自的适用场景、中断与抢占约束,以及“这把锁能不能睡觉”为什么是内核里最致命的问题之一。

岗位关联

掌握内核同步机制,是写出无死锁、无竞态驱动与子系统的地基;从底层系统编程到 AI 推理框架的性能调优,这份“后厨秩序感”是 AI 系统工程师深入架构底层的分水岭。

目录
  1. 可视化演示
  2. 并发与竞态
  3. 原子操作
  4. 自旋锁
  5. 信号量
  6. 互斥锁
  7. RCU机制
  8. 选型与约束
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。

本节配有交互动效(进入视口后自动加载,约 3KB)。

系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T

并发与竞态

深夜食堂后厨最乱的时候,七八位厨师在同一张案板上切菜。A 刚把一勺盐撒进汤里,B 正巧也伸手往同一锅汤加盐。两人互不知情,汤咸得没法喝。内核世界更夸张:多个 CPU 核心可以同一瞬间执行同一条指令,访问同一个全局变量。只要这段“共用案板”的代码没有规矩,结果就完全不可预测——这就是并发与竞态。

专业地讲,“同时访问同一份数据”的代码区域叫临界区,被争抢的数据叫临界资源。多个执行流(进程、线程、中断、软中断)在临界区中交错执行,导致结果依赖执行顺序,就叫竞态条件。内核里的并发来源主要有三:多核并行、中断异步到来、以及内核抢占——高优先级的执行流可以在任意指令处打断当前执行流。

最典型的危害是“丢失更新”。一个 counter = counter + 1 看起来一步完成,实际上要拆成“读 → 加 → 写”三条机器指令。假设两个 CPU 同时读到旧值 0,各自加 1,再先后写回,最终结果只有 1,而不是 2。

要治这锅咸汤,后厨必须立规矩:谁用盐罐,谁就先举牌。内核里的“牌子”就是各种同步原语——原子操作、自旋锁、信号量、互斥锁和 RCU。接下来我们一张一张看。

无锁时:  xfinal⩽x0+n1+n2,有锁时:  xfinal=x0+n1+n2\text{无锁时: } \ x_{final} \leqslant x_0 + n_1 + n_2, \qquad \text{有锁时: } \ x_{final} = x_0 + n_1 + n_2
两位厨师同时往同一个盐罐里按自己的刻度画了一道,结果谁都没有完整记上自己的账。
  1. 初始值 \(x_0\)共享计数器进入临界区前的原始值,相当于盐罐原来的刻度。
    x0=临界区入口处的计数器快照x_0 = \text{临界区入口处的计数器快照}
  2. 两个自增量 \(n_1, n_2\)两个执行流各自完成的累加次数,它们本来都应该计入最终结果。
    n1,n2=各自执行的自增次数n_1, n_2 = \text{各自执行的自增次数}
  3. 无锁不等式交错读写导致至少一次“写回”覆盖了别人的成果,结果小于完整累加。
    xfinal=x0+nlast  ⩽  x0+n1+n2x_{final} = x_0 + n_{last} \;\leqslant\; x_0 + n_1 + n_2
  4. 有锁等式加上互斥后,临界区被串行化,两次累加都不丢失,结果精确等于完整和。
    xfinal=x0+n1+n2=串行化的正确结果x_{final} = x_0 + n_1 + n_2 = \text{串行化的正确结果}

原子操作

对付“抢盐”最朴素的办法,是规定一个动作必须在“一口气”内完成。后厨有条规矩:抓起盐罐、倒盐、放回原处,必须在一次呼吸之间做完,任何人不得在你呼吸的中间插队。打断你,就等于这个动作从来没有发生过。

内核把这种“一口气”做成了硬件指令,叫原子操作。它最常见的形态是“读-改-写”:一条指令完成读取、修改、写回,中间不可能被其他执行流看到半成品。内核用 atomic_t 类型与 atomic_inc、atomic_xchg 等 API 暴露它。atomic_xchg(lock, 1) 的意思是“把 1 放进锁里,同时把锁原来的值还给我”——交换在一步内完成,不可能读到旧值再被别人改。

原子操作还是内存屏障的亲密伙伴。CPU 和编译器为了性能会打乱指令顺序,内存屏障告诉它们:“这里的顺序不许乱。”原子操作保证“不可分割”,屏障保证“顺序可见”,两者共同构成所有锁的地基。

有了这个“一气呵成”的手势,守锅的帮厨就能安全地抢锅了——所有锁的争抢,最终都建立在原子操作这块地基上。

atomic_xchg(&L,v): (old, L)←(L, v), 整步完成不可插入\text{atomic\_xchg}(\&L, v): \ (old,\ L) \leftarrow (L,\ v), \ \text{整步完成不可插入}
厨师把“抓盐、倒盐、放回”压缩成一个手势,别人只能看到结果,看不到过程。
  1. 地址参数 \(\&L\)锁变量所在的地址,也就是盐罐在调料架上的位置。
    &L=锁变量的内存地址\&L = \text{锁变量的内存地址}
  2. 写入值 \(v\)要放进锁的新值,内核里常写 1 表示“锁被占用”。
    v=1 ⇒ 锁被占用v = 1 \ \Rightarrow \ \text{锁被占用}
  3. 交换结果赋值在同一瞬间双向完成,函数返回的 old 就是交换前锁的状态。
    old=L (交换前的旧值),L=vold = L \ \text{(交换前的旧值)}, \quad L = v
  4. 不可插入性硬件保证整条指令要么完全生效、要么完全不生效,这是“不可分割”的数学表达。
    R∘M∘W=单步操作, 中间状态不可见\text{R} \circ \text{M} \circ \text{W} = \text{单步操作, 中间状态不可见}

自旋锁

后厨里最性急的帮厨小张负责看管唯一一口炒锅。他的办法简单粗暴:锅空着,他立刻上灶;锅没空,他就站在灶台边死死盯着,一秒都不离开。厨师圈管这叫“守锅”,内核里叫自旋——CPU 在原地打转等待,不睡觉、不挪窝。

自旋锁的实现就是“循环 + 原子交换”。等锁的线程反复执行 atomic_cmpxchg:如果锁是 0(空闲),就把它改成 1 并返回成功;如果锁是 1(被占),就继续转圈尝试。这个过程可以写成:

自旋锁有三条军规。其一,临界区必须极短,否则把 CPU 烧在空转上。其二,持锁期间绝对不能睡眠——等锁的人不睡觉,持锁的人去睡,所有人一起死锁。其三,配合关中断:若持锁时本 CPU 来了中断,而中断处理程序也来抢这把锁,会永远等下去,所以中断路径要用 spin_lock_irqsave。在单核非抢占内核中,自旋锁甚至可以退化为空操作——因为根本不存在第二个执行流插进来。

守锅法只适合“爆炒三十秒”的极短活。小张要是用守锅法去守一锅三小时的炖汤,他会在灶台前饿晕,所有排队的厨师也都会堵死在锅前——临界区太长、不能睡眠,正是自旋锁的适用边界。

spin_lock:  while (atomic_cmpxchg(&lock, 0, 1)≠0) { cpu_relax() },tcs≪tsched\text{spin\_lock: } \ \text{while } \big(\text{atomic\_cmpxchg}(\&lock,\ 0,\ 1) \neq 0\big)\ \{\ \text{cpu\_relax}()\ \}, \quad t_{cs} \ll t_{sched}
帮厨小张盯着锅:锅一空就抢,锅没空就原地打转,绝不离开。
  1. 比较交换若 lock 当前等于 0,则把它写成 1,并返回旧值;否则直接返回旧值。
    atomic_cmpxchg(&lock, 0, 1)=old value of lock\text{atomic\_cmpxchg}(\&lock,\ 0,\ 1) = \text{old value of } lock
  2. 循环条件 \(\neq 0\)旧值是 1,说明别人正占着锁,当前线程继续“转圈空等”。
    old=1 ⇒ 继续自旋\text{old} = 1 \ \Rightarrow \ \text{继续自旋}
  3. cpu\_relax()向 CPU 发出“我在忙等”的提示,可以减少功耗、让流水线更顺畅。
    cpu_relax()=自旋等待时的节能指令\text{cpu\_relax}() = \text{自旋等待时的节能指令}
  4. 适用条件 \(t_{cs} \ll t_{sched}\)临界区执行时间必须远远小于一次调度切换的时间,否则空转太久纯属浪费。
    tcs≪tsched ⇔ 临界区极短t_{cs} \ll t_{sched} \ \Leftrightarrow \ \text{临界区极短}

信号量

后厨的取菜窗口换了制度:窗口挂着三张餐票,领到餐票才准取菜;票发完了,后来的厨师坐在长椅上休息,等有人还了餐票,窗口再叫醒一位。这就是信号量——一个“计数器 + 等待队列”。

信号量由荷兰计算机科学家 Dijkstra 提出,核心是两个操作:P(proberen,测试)和 V(verhogen,增加)。P 把计数减一,发现小于 0 就睡眠;V 把计数加一,发现小于等于 0 就唤醒一个睡眠者。

信号量把“浪费的等待”变成“睡眠—唤醒”:睡眠会触发上下文切换,大约消耗微秒级代价,但等待者不占 CPU。关键约束是:中断上下文中不能使用信号量——中断处理程序没有进程上下文可以睡眠。多核时代 Linux 更推荐互斥锁,但信号量的计数思维(生产-消费、资源池)仍是理解内核的一把钥匙。

餐票制度适合“窗口排长队、等菜要很久”的场景。票发完大家安心打盹,菜好了再被叫醒——多花一次上下文切换的代价,换回等待时零 CPU 占用。

P(S): S←S−1;  if S<0 then sleep();V(S): S←S+1;  if S⩽0 then wakeup()P(S): \ S \leftarrow S - 1;\ \ \text{if } S < 0\ \text{then sleep}(); \qquad V(S): \ S \leftarrow S + 1;\ \ \text{if } S \leqslant 0\ \text{then wakeup}()
窗口发券:券没了就排队睡觉,有人还券就叫醒一位,数字始终记录着“还有几张”。
  1. 信号量初值 \(S\)可同时使用的资源份数,好比窗口挂出的三张餐票。
    S0=可用资源数量S_0 = \text{可用资源数量}
  2. P 操作先减一申请资源前先把计数减一,这个减一动作本身必须是原子的。
    S←S−1 (申请一份资源)S \leftarrow S - 1 \ \text{(申请一份资源)}
  3. 睡眠判断 \(S < 0\)计数值变负,说明资源已被抢光,调用者进入等待队列睡觉。
    S<0 ⇒ sleep 并加入等待队列S < 0 \ \Rightarrow \ \text{sleep 并加入等待队列}
  4. V 操作先加一归还资源时把计数加回,相当于放回一张餐票。
    S←S+1 (归还一份资源)S \leftarrow S + 1 \ \text{(归还一份资源)}
  5. 唤醒判断 \(S \leqslant 0\)加回后仍不大于 0,说明确有睡着的等待者,需要唤醒一位。
    S⩽0 ⇒ wakeup 一个等待者S \leqslant 0 \ \Rightarrow \ \text{wakeup 一个等待者}

互斥锁

后厨唯一的卫生间门口挂着钥匙,规则升级:钥匙一次只借一人,借钥匙要登记主人姓名,还回来才能借下一个人。比餐票更严格的是,这把钥匙有“主人”。这就是互斥锁——信号量的特化版,却多了几个关键属性。

互斥锁本质是“计数恒为 1 的信号量”,但内核把 struct mutex 实现得更精细:它有 owner(所有者),记录当前持有锁的线程;有 wait_list,管理睡眠等待者;普通 mutex 禁止同线程重复获取(禁止递归),防止自己把自己锁死。获取锁时,快速路径直接原子拿下,失败则进入慢速路径,把当前任务挂到等待队列并调度出去。

互斥锁还内置了防止“优先级反转”的机制。看下面的公式:

“一人一钥匙 + 主人登记 + 紧急加急”,让互斥锁成为进程上下文长临界区的默认选择。需要区分的是:互斥锁与信号量在“能否睡眠”上一致,真正拉开差距的是所有者概念、递归禁令与优先级继承。

优先级继承:  PL 持锁∧PH 等锁 ⇒ priority(PL)←priority(PH)\text{优先级继承: } \ P_L\ \text{持锁} \wedge P_H\ \text{等锁} \ \Rightarrow \ \text{priority}(P_L) \leftarrow \text{priority}(P_H)
普通厨师正占着锅,主厨只能排队等——内核干脆把普通厨师临时提升到主厨的加急档,防止中间层任务插队。
  1. \(P_L\) 与 \(P_H\)低优先级任务与高优先级任务,好比普通厨师和主厨。
    PL=低优先级任务,PH=高优先级任务P_L = \text{低优先级任务}, \quad P_H = \text{高优先级任务}
  2. 持锁 ∧ 等锁低优先级任务正占着锁,高优先级任务只能在锁外排队。
    PL 持锁∧PH 等锁=冲突条件P_L\ \text{持锁} \wedge P_H\ \text{等锁} = \text{冲突条件}
  3. 优先级继承内核临时把持锁者的优先级提升到等待者级别,防止中间层任务插队。
    priority(PL)←priority(PH)\text{priority}(P_L) \leftarrow \text{priority}(P_H)
  4. 释放后恢复锁归还后,\(P_L\) 的优先级恢复原值,一切回归正常。
    unlock 后 priority(PL)=原值\text{unlock 后}\ \text{priority}(P_L) = \text{原值}

RCU机制

后厨角落有个书架,贴着一本《今日菜单》。几百位厨师随时来翻,极少有人改。如果每次翻书都要领钥匙排队,翻书反而比做菜还慢。图书馆想出办法:读者随便翻,写者要换新版时,先把新书摆上去,等所有还在看旧书的人合上书页,才把旧书收走。这就是 RCU(Read-Copy-Update,读-复制-更新)。

读侧几乎零成本:rcu_read_lock() 与 rcu_read_unlock() 在可抢占内核中只负责关闭、恢复本 CPU 抢占(或标记读者活跃),没有原子操作、没有自旋。写侧分三步:先复制一份旧数据(Copy),修改后再把指向旧数据的指针原子地换成新指针(Update),最后调用 synchronize_rcu() 或 call_rcu() 等待宽限期结束,回收旧数据(Reclaim)。

RCU 的适用前提有三条:读操作远多于写操作;数据通过指针访问且写者能以一条原子指令完成指针发布;读者不能在读临界区内睡眠或阻塞过久。内核中路由表、文件系统目录缓存(dcache)、网络连接跟踪表都受益于 RCU——在大模型推理框架的共享配置热更新里,这个思想同样被大量借鉴。

图书馆换版法用“等最后一页合上”代替“锁门”,换来千百位读者同时翻书互不干扰。代价是写者必须承受一段宽限期、旧数据晚回收——这正是读多写少场景的黄金制度。

tgrace=max⁡i∈readers(Dold)tiend;回收 Dold  ⟺  t>tgracet_{grace} = \max_{i \in \text{readers}(D_{old})} t_i^{\text{end}}; \qquad \text{回收}\, D_{old} \iff t > t_{grace}
换新版书后,必须等最慢的那位读者合上书页,才能把旧书收走。
  1. 读者集合在旧数据发布后、新指针发布前,已经开始读旧数据的读者全体。
    readers(Dold)=读旧数据的读者集合\text{readers}(D_{old}) = \text{读旧数据的读者集合}
  2. 读者结束时刻每个读者离开读临界区的时刻,也就是合上书页的时间点。
    tiend=第 i 个读者的读结束时刻t_i^{\text{end}} = \text{第 } i \text{ 个读者的读结束时刻}
  3. 取最大值宽限期必须覆盖所有读者,等最慢的那位合上书。
    tgrace=max⁡i∈readers(Dold)tiendt_{grace} = \max_{i \in \text{readers}(D_{old})} t_i^{\text{end}}
  4. 回收条件只有当前时间超过宽限期,写者才允许把旧数据从内存中释放。
    回收 Dold  ⟺  t>tgrace\text{回收}\, D_{old} \iff t > t_{grace}

选型与约束

四种制度都学会了,回到后厨当大厨:面对一口锅,该用哪种规矩?如果只是爆炒十秒——守锅(自旋锁);如果炖汤三小时,又不在突发铃声(中断)的紧要关头——钥匙(互斥锁);如果有多份同款调料要分配、且厨师可以打盹——餐票(信号量);如果这份菜谱每天被几千人看、只有半夜改一次——换版(RCU)。

决策表可以浓缩成下面的选择公式:

除选型外还有三条通用军规。其一,上下文决定睡眠权限:硬中断、软中断上下文只能使用原子操作与自旋锁(及其 irq 变体),绝不能用信号量、互斥锁这些会睡眠的原语。其二,抢占内核下持锁期间要防抢占:持有自旋锁的代码会被 preempt_disable 保护,防止本 CPU 被调度走。其三,锁顺序一致防死锁:A 锁着锅等电饭煲、B 锁着电饭煲等锅,两头都堵死;多把锁必须按固定顺序获取,临界区越短越好。内存屏障则在无锁读和跨 CPU 可见性场景中补齐顺序保证。

没有万能的制度,只有匹配场景的秩序。大厨心里有这张表,工程师心里也该有:先问上下文能不能睡,再看临界区长短,最后看读写比例——内核这座后厨,就能在千军万马中从容开火。

同步原语={自旋锁,tcs≪tsched∧禁止睡眠互斥锁,tcs 较长∧进程上下文信号量,计数资源∧可睡眠等待RCU,读多写少∧指针发布\text{同步原语} = \begin{cases} \text{自旋锁}, & t_{cs} \ll t_{sched} \wedge \text{禁止睡眠} \\ \text{互斥锁}, & t_{cs}\ \text{较长} \wedge \text{进程上下文} \\ \text{信号量}, & \text{计数资源} \wedge \text{可睡眠等待} \\ \text{RCU}, & \text{读多写少} \wedge \text{指针发布} \end{cases}
根据“临界区多长、能否睡眠、是否计数、读写比例”四个问题,对号入座。
  1. 判断条件一临界区极短且持锁期间不能睡眠,选自旋锁。
    tcs≪tsched∧禁止睡眠 ⇒ 自旋锁t_{cs} \ll t_{sched} \wedge \text{禁止睡眠} \ \Rightarrow \ \text{自旋锁}
  2. 判断条件二临界区较长且运行在进程上下文,选互斥锁。
    tcs 较长∧进程上下文 ⇒ 互斥锁t_{cs}\ \text{较长} \wedge \text{进程上下文} \ \Rightarrow \ \text{互斥锁}
  3. 判断条件三需要计数管理多份资源且允许睡眠,选信号量。
    计数资源∧可睡眠等待 ⇒ 信号量\text{计数资源} \wedge \text{可睡眠等待} \ \Rightarrow \ \text{信号量}
  4. 判断条件四读远多于写且数据能通过指针原子发布,选 RCU。
    读多写少∧指针发布 ⇒ RCU\text{读多写少} \wedge \text{指针发布} \ \Rightarrow \ \text{RCU}

AI 应用场景

网卡驱动收包队列的“守锅式”保护

阶段:数据面性能优化(推理集群的网络收发路径)

解决的问题:收包路径 NAPI 与上层释放 skb 同时操作环形队列索引。若用会睡眠的锁,收包中断上下文直接崩溃;若不加锁,索引竞态导致丢包。自旋锁配合关中断(spin_lock_irqsave)在纳秒级临界区内完成入队、出队,零睡眠、零丢包。

具体案例:某 25G 网卡驱动在多队列模式下,每个队列的 tail 指针用原子操作加自旋锁保护,单核收包吞吐从 60 万 pps 提升到 150 万 pps。

推理框架共享配置表的“换版式”更新

阶段:模型推理服务的配置热更新(典型读多写少)

解决的问题:推理引擎每毫秒被数百线程读取 batch size、模型路径等配置;加互斥锁导致读者互相阻塞、p99 延迟飙升。借鉴 RCU 思想:写者构造新配置并发布指针,读者无锁读取旧版或新版,宽限期后回收旧配置。

具体案例:自研推理网关在热更新 tokenizer 映射表时,从“全量加锁 30ms 停顿”优化为“无停顿热切换”,QPS 提升约 18%。

安全模块钩子列表的 RCU 保护

阶段:安全审计与访问控制(LSM 钩子遍历)

解决的问题:安全审计线程高频遍历钩子函数列表,同时安全策略更新要增删钩子。互斥锁让每次审计都付锁开销;RCU 让遍历无锁、更新等待宽限期后回收旧节点。

具体案例:企业安全审计平台在内核模块中维护回调列表,策略热更新无需重启服务,读者侧零阻塞。

Python 演示

python71 行
import threadingimport time COUNT = 50000EXPECTED = COUNT * 2  def race_run():    counter = {"v": 0}     def worker():        for _ in range(COUNT):            counter["v"] += 1     threads = [threading.Thread(target=worker) for _ in range(2)]    for t in threads:        t.start()    for t in threads:        t.join()    return counter["v"]  def protect_run(lock_factory):    counter = {"v": 0}    lock = lock_factory()     def worker():        for _ in range(COUNT):            lock.acquire()            counter["v"] += 1            lock.release()     threads = [threading.Thread(target=worker) for _ in range(2)]    t0 = time.time()    for t in threads:        t.start()    for t in threads:        t.join()    return counter["v"], time.time() - t0  def rcu_like_run():    table = {"version": 1, "items": ["a", "b"]}     def reader():        for _ in range(200):            snapshot = table["items"]  # 读侧不拿锁, 等价于读旧指针            assert snapshot in (["a", "b"], ["a", "b", "c"])     def writer():        table["items"] = ["a", "b", "c"]  # 原子发布新指针        table["version"] = 2        time.sleep(0.01)  # 模拟宽限期: 等旧读者读完再回收     rt = threading.Thread(target=reader)    wt = threading.Thread(target=writer)    for t in (rt, wt):        t.start()    for t in (rt, wt):        t.join()    return table["version"]  if __name__ == "__main__":    lost = race_run()    print("无锁(竞态)结果:", lost, "期望:", EXPECTED, "丢失:", EXPECTED - lost)    v, cost = protect_run(threading.Lock)    print("互斥锁保护结果:", v, "耗时:", round(cost, 3), "秒")    v2, cost2 = protect_run(lambda: threading.Semaphore(1))    print("信号量保护结果:", v2, "耗时:", round(cost2, 3), "秒")    print("RCU 模仿(读侧无锁)版本号:", rcu_like_run())
目的:用 Python 线程生动复现内核竞态(丢失更新),再用互斥锁、信号量修复,最后用读无锁的方式模仿 RCU 的指针发布与宽限期,帮助理解正文四种同步原语的行为差异。
  1. counter["v"] += 1一句看似简单的代码,实际拆成“取下标、加法、写回”多个字节码步骤,为线程切换留下缝隙,正是竞态根源。
  2. threads = [threading.Thread(target=worker) for _ in range(2)]创建两个“厨师”线程,相当于两个 CPU 执行流同时接近共享计数器。
  3. lock.acquire()` 与 `lock.release()手动划出临界区,把读-改-写串行化,对应内核中互斥锁和信号量的 P/V 操作。
  4. threading.Semaphore(1)计数为 1 的信号量也能保护互斥,代码结果与互斥锁一致,但语义上缺少 owner 概念。
  5. table["items"] = ["a", "b", "c"]一次性替换字典中的列表引用,相当于 RCU 写者原子发布新指针,读者无锁读取。
  6. time.sleep(0.01)模拟宽限期:写者等待旧读者全部读完,才允许回收旧数据。

常见误区

误区自旋锁会让 CPU 空转,肯定比会睡眠的互斥锁慢,以后一律用互斥锁。

正解睡眠不是免费的:一次完整的睡眠-唤醒要付出两次上下文切换,约微秒级代价。若临界区只有几百纳秒,自旋等待反而更快,还避免了调度延迟。守锅法适合“十秒出锅”的短活,去睡一觉再回来反而要一分钟。

为何常见:大家习惯用“是否浪费 CPU”来判断效率,忽略了睡眠本身的上下文切换开销与延迟抖动。

误区信号量计数改成 1 就是互斥锁,内核里没必要区分两者。

正解互斥锁多了 owner 记录、禁止递归、优先级继承等语义;信号量没有所有者概念,任何一个任务都能执行 V 归还,可能被不相干的任务误释放,也无法防止优先级反转。

为何常见:教科书常把“计数为 1 的信号量”说成互斥,忽略了现代内核在锁语义上的强化。

误区RCU 读侧不需要任何保护,随便读全局指针即可。

正解RCU 读侧要求读指针时必须处于 rcu_read_lock 保护内,在可抢占内核中它还会关闭本 CPU 抢占;如果读者睡眠,宽限期暴涨,旧数据迟迟无法回收,内存占用会失控。

为何常见:“读多写少 + 无锁”的宣传让大家忽略了 RCU 对“读者不睡眠”和“指针原子发布”的严格前提。

误区关中断能防止一切并发,干脆多关一会儿更保险。

正解关中断只挡住本 CPU 的中断,其他 CPU 仍可能同时访问同一数据;长时间关中断还会扩大中断延迟,引发实时性故障。必须“本地关中断 + 跨 CPU 锁”协同配合。

为何常见:单核时代“并发 = 中断打断”的直觉被带到了多核时代,忽略了多 CPU 是更大的并发源。

误区持锁后想睡就睡,反正锁会保护共享数据。

正解自旋锁持锁期间绝不能睡眠:等锁者自旋不睡,持锁者一旦睡着,CPU 永远等不到锁释放,直接死锁甚至系统挂起。只有信号量、互斥锁这类“可睡眠锁”允许持有者睡眠,用户态“边拿锁边等待”的习惯绝不能带进内核。

为何常见:没有区分锁的睡眠语义,把用户态编程习惯错误移植到内核同步模型中。

面试题

Q1. 下列关于自旋锁的说法,正确的是?

单选题 · easy

  • A.等待时会主动睡眠让出 CPU
  • B.适合保护极短的临界区← 正确答案
  • C.同一 CPU 可以递归获取
  • D.在单核上始终无法工作

答案:B。自旋锁是忙等,不会睡眠,故 A 错;临界区极短才是它的适用前提,B 对;普通自旋锁不支持递归获取,C 错;单核配合关中断仍可使用,D 错。就像守锅的帮厨只适合“爆炒十秒”,不能去炖汤。

Q2. 互斥锁与二进制信号量的核心区别是?

单选题 · easy

  • A.互斥锁有所有者且可继承优先级← 正确答案
  • B.信号量不能用于进程上下文
  • C.互斥锁一定比信号量速度更快
  • D.信号量只能初始化为数值一

答案:A。互斥锁记录 owner 并支持优先级继承,这是与信号量的本质差异,A 对。信号量常用于进程上下文,B 错;性能取决于场景而非类型,C 错;信号量计数可大于 1,D 错。好比卫生间钥匙能登记主人、还能紧急加急,餐票没有这套机制。

Q3. RCU 最典型的适用场景是?

单选题 · easy

  • A.读多写少的共享数据访问← 正确答案
  • B.写多读少的日志记录
  • C.读写次数完全均衡
  • D.只写不读的状态更新

答案:A。RCU 的设计目标就是让读者无锁、写者等宽限期,只有读远多于写才能摊薄写者的等待成本。写多读少或读写均衡场景更适合自旋锁、互斥锁。如同图书馆换版法,只有翻书的人远多于改书的人才有意义。

Q4. 持有自旋锁期间为什么绝对不能睡眠?

单选题 · mid

  • A.睡眠会让 CPU 进入低功耗状态
  • B.等锁者自旋不休所以必死锁← 正确答案
  • C.睡眠会触发调度器重置锁
  • D.锁会在睡眠时自动释放掉

答案:B。自旋锁的等锁者不会睡眠,而是一直转圈检查。若持锁者睡眠,锁永远不会被释放,等锁者将无限自旋,系统死锁。A、C、D 都不是自旋锁的行为。这就像守锅的帮厨绝不离开,你拿着锅去睡觉,他只能等到天荒地老。

Q5. 为什么中断路径要使用 spin_lock_irqsave?

单选题 · mid

  • A.防止本 CPU 中断打断持锁者← 正确答案
  • B.用中断屏蔽替代内存屏障
  • C.让等待者可以更早被唤醒
  • D.降低多核之间的锁竞争

答案:A。若持锁时本 CPU 来中断,而中断处理程序也争抢同一把锁,持锁者被中断打断、无法释放锁,中断处理程序又抢不到锁,形成死等。irqsave 先保存并关闭本 CPU 中断,防止这种打断。B、C、D 都不是它的核心目的。

Q6. 下列哪些场景适合使用信号量?

多选题 · mid

  • A.守护多份可用资源计数发放← 正确答案
  • B.生产消费队列的空位管理← 正确答案
  • C.保护一两条指令的计数器加一
  • D.中断上下文中保护寄存器

答案:AB。信号量的精髓是“计数 + 可睡眠”,多份资源发放与生产消费空位管理都依赖计数,A、B 正确。极短临界区应该用原子操作或自旋锁,C 错;中断上下文不能睡眠,不能使用信号量,D 错。

Q7. 关于互斥锁的 owner 与优先级继承,正确的是?

多选题 · mid

  • A.owner 记录当前持有锁的任务← 正确答案
  • B.允许任意次递归获取同一把锁
  • C.高优先级等锁时低优先级被提权← 正确答案
  • D.继承只发生在锁释放之后

答案:AC。互斥锁用 owner 记录持锁线程,A 对;普通 mutex 禁止递归,B 错;优先级继承发生在高优先级等锁期间,临时提升低优先级持锁者,C 对、D 错。

Q8. 关于 RCU 宽限期,下列说法正确的有?

多选题 · hard

  • A.必须等所有旧读者退出读区← 正确答案
  • B.读者必须使用锁保护读操作
  • C.旧数据在宽限期结束后回收← 正确答案
  • D.写者更新指针必须是原子的← 正确答案

答案:ACD。宽限期定义为最旧读者结束时刻,写者必须等所有读旧数据的读者退出,A 对;RCU 读侧无锁,B 错;延迟回收的前提是宽限期结束,C 对;指针发布必须原子完成,D 对。

Q9. 自旋锁在单核非抢占内核中,配合关中断后为何近乎空操作?

单选题 · hard

  • A.同一时刻只有单一执行流← 正确答案
  • B.中断会被自动全部屏蔽掉
  • C.内存屏障让锁失去意义
  • D.锁竞争速度变得极快

答案:A。单核非抢占内核中,没有第二个执行流会打断当前任务;配合关中断后连中断也被排除,唯一运行流就是持锁者,自旋检查永远失败不了,锁自然退化为空操作。A 正是“没有并发 = 不需要锁”的原因。

Q10. RCU 写者发布新指针时为何需要内存屏障配合?

单选题 · extreme

  • A.防止指针先于数据内容被读者看到← 正确答案
  • B.降低处理器在等锁时的功耗
  • C.加快旧数据在宽限期后的回收
  • D.减少多核缓存行的同步开销

答案:A。CPU 与编译器可能乱序执行,若不设屏障,读者可能先看到新指针,再去读尚未写完的数据,读到半新半旧的脏状态。内存屏障保证“数据写入完成”先于“指针发布”可见。功耗、回收、缓存同步都不是写者屏障的目的。