临界区与锁 —— 补充理解
本文档整理了在学习并发/同步机制时对临界区和锁的核心认知——不仅仅是"怎么用",而是理清临界区到底是什么、锁到底在锁什么、以及它们之间的因果逻辑关系。
一、什么是临界区 (Critical Section)?
定义:
临界区是进程或线程中一段访问共享资源(如全局变量、共享数据结构、外部文件等)的代码片段。
为保证程序正确性,这段代码必须具有互斥性 (Mutual Exclusion)——任何时刻只允许一个线程进入临界区执行。
关键区分:临界区不是共享资源本身,而是操作共享资源的代码。 锁加在代码上,不是加在数据上。
二、临界区的三要素
| 要素 | 说明 | 类比 |
|---|---|---|
| ① 资源共享 | 诱因——如果没有共享资源,所有代码都是安全的 | 多人共用一个洗手间 |
| ② 多步操作 | 隐患——如 counter++ 拆分为"读-改-写"三步,不是原子的 |
洗手间里的私人操作需要连续完成 |
| ③ 互斥需求 | 解药——锁确保临界区像单条原子指令一样不可分割 | 门上的插销确保同一时间只有一个人 |
形象比喻:单人洗手间
- 洗手间(资源) 是共享的,但为了安全和隐私,必须保证同一时间只有一个人在里面
- 锁 = 门上的插销
- 临界区 = 你在洗手间里进行的一系列操作过程
三、临界区 vs. 竞态条件
回顾
线程 A 进入临界区 线程 B 进入临界区
① 从内存读 counter → 寄存器
① 从内存读 counter → 寄存器
② 寄存器 +1 → 寄存器
② 寄存器 +1 → 寄存器
③ 写回内存
③ 写回内存
| 概念 | 角色 |
|---|---|
| 临界区 | 修改 counter 的那段代码("读-改-写"三步组成的代码段) |
| 竞态条件 | 两个线程同时处于临界区导致的结果不可预测 |
| 锁 | 阻止多个线程同时进入临界区的手段 |
因果链:共享资源 → 多步操作(非原子)→ 需要互斥 → 临界区概念诞生 → 竞态条件 = 多个线程同时处于临界区 → 锁 = 防止竞态条件的工具
四、锁到底在"锁"什么?
这是最容易被误解的地方。需要理解逻辑层面和物理层面的区别:
物理真相
| 常见误解 | 物理事实 |
|---|---|
| "给代码加锁" | 代码段是只读的,OS 不会修改代码本身 |
| "锁住了某段代码" | 锁是一个存在于共享内存中的变量 |
锁的本质:一个存在于内存中的特殊变量,加上围绕它的一套协议。
锁变量的两种状态:
┌─────────────────┐
│ 0 = 未占用 │ ← 可以进入临界区
│ 1 = 已占用 │ ← 有线程在临界区内,请等待
└─────────────────┘
协议约定
程序员达成协议:"在进入这段危险代码(临界区)之前,必须先检查并修改那个锁变量。"
逻辑修正:不是代码被锁住了,而是线程被协议拦住了。
线程 A 线程 B
│ │
▼ ▼
lock(锁变量) lock(锁变量)
│ │
│ 将锁变量 0→1 (成功) │ 锁变量 = 1 (失败)
│ │
▼ ▼
进入临界区 被阻塞(自旋或休眠)
│ │
│ 执行 counter++ │ (等待)
│ │
▼ ▼
unlock(锁变量 1→0) 被唤醒
│ │
│ ▼
lock(锁变量 0→1 成功)
│
▼
进入临界区
五、先有临界区还是先有锁?(因果逻辑)
这是一个"先有鸡还是先有蛋"的问题,正确理解如下:
| 步骤 | 内容 | 说明 |
|---|---|---|
| 先存在 | 临界区 | 一段代码只要"访问了共享资源"且"操作是非原子的",它天生就是临界区 |
| 后加上 | 锁 | 因为我们识别出它是临界区,所以必须在它周围加上锁来保护 |
误区纠正:不是通过看有没有锁来判断临界区,而是因为发现它是临界区,所以必须在它周围加锁。没有锁的临界区是"不设防的临界区",会导致竞态条件。
标准执行流
- 识别:程序员发现一段代码会修改全局变量
counter——这就是临界区 - 设防:在代码前写
lock(),在代码后写unlock() - 运行:线程间通过锁变量的状态协商谁进入临界区
一句话总结
| 概念 | 角色 | 类比 |
|---|---|---|
| 临界区 | 患处(会出 Bug 的代码片段) | 伤口 |
| 锁 | 绷带(保护患处的手段) | 创可贴 |
六、补充:锁的实现与类型(硬件基础)
6.1 锁需要硬件支持
lock() 和 unlock() 看起来是函数调用,但它们的核心依赖于 CPU 提供的特殊硬件指令。因为检查锁变量+修改锁变量本身也是一个"读-改-写"的多步操作,如果两个线程同时执行 lock(),不加硬件帮助也会出现竞态条件。
关键硬件指令:
| 指令 | 做了什么 |
|---|---|
| Test-and-Set (TAS) | 原子地:读取旧值 → 设置为 1 → 返回旧值 |
| Compare-and-Swap (CAS) | 原子地:比较当前值是否为预期值 → 如果是则交换为新值 |
| Load-Linked / Store-Conditional (LL/SC) | 成对使用,先链接读取,条件写入(若期间内存被改则失败) |
这些指令的共同点:在硬件层面保证"读"和"写"之间不会被其他核心打断。
6.2 锁的两种基本实现
| 类型 | 行为 | 适用场景 | 缺点 |
|---|---|---|---|
| 自旋锁 (Spinlock) | 拿不到锁的线程在原地忙等(不断循环检查) | 临界区非常短,等待时间 < 上下文切换开销 | 浪费 CPU 时间片 |
| 互斥锁 (Mutex/Sleeping Lock) | 拿不到锁的线程让出 CPU(进入休眠,加入等待队列) | 临界区较长,或者不确定等待时间 | 上下文切换开销大 |
很多现代 mutex 的实现是两阶段的:先自旋一小会儿,如果锁还没释放再休眠。结合了两者的优点。
6.3 死锁:自己锁自己
如果一个线程在持有锁的时候,由于逻辑错误再次申请同一把锁:
Thread A:
lock(mutex) ← 成功,mutex = 1
...
lock(mutex) ← 自己锁自己!mutex = 1,永远无法变成 0
★ 线程 A 永远阻塞在这里
为什么不能直接允许?
如果同一线程可以重复获取同一把锁,那第二层 lock() 必须能够识别"这是同一个线程",而不是"锁被别人占着"。这引入了可重入锁 (Reentrant Lock / Recursive Mutex) 的概念。普通 mutex 不可重入,重复加锁会导致死锁。
更复杂的死锁场景 —— 循环等待:
Thread A Thread B
lock(mutex_1) lock(mutex_2)
... ...
lock(mutex_2) ← 阻塞 lock(mutex_1) ← 阻塞
★ 两人各持一锁等对方释放,死锁
七、锁的粒度:全局锁 vs. 细粒度锁
| 全局大锁 (Coarse-Grained) | 精准细锁 (Fine-Grained) | |
|---|---|---|
| 做法 | 整个数据结构/子系统用一把锁 | 每个小资源独立配锁 |
| 并发度 | 低——一次只能有一个线程操作 | 高——不相关的资源可以并发操作 |
| 编程复杂度 | 简单 | 复杂(容易死锁) |
| 类比 | 实验室大门上装一把锁,所有人排队进出 | 每个器材柜上单独装锁 |
权衡: 细粒度锁能提高并发性能,但增加了死锁风险和编程复杂度。
八、概念对照速查
| 概念 | 一句话记住 |
|---|---|
| 临界区 | 操作共享资源的代码段,天生就是临界区 |
| 锁 | 共享内存中的一个标志位 + 一套协议约定 |
| 互斥 | 同一时刻只允许一个线程进入临界区 |
| 竞态条件 | 多个线程同时处于临界区导致的结果错误 |
| 自旋锁 | 拿不到锁就原地空转检查 |
| 互斥锁 | 拿不到锁就让出 CPU,进入休眠队列 |
| 死锁 | 线程互相等待对方释放锁,谁也跑不了 |
| 锁粒度 | 大锁简单但慢,小锁复杂但快 |
九、术语速查表
| 术语 | 英文 | 定义 |
|---|---|---|
| 临界区 | Critical Section | 访问共享资源的代码段,需要互斥保护 |
| 互斥 | Mutual Exclusion | 同一时间只允许一个执行流进入临界区的性质 |
| 竞态条件 | Race Condition | 多个执行流对共享资源的并发访问导致结果依赖执行顺序 |
| 自旋锁 | Spinlock | 持续检查锁状态直到获取的锁实现 |
| 互斥量 | Mutex (Mutual Exclusion) | 提供互斥访问的同步原语,通常伴随休眠/唤醒 |
| 可重入锁 | Reentrant Lock / Recursive Mutex | 允许同一线程重复获取同一把锁 |
| 死锁 | Deadlock | 多个线程互相等待对方持有的资源,永远阻塞 |
| TAS | Test-and-Set | 原子地读取并设置内存值的硬件指令 |
| CAS | Compare-and-Swap | 原子地比较并交换内存值的硬件指令 |
| 锁粒度 | Lock Granularity | 锁保护的资源范围大小(粗粒度 vs. 细粒度) |
导师的下一步建议:
临界区和锁的关系至此已经清晰:临界区是"患处",锁是"绷带"。锁的本质不是锁住代码,而是通过共享内存中的一个标志位加上一套协议约定,让线程在进入临界区之前达成"互斥"的共识。理解了这把锁的物理本质,信号量和条件变量就会自然得多。
接下来进入第 29 章——如何基于锁构建正确的并发数据结构(计数器、链表、队列),在并发度和性能之间找到平衡。