☰
【嵌入式linux学习】自旋锁 互斥锁 原子变量
2026/10/13 9:33:43 网站建设 项目流程

【嵌入式Linux学习】自旋锁、互斥锁、原子变量

从一个高频问题出发:自旋锁为什么不能睡眠,mutex 为什么可以睡眠?

  1. 本质:spinlock 是忙等锁、mutex 是睡眠锁、atomic 是硬件原子指令——三者定位完全不同。
  2. 核心问题:为什么 spinlock 不能睡眠(死锁 + 中断上下文),mutex 为什么可以睡眠(等待队列机制)。
  3. atomic:单条指令原子、多条逻辑不原子,它的边界在哪。
  4. 落地选型:对比表、场景规则。

文章目录

  • 【嵌入式Linux学习】自旋锁、互斥锁、原子变量
    • 1. 本质:三种同步手段的定位
    • 2. 为什么 spinlock 不能睡眠
      • 2.1 原因一:持有者一旦睡眠,就会死锁
      • 2.2 原因二:自旋锁很多场景运行在中断上下文
      • 2.3 spinlock 的三条使用约束
    • 3. 为什么 mutex 可以睡眠
    • 4. spinlock vs mutex 对比表
    • 5. atomic 原子变量
      • 5.1 是什么
      • 5.2 原理:CPU 硬件原子指令
      • 5.3 边界:只能保护单个整数,不能保护多语句临界区
      • 5.4 拓展:refcount_t
    • 6. 总对比
    • 7. 什么场景用什么
    • 8. 互斥锁完整流程

1. 本质:三种同步手段的定位

手段本质拿不到锁 / 资源时的行为一句话总结
spinlock 自旋锁忙等锁CPU 原地循环自旋,不放弃 CPU持有者必须一直在 CPU 上,尽快释放锁
mutex 互斥锁睡眠锁进程加入等待队列,调用 schedule 让出 CPU,进入睡眠拿不到就睡,锁释放后被唤醒
atomic 原子变量硬件原子指令不涉及等待,操作直接完成单个整数操作的天然互斥

设计前提不同,决定了它们的行为完全不同:spinlock 假设「持有锁的线程必须一直在 CPU 上运行,尽快释放锁」;mutex 假设「持有者和等待者都可以睡眠」。


2. 为什么 spinlock 不能睡眠

2.1 原因一:持有者一旦睡眠,就会死锁

先看代码:

spin_lock(&lock);// 临界区,拿到锁了xxx_func();// 假设这个函数内部发生睡眠!spin_unlock(&lock);

场景推演:

  1. CPU0 上任务 A 拿到 spinlock,preempt_count++,关闭内核抢占;
  2. A 在持有锁的情况下进入睡眠,主动调用schedule()让出 CPU——A 被切出 CPU,休眠了,没有机会执行spin_unlock释放锁;
  3. CPU1 上任务 B 执行spin_lock(&lock),想要同一把锁;
  4. 锁还被 A 持有,而 A 在睡觉、不在 CPU 上跑,永远不会解锁;B 就在 CPU1 上无限循环自旋忙等。

结果:CPU1 100% 占用空转,系统卡死。锁持有者睡着了,等待者空转——这就是死锁

关键补充:自旋锁持有期间,内核抢占已被preempt_count++关闭,本 CPU 不会被别人抢占;但代码自己主动调用schedule()睡眠,是不受preempt_count拦截的!preempt_count只是「禁止被别人抢占」,拦不住代码自己主动睡觉——这正是持有 spinlock 不能睡眠的根源。

2.2 原因二:自旋锁很多场景运行在中断上下文

自旋锁经常用来和中断配合,比如spin_lock_irqsave():获取锁的同时关闭本地中断,防止中断上下文竞争同一把锁。

中断上下文是不能睡眠的!中断上下文没有进程上下文、没有 task_struct,不能睡眠、不能调度。如果 spinlock 允许睡眠,一旦在中断上下文拿到锁后触发睡眠,直接内核 Oops 崩溃。

2.3 spinlock 的三条使用约束

  1. 持有者不能睡眠(不能主动schedule);
  2. 持有者不能被抢占(靠preempt_count关闭内核抢占);
  3. 临界区代码要短小、快速执行完立刻 unlock。

内核有调试检测:如果检测到持有 spinlock 时发生睡眠,直接打印 BUG、Oops。


3. 为什么 mutex 可以睡眠

mutex 不是忙等。拿不到锁的时候,进程去等待队列睡觉、让出 CPU,不会原地自旋。

mutex_lock(&mtx);// 临界区mutex_unlock(&mtx);

机制推演:

  1. 任务 A 拿到 mutex 锁。A可以被抢占、可以睡眠——哪怕 A 被调度切出 CPU 去睡觉也没关系,锁依旧属于 A;
  2. 任务 B 调用mutex_lock()拿不到锁:
    • B 把自己加入 mutex 的等待队列;
    • 设置进程睡眠状态;
    • 调用schedule()主动让出 CPU,CPU 去跑其他任务。B 休眠,不消耗 CPU;
  3. 后续 A 执行mutex_unlock()释放锁,会唤醒等待队列上的 B;B 被唤醒后继续执行、拿到锁。

关键点:

  • 等待者不自旋,直接睡觉,不会空耗 CPU;
  • 持有者就算被切走睡眠也没问题:等持有者再次被调度上 CPU,会继续运行到mutex_unlock做解锁,再唤醒等待者。

⚠️ 但 mutex 有自己的限制:mutex 不能在中断上下文使用!因为中断上下文不允许睡眠,而mutex_lock拿不到锁会睡眠,中断里调用mutex_lock直接 Oops。中断上下文只能用 spinlock 系列锁。


4. spinlock vs mutex 对比表

特性spinlock 自旋锁mutex 互斥锁
拿不到锁行为CPU 原地自旋忙等进程加入等待队列,睡眠,让出 CPU
持有者是否允许睡眠❌ 绝对禁止,会死锁✅ 允许持有者睡眠、被抢占
是否关闭内核抢占✅ 获取锁时preempt_count++关闭抢占❌ 不操作 preempt_count,不关闭抢占
临界区长短适合短小、快速执行的临界区适合可以长时间操作的临界区
能否用于中断上下文✅ 可以(spin_lock_irqsave)❌ 禁止,拿不到锁会睡眠,中断不能睡眠
性能临界区很短时性能好;自旋消耗 CPU上下文切换有开销,适合长临界区

5. atomic 原子变量

5.1 是什么

atomic_t(32 位)/atomic64_t(64 位),内核提供的原子整数类型:

atomic_tcnt;atomic_set(&cnt,0);atomic_inc(&cnt);// 原子自增atomic_dec(&cnt);atomic_read(&cnt);

原子的含义:这一条操作,CPU 硬件层面不可分割——要么完整做完,要么完全不做,中间不会被打断。

5.2 原理:CPU 硬件原子指令

普通的cnt++,C 语言一行代码,CPU 会拆成 3 条汇编:

读寄存器 ← cnt 寄存器 += 1 cnt ← 寄存器

多核 SMP 下,两个 CPU 同时执行这 3 步,会发生数据竞争,结果出错。

而atomic_inc(&cnt)是 CPU 提供的原子指令(比如 x86 的lock add):整个「读-改-写」作为一条硬件原子操作,多核之间不会被打断,不需要锁保护。

✅ 核心:atomic 依靠CPU 硬件指令实现互斥,不需要锁,没有锁开销。

5.3 边界:只能保护单个整数,不能保护多语句临界区

atomic 只适用于简单的整数算术操作:自增、自减、加减、比较交换。不能保护一段由多条语句组成的临界区!

❌ 错误示例,很多人踩坑:

// 想保护一段逻辑,atomic 做不到if(atomic_read(&flag)==1){do_a();do_b();atomic_set(&flag,0);}

虽然每一条 atomic 函数本身是原子的,但atomic_read()和后面的do_a()、atomic_set()之间会被打断,多核其他 CPU 可以穿插执行,整个 if 分支不是原子的。

atomic 只能保护单个整数操作;不能保护多条业务逻辑、多个变量。如果临界区是好几行代码、要改多个变量 → 不能用 atomic,必须 spinlock / mutex。

5.4 拓展:refcount_t

内核现在推荐用refcount_t代替单纯的atomic_t做引用计数:它在原子操作之上增加了溢出防护,防止整数溢出漏洞,底层仍然是硬件原子指令。


6. 总对比

特性atomic 原子变量spinlock 自旋锁mutex 互斥锁
实现方式CPU 硬件原子指令忙等自旋循环等待队列 + 睡眠调度
保护范围仅单个整数变量任意代码临界区(多行代码、多个变量)任意代码临界区
拿不到资源行为不涉及等待,操作直接完成CPU 原地自旋忙等进程睡眠,让出 CPU
是否修改preempt_count❌ 不修改✅preempt_count++关闭内核抢占❌ 完全不操作抢占计数器
持有者能否睡眠本来就没有持有者概念❌ 严禁睡眠,死锁风险✅ 允许睡眠、允许被抢占
中断上下文使用✅ 可以✅ 可以(irqsave 版本)❌ 禁止,会睡眠
开销极小,硬件指令,几乎无开销临界区短时开销小;自旋会耗 CPU存在上下文切换,开销偏大
适用场景计数器、引用计数(refcount)、简单 flag 标记临界区简短;可能在中断访问共享数据临界区较长;中间可能睡眠 / 等待 IO

典型 atomic 使用场景:内核引用计数(模块引用、refcount_t)、统计计数器。

  1. atomic 原子变量依靠 CPU 硬件原子指令,只能完成单个整数的原子加减,适合计数器。不能保护多行代码临界区,没有锁的概念,可以在中断上下文使用。

  2. spinlock 自旋锁是忙等锁,拿不到锁 CPU 原地自旋。可以保护任意临界区;获取锁会增加preempt_count关闭内核抢占;临界区必须短小快速;持有者不能睡眠,支持中断上下文;如果持有锁睡眠会死锁。

  3. mutex 互斥锁是睡眠锁。拿不到锁时进程进入等待队列睡眠、让出 CPU。持有者允许抢占、允许睡眠;适合比较长的临界区;但 mutex 不能用于中断上下文,因为拿不到锁会睡眠,中断上下文不允许睡眠。

  4. 自旋锁是忙等锁:拿不到锁会原地循环自旋,不释放 CPU。它的前提是锁持有者必须持续在 CPU 上运行、尽快释放锁。如果持有自旋锁时睡眠,持有者被切出 CPU、无法执行解锁;其他 CPU 上等待该锁的执行流会无限自旋,发生死锁,系统卡死。另外,自旋锁可用于中断上下文,而中断上下文本身不允许睡眠。

  5. mutex 是睡眠锁:拿不到 mutex 锁时,进程不会自旋,而是加入等待队列、调用schedule让出 CPU 进入睡眠,不消耗 CPU。即使锁持有者被调度切出去睡眠也没关系,等持有者再次被调度运行后,最终会执行解锁,解锁时会唤醒等待队列上休眠的进程。

  6. 注意区别:spin_lock会操作preempt_count关闭内核抢占;mutex 不修改抢占计数器,允许内核抢占。同时 mutex 不能在中断上下文使用,因为拿不到锁会睡眠,而中断上下文禁止睡眠。

7. 什么场景用什么

  1. 仅仅做计数器ref++ / ref--→atomic_t / refcount_t
  2. 需要保护一小段代码,会被中断访问,代码执行很快 →spinlock
  3. 保护一大段逻辑,中间可能调用kmalloc、wait、IO 等待、会睡眠 →mutex

追问:为什么atomic_inc之后还需要锁?

单条atomic_inc本身安全。但如果你的逻辑是「读原子变量 → 做判断 → 再修改」,多条操作组合在一起,整体不具备原子性,这时仍然需要 spinlock / mutex 保护整个逻辑块。

8. 互斥锁完整流程

mutex_lock()核心简化流程:

  1. 尝试原子操作抢占锁(fast path),成功直接返回。
  2. 抢锁失败 →把当前进程封装成mutex_waiter,加入这个 mutex 的等待队列。
  3. 设置进程状态为睡眠(TASK_UNINTERRUPTIBLE)。
  4. 调用schedule(),主动让出 CPU,进程从运行队列摘除,进入睡眠,不再占用 CPU。

此时这个进程就卡在等待队列上,不会自己跑,必须别人唤醒。

谁来唤醒?mutex_unlock()的释放者

当持有锁的进程调用mutex_unlock():

  1. 释放锁(原子修改 mutex owner 状态)。
  2. 检查 mutex 的等待队列上有没有等待者。
  3. 如果有等待进程:调用wake_up_process()唤醒等待队列里的等待者。
    • 唤醒动作,是当前正在执行 unlock 的进程上下文做的,不是中断,不是内核后台线程。
  4. 被唤醒的进程:状态变为 TASK_RUNNABLE,被丢进 CPU 就绪队列,等待调度器选中它运行。

⚠️ 重点区分:

  • 唤醒者:释放锁的进程(unlock 的调用方)
  • 被唤醒者:之前在 mutex 等待队列上睡眠的进程
  • 调度器只负责 “选进程上 CPU”,调度器不会主动唤醒 mutex 等待进程。

唤醒之后,就能拿到锁吗?

❌唤醒 ≠ 拿到锁
唤醒只是把进程变成就绪态,等待被调度运行。

等这个进程重新被调度上 CPU,回到mutex_lock剩下的代码,再次尝试抢锁。

有可能在它被唤醒、还没来得及运行的间隙,又有别的进程(fast path)抢先拿到锁,那它又会再次睡眠。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询