Appearance
锁
2026 大纲 二(三)3 锁。
把进入区和退出区包起来,叫它锁
上一节的各种写法有个共同的不方便:每次要保护一段临界区, 都得把进入区那几行代码原样抄一遍。抄错一个字(比如 Peterson 里 turn 赋反了) 就前功尽弃。
自然的做法是封装:把进入区包成 acquire(),把退出区包成 release()。 这就是锁。
⚠️ 所以锁不是一种新机制,它只是对底层互斥手段的一层封装—— 底下可以是 TSL、可以是 Swap、也可以是信号量。 上一节那四条准则可以原样拿来评价一把锁。
封装之后有一个新的设计问题浮出来:拿不到锁的时候干什么?
两种选择,也就是两类锁:一直循环重试(自旋锁,忙等)、 让出处理机去睡(互斥锁,阻塞)。 这一条就是两者唯一的分界,剩下的差别全是它的推论。
判据是比临界区长度与两次上下文切换的开销:临界区很短时, 自旋几圈就等到了,比睡一觉再被叫醒便宜;临界区长则反过来。
⚠️ 还有一条边界要立住:自旋锁在单处理机上毫无意义。 因为持锁者此刻并没有在运行(CPU 被自旋的这个进程占着), 它等的是一个当下不可能被释放的锁——除非被时钟中断切走,否则死等。
一、锁封装了什么
acquire(lock); // 获取锁 —— 就是「进入区」
临界区;
release(lock); // 释放锁 —— 就是「退出区」二、自旋锁
c
// 基于 TSL 实现
void acquire(bool *lock) {
while (TestAndSet(lock)); // 一直循环直到读到旧值 false
}
void release(bool *lock) {
*lock = false;
}自旋等待的那个条件,只能由被自旋者挤下去的那个进程来改变——这就是单处理机上自旋无意义的全部理由。多处理机上持锁者能在另一个核上并行跑完临界区,转几圈就可能等到锁被释放,"转"才比"切出去再切回来"划算。
多核上的改法(思路比代码重要)——先只读地自旋,看起来空了再动手抢:
c
void acquire(bool *lock) {
while (true) {
while (*lock) ; // ① 只读自旋:命中本核缓存,不产生写
if (!TestAndSet(lock)) return; // ② 看着像空的,才真正去抢
}
}① 只读,缓存行可被多个核共享持有,不会互相踢;只有在锁看起来空了时才走 ②,产生一次写。这样把"每圈一次写"降成"锁状态变化时才写一次"。
三、互斥锁
void acquire(lock) {
if (lock 已被占用) {
将自己加入该锁的等待队列;
block(); // 让出处理机,进入阻塞态
}
lock = 已占用;
}
void release(lock) {
lock = 可用;
if (等待队列非空)
wakeup(队首进程); // 把一个等待者移入就绪队列
}它满足全部四条准则,代价是每次等待要付两次上下文切换——而上下文切换不只是保存/恢复寄存器,它还会让高速缓存和 TLB 里属于原进程的内容失效,切回来时要重新预热。
四、两种锁的对照
| 判断维度 | 选自旋锁 | 选互斥锁 |
|---|---|---|
| 处理机数 | 多处理机(持锁者能在别的核上推进) | 单处理机、多处理机都可以 |
| 临界区长度 | 很短(短于两次上下文切换) | 较长或长度不可预知 |
| 调用位置 | 中断处理程序等不能睡眠的上下文 | 进程上下文 |
| 让权等待 | 不满足 | 满足 |
| 主要开销 | 空转的处理机时间 + 跨核缓存失效 | 两次上下文切换 + 缓存/TLB 预热 |
五、锁的粒度
| 粗粒度(一把大锁) | 细粒度(多把小锁) | |
|---|---|---|
| 做法 | 整个数据结构一把锁 | 按分区/桶/行各一把锁 |
| 并发度 | 低——互不相干的操作也被串行化 | 高——不同分区可并行 |
| 单次开销 | 小,加解锁各一次 | 大,可能要连续拿多把锁 |
| 出错风险 | 低 | 高:多把锁就可能形成循环等待 |
| 什么时候合适 | 竞争本来就少,或临界区极短 | 竞争激烈且访问天然可分区 |
推导链:锁的作用是把并发变成串行 ⇒ 被锁圈住的范围越大,被强制串行的工作越多 ⇒ 想提高并发就得把范围切小 ⇒ 切小意味着一次操作可能要同时拿多把锁 ⇒ 多把锁 + 不同的申请顺序 = 循环等待。同步互斥基本概念里"剩余区必须摘出去"本质上也是粒度问题——把无关代码留在临界区里,等于无谓地把粒度调粗。
考点速记
- 锁是对底层互斥机制的封装,
acquire/release正好落在进入区与退出区,四条准则可直接用来评价它。 - ⚠️自旋锁与互斥锁的分界只有一条:拿不到锁时让不让出处理机。 自旋锁忙等,互斥锁阻塞。
- ⚠️自旋锁在单处理机上毫无意义——持锁者此刻并没在运行,它等的是一个当下不可能被释放的锁。
- 自旋锁在多核上也不免费:每圈都写锁变量会引发跨核缓存失效,改法是先只读自旋、看到可能空闲了再去抢。
- 选型判据是比临界区长度与两次上下文切换的开销:临界区短用自旋锁,长用互斥锁。
- ⚠️互斥锁就是初值为 1 的信号量,但锁有"所有权"(谁加的锁谁解),因而表达不了同步——同步需要"甲做完某事、乙才能继续",而解锁的人和加锁的人必须是同一个。
- 能力链是 锁 < 信号量 < 管程:锁只能互斥,信号量能互斥也能同步,管程再加上封装与条件变量。
- 粒度调细必须配"按固定顺序申请锁"的规矩,否则会死锁(见死锁的概念与预防的资源有序分配法)。
这一节在真题里被考过的形式:
锁本身至今不单独成题。 本页下方练习区渲染的是整个 sync-mutex-concepts 标签下的 18 道题,范围比本节宽,属正常——那些题分别在 同步与互斥基本概念、 同步互斥实现方法、信号量 与三个经典同步问题里讲。
它不单独成题,但两条结论是选项里的常见素材:
第一,速记第六条(互斥锁 = 初值为 1 的信号量,但锁表达不了同步)。 凡出现"能否用锁实现前驱关系""互斥锁和信号量有什么区别"这类问法, 答案都由它给出——分界是有没有所有权。
第二,速记第三条(自旋锁在单处理机上无意义)。 它是"为什么多核编程才大量用自旋锁" 这类判断的依据,也与同步互斥实现方法速记第六条 (关中断在多核上失效)构成一对:单核与多核,各有各的方法失效。
复习优先级:读懂即可,重点是第二、六条。 "拿不到锁让不让出处理机"这条分界 与"互斥锁表达不了同步"这条结论要记住,其余理解一遍就够。 第四条那个缓存失效属于体系结构的地界,不必细究。
易错:认为锁是一种独立于信号量的新机制。它是对底层互斥手段的封装,互斥锁就是初值为 1 的信号量。
易错:认为自旋锁在单处理机上也有用。持锁者此刻没在运行,等的是一个不可能被释放的锁。
易错:认为可以用互斥锁实现进程同步。锁有所有权——加锁和解锁必须是同一个进程,表达不了"甲做完乙才能走"。
易错:认为锁的粒度越细越好。细粒度必须配"按固定顺序申请"的规矩,否则会死锁。
教材出处
- "把标志看做一个锁":汤小丹《计算机操作系统》2.4.2 节,印刷 p51——"锁开"进入、"锁关"等待,初始时锁是打开的;测试和关锁操作必须是连续的。
- 自旋锁的引入与总线竞争:同书 10.4.2 节,印刷 p321——多处理机上读—修改—写原语包含若干条指令、需多次总线操作,总线由多个处理机竞争获取;自旋锁与信号量的主要差别在于自旋锁可避免调用进程阻塞,而进程切换"需要花费一定开销,并且会使高速缓存失效";用自旋锁保护的临界区一般都应比较短。
- 单 CPU 下自旋锁退化为空操作:同书印刷 p322——"自旋锁只有在内核可抢占或 SMP 的情况下才真正需要,在单 CPU 且不可抢占的内核下……不需要自旋锁,此时自旋锁的所有操作都是空操作。"同页还给出了选型判据(临界区大用信号量、中断上下文或临界区极小用自旋锁)与读写自旋锁的定义。
- 互斥锁是初值为 1 的信号量:同书 2.4.3 节记录型信号量,印刷 p54——"如果 S->value 的初值为 1,表示只允许一个进程访问临界资源,此时的信号量转化为互斥信号量。"
相关知识
同步互斥实现方法|同步与互斥的基本概念|信号量与 PV 操作|管程与条件变量|读者-写者问题|死锁的概念