互斥锁
2.3.2 的硬件指令(TS、Swap)解决了”检查+上锁”的原子性问题,但直接用指令写代码太原始。互斥锁就是把它包装成一对好用的接口。
这一节最需要注意的不是机制,而是术语——教材在这里把两个概念合并了,读得越仔细越容易困惑。
机制
最简单的锁
互斥锁只需要一个布尔变量和两个操作:
bool available = true; // 锁是否可用
void acquire() { // 进入临界区前调用
while (!available) // 忙等,直到锁可用
;
available = false; // 上锁
}
void release() { // 退出临界区后调用
available = true; // 解锁
}使用方式就是把它填进 2.3.1 的四段式结构:
acquire(); // 进入区
critical section; // 临界区
release(); // 退出区
remainder section; // 剩余区关键要求:acquire() 和 release() 的执行必须是原子操作。
原因就是 2.3.2 里”双标志先检查法”的教训——while (!available) 和 available = false 之间若能被打断,两个进程就会同时通过。所以互斥锁通常用硬件机制(TS、Swap、CAS)来实现,而不是像上面那样用普通赋值语句。上面的代码是语义描述,不是可用实现。
优缺点
缺点:忙等待(busy waiting)。
当有进程在临界区里时,其他进程会在 while 循环里不停地调用检查,白白消耗 CPU 周期。这违反了四准则中的让权等待。
优点:等待期间没有上下文切换。
这是它真正的价值所在。相比之下,“阻塞”式的等待要经历:保存现场 → 挂入等待队列 → 调度别的进程 → 将来被唤醒 → 恢复现场。这一套的开销可能比临界区本身还大。
因此适用场景非常明确:多处理器系统 + 临界区很短。
在多处理器上,一个线程可以在某个核上自旋,而持有锁的线程在另一个核上继续执行并很快释放锁。自旋几十个周期就拿到锁,远比切换划算。
反过来,单处理器上自旋是纯粹的浪费:持有锁的那个进程此刻根本没在运行(因为 CPU 被自旋的这个占着),自旋者要一直转到自己的时间片耗尽为止,锁才有可能被释放。
边界
互斥锁与自旋锁:教材的口径与通行口径
疑问点:互斥锁是否就是自旋锁,二者关系应如何记忆
教材原文:“上面描述的互斥锁也称自旋锁,其主要缺点是忙等待……因此,互斥锁通常用于多处理器系统,一个线程可以在一个处理器上旋转,而不影响其他线程的执行。”
但同一段前文又说”当一个进程试图获取不可用的锁时,会被阻塞”。 究竟应当按哪种口径记忆?两种说法似乎都能讲通。
两种说法都能讲通,因为它们描述的层次不同。这个困惑是有根据的,需要分三层讲清。
第一层:教材在这一节描述的是一个具体实现。
教材给出的 acquire() 用的是 while (!available);——这是忙等。忙等的锁就是自旋锁。所以教材说”上面描述的互斥锁也称自旋锁”,这句话限定在”上面描述的”这个实现上,完全正确。
第二层:教材前文那句”会被阻塞”是宽泛用法。
那里的”阻塞”指的是”进不去、被挡住”,不是进程状态意义上的阻塞态。这是同一个词的两种用法。紧接着的”其主要缺点是忙等待”已经点明了:它并没有真的进入阻塞态让出 CPU,而是在原地转圈。
第三层:从概念上说,自旋锁是互斥锁的一种。
“互斥锁”这个名字只规定了目的(提供互斥),没有规定等不到时怎么办。等待方式有两种选择:
| 等不到锁时 | 名称 | 开销 | |
|---|---|---|---|
| 自旋 | 原地循环检查,不让出 CPU | 自旋锁 | 无切换,但耗 CPU |
| 阻塞 | 挂入等待队列,让出 CPU | 阻塞型互斥锁 | 有切换开销,但不耗 CPU |
所以准确的关系是:自旋锁 ⊂ 互斥锁。
考试怎么答:按教材口径。凡题目描述了 while(!available); 这样的忙等实现,就按自旋锁的性质作答——主要缺点是忙等待、违反让权等待、适用于多处理器系统、优点是无上下文切换。
持有自旋锁时能否进行调度
疑问点:持有自旋锁期间能否发生进程调度
教材称”持有自旋锁时不能睡眠,不能做可能阻塞的操作,临界区必须很短”。 那么此时能否进行进程调度?
不能,而且理由与 2.2.2 的”内核程序临界区禁止调度”完全一致——事实上,自旋锁正是保护内核程序临界区的那把锁。
两个层次的原因:
第一,会造成大规模空转。 假设持有锁的进程被换下 CPU,那么其他 CPU 上所有等这把锁的进程会持续自旋一整个时间片——它们在等一个此刻根本没在运行的进程释放锁。锁的持有时间从”几十个周期”暴涨到”一个时间片”,自旋锁的全部优势荡然无存。
第二,同一 CPU 上可能直接死锁。 若持有锁的进程被切换后,新调度上来的进程(或一个中断处理程序)也要获取同一把锁,它会开始自旋等待。但持有锁的那个进程已经被换下去了,在当前 CPU 上永远得不到执行机会去释放锁——自旋者转到天荒地老。
因此实际系统的做法是:获取自旋锁时同时关闭内核抢占(Linux 的 spin_lock() 内部就包含 preempt_disable());若该锁还可能被中断处理程序获取,则还要一并关中断(spin_lock_irqsave())。
这也解释了教材那句”不能睡眠、不能做可能阻塞的操作”:睡眠意味着让出 CPU,而让出 CPU 就会触发上述两个问题。
自旋锁是操作系统层面的还是应用层面的
疑问点:自旋锁属于操作系统机制还是应用程序自行实现
自旋锁具体由什么实现?以 Java 并发包(JUC)为例,其底层是什么。
两个层面都有,因为自旋锁的本质要求只有一条:一条硬件原子指令。
只要能拿到 TS、Swap 或 CAS(x86 的 cmpxchg),任何一层的代码都能自己实现自旋锁。它不是操作系统的专属机制。
内核层面:Linux 的 spin_lock() 是典型的内核自旋锁,专门用于保护内核数据结构。内核必须用自旋锁而不能用阻塞锁的场景是中断上下文——中断处理程序不能睡眠。
用户层面:JUC 的锁建立在 CAS 之上,但不是纯自旋锁。
以 ReentrantLock 为例,它基于 AQS(AbstractQueuedSynchronizer):
- AQS 内部有一个
volatile int state,通过 CAS 尝试把它从 0 改成 1 来抢锁。这一步是无锁的原子操作,由Unsafe.compareAndSwapInt最终落到 CPU 的lock cmpxchg指令。 - 抢不到时,线程进入 AQS 的 CLH 等待队列,会先短暂自旋几次(前驱是头节点时值得再试一下)。
- 自旋若干次仍拿不到,就调用
LockSupport.park()—— 这会通过futex系统调用真正陷入内核并阻塞,让出 CPU。
所以 JUC 的锁是”自适应”的:先自旋一小会儿赌锁马上释放,赌输了就转为阻塞。 这正是对本节那张表的工程化折中——短临界区享受自旋的低开销,长临界区避免自旋的浪费。
synchronized 关键字的路径类似:轻量级锁阶段用 CAS 自旋,竞争加剧后膨胀为重量级锁,底层用操作系统互斥量,线程真正阻塞。
关联对照:JVM 的偏向锁不属于自旋锁
偏向锁与自旋锁不是一回事,这个判断是准确的。
偏向锁优化的是完全没有竞争的场景:把获得锁的线程 ID 直接记录在对象头的 Mark Word 里,同一个线程之后再进入这个同步块时,连 CAS 都不需要执行,只做一次线程 ID 比对。
它解决的问题是”无竞争时 CAS 也是有成本的”,而自旋锁解决的是”有竞争时如何等待”。两者针对的是完全相反的场景。
补充一点背景:偏向锁自 JDK 15 起已被默认禁用并标记为废弃,之后从 HotSpot 中移除。原因是它使 JVM 同步子系统复杂度大增,而它所优化的那类单线程重复加锁场景在现代应用中已不常见。
这部分内容不在 408 考纲内,此处列出仅为把概念区分开。
互斥锁 vs 信号量
互斥锁只能取两个值(可用/不可用),只能解决互斥;信号量可以是任意非负整数,既能解决互斥(初值为 1),也能解决同步(初值为 0 或 n)。
所以互斥锁可以看作信号量的一个特例——初值为 1 的互斥信号量。但反过来不成立。
对照速查
| 自旋锁(教材所述互斥锁) | 阻塞型互斥锁 | |
|---|---|---|
| 等不到锁时 | 原地循环,不让出 CPU | 挂入等待队列,让出 CPU |
| 让权等待 | 违反 | 满足 |
| 上下文切换 | 无 | 有 |
| 适用处理机 | 多处理器 | 单/多均可 |
| 适用临界区 | 很短 | 较长 |
| 主要缺点 | 忙等待 | 切换开销 |
| 层面 | 实现 | 依赖 |
|---|---|---|
| 硬件 | TS / Swap / CAS(cmpxchg) | — |
| 内核 | Linux spin_lock()(含关抢占) | 硬件原子指令 |
| 用户态 | JUC:CAS + 短自旋 + park() 阻塞 | 硬件原子指令 + futex |
考点
- 互斥锁的
acquire()/release()必须是原子操作,因此需硬件支持 - 主要缺点是忙等待,违反让权等待
- 优点是无上下文切换,因此适用于多处理器 + 短临界区
- 单处理器上自旋是纯浪费(持有锁者此刻根本没在运行)
- 教材口径:所描述的互斥锁即自旋锁;概念上自旋锁 ⊂ 互斥锁
- 互斥锁只能解决互斥,信号量还能解决同步
链接
- 🏠 返回总览:操作系统第 2 章:进程与线程总览
- ⬅️ 上一节:2.3.2 实现临界区互斥的基本方法
- ➡️ 下一节:2.3.4 信号量
- 🔗 为何持锁期间禁止调度,见 2.2.2 调度的实现
- 📖 名词库:第 2 章名词库