【嵌入式Linux学习】自旋锁、互斥锁、原子变量
从一个高频问题出发:自旋锁为什么不能睡眠,mutex 为什么可以睡眠?
- 本质:spinlock 是忙等锁、mutex 是睡眠锁、atomic 是硬件原子指令——三者定位完全不同。
- 核心问题:为什么 spinlock 不能睡眠(死锁 + 中断上下文),mutex 为什么可以睡眠(等待队列机制)。
- atomic:单条指令原子、多条逻辑不原子,它的边界在哪。
- 落地选型:对比表、场景规则。
文章目录
- 【嵌入式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);场景推演:
- CPU0 上任务 A 拿到 spinlock,
preempt_count++,关闭内核抢占; - A 在持有锁的情况下进入睡眠,主动调用
schedule()让出 CPU——A 被切出 CPU,休眠了,没有机会执行spin_unlock释放锁; - CPU1 上任务 B 执行
spin_lock(&lock),想要同一把锁; - 锁还被 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 的三条使用约束
- 持有者不能睡眠(不能主动
schedule); - 持有者不能被抢占(靠
preempt_count关闭内核抢占); - 临界区代码要短小、快速执行完立刻 unlock。
内核有调试检测:如果检测到持有 spinlock 时发生睡眠,直接打印 BUG、Oops。
3. 为什么 mutex 可以睡眠
mutex 不是忙等。拿不到锁的时候,进程去等待队列睡觉、让出 CPU,不会原地自旋。
mutex_lock(&mtx);// 临界区mutex_unlock(&mtx);机制推演:
- 任务 A 拿到 mutex 锁。A可以被抢占、可以睡眠——哪怕 A 被调度切出 CPU 去睡觉也没关系,锁依旧属于 A;
- 任务 B 调用
mutex_lock()拿不到锁:- B 把自己加入 mutex 的等待队列;
- 设置进程睡眠状态;
- 调用
schedule()主动让出 CPU,CPU 去跑其他任务。B 休眠,不消耗 CPU;
- 后续 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)、统计计数器。
atomic 原子变量依靠 CPU 硬件原子指令,只能完成单个整数的原子加减,适合计数器。不能保护多行代码临界区,没有锁的概念,可以在中断上下文使用。
spinlock 自旋锁是忙等锁,拿不到锁 CPU 原地自旋。可以保护任意临界区;获取锁会增加
preempt_count关闭内核抢占;临界区必须短小快速;持有者不能睡眠,支持中断上下文;如果持有锁睡眠会死锁。mutex 互斥锁是睡眠锁。拿不到锁时进程进入等待队列睡眠、让出 CPU。持有者允许抢占、允许睡眠;适合比较长的临界区;但 mutex 不能用于中断上下文,因为拿不到锁会睡眠,中断上下文不允许睡眠。
自旋锁是忙等锁:拿不到锁会原地循环自旋,不释放 CPU。它的前提是锁持有者必须持续在 CPU 上运行、尽快释放锁。如果持有自旋锁时睡眠,持有者被切出 CPU、无法执行解锁;其他 CPU 上等待该锁的执行流会无限自旋,发生死锁,系统卡死。另外,自旋锁可用于中断上下文,而中断上下文本身不允许睡眠。
mutex 是睡眠锁:拿不到 mutex 锁时,进程不会自旋,而是加入等待队列、调用
schedule让出 CPU 进入睡眠,不消耗 CPU。即使锁持有者被调度切出去睡眠也没关系,等持有者再次被调度运行后,最终会执行解锁,解锁时会唤醒等待队列上休眠的进程。注意区别:
spin_lock会操作preempt_count关闭内核抢占;mutex 不修改抢占计数器,允许内核抢占。同时 mutex 不能在中断上下文使用,因为拿不到锁会睡眠,而中断上下文禁止睡眠。
7. 什么场景用什么
- 仅仅做计数器
ref++ / ref--→atomic_t / refcount_t - 需要保护一小段代码,会被中断访问,代码执行很快 →spinlock
- 保护一大段逻辑,中间可能调用
kmalloc、wait、IO 等待、会睡眠 →mutex
追问:为什么
atomic_inc之后还需要锁?单条
atomic_inc本身安全。但如果你的逻辑是「读原子变量 → 做判断 → 再修改」,多条操作组合在一起,整体不具备原子性,这时仍然需要 spinlock / mutex 保护整个逻辑块。
8. 互斥锁完整流程
mutex_lock()核心简化流程:
- 尝试原子操作抢占锁(fast path),成功直接返回。
- 抢锁失败 →把当前进程封装成
mutex_waiter,加入这个 mutex 的等待队列。 - 设置进程状态为睡眠(TASK_UNINTERRUPTIBLE)。
- 调用
schedule(),主动让出 CPU,进程从运行队列摘除,进入睡眠,不再占用 CPU。
此时这个进程就卡在等待队列上,不会自己跑,必须别人唤醒。
谁来唤醒?mutex_unlock()的释放者
当持有锁的进程调用mutex_unlock():
- 释放锁(原子修改 mutex owner 状态)。
- 检查 mutex 的等待队列上有没有等待者。
- 如果有等待进程:调用
wake_up_process()唤醒等待队列里的等待者。- 唤醒动作,是当前正在执行 unlock 的进程上下文做的,不是中断,不是内核后台线程。
- 被唤醒的进程:状态变为 TASK_RUNNABLE,被丢进 CPU 就绪队列,等待调度器选中它运行。
⚠️ 重点区分:
- 唤醒者:释放锁的进程(unlock 的调用方)
- 被唤醒者:之前在 mutex 等待队列上睡眠的进程
- 调度器只负责 “选进程上 CPU”,调度器不会主动唤醒 mutex 等待进程。
唤醒之后,就能拿到锁吗?
❌唤醒 ≠ 拿到锁
唤醒只是把进程变成就绪态,等待被调度运行。
等这个进程重新被调度上 CPU,回到mutex_lock剩下的代码,再次尝试抢锁。
有可能在它被唤醒、还没来得及运行的间隙,又有别的进程(fast path)抢先拿到锁,那它又会再次睡眠。