临界区与锁 —— 补充理解

本文档整理了在学习并发/同步机制时对临界区和锁的核心认知——不仅仅是"怎么用",而是理清临界区到底是什么、锁到底在锁什么、以及它们之间的因果逻辑关系。


一、什么是临界区 (Critical Section)?

定义:
临界区是进程或线程中一段访问共享资源(如全局变量、共享数据结构、外部文件等)的代码片段。

为保证程序正确性,这段代码必须具有互斥性 (Mutual Exclusion)——任何时刻只允许一个线程进入临界区执行。

关键区分:临界区不是共享资源本身,而是操作共享资源的代码。 锁加在代码上,不是加在数据上。


二、临界区的三要素

要素 说明 类比
① 资源共享 诱因——如果没有共享资源,所有代码都是安全的 多人共用一个洗手间
② 多步操作 隐患——如 counter++ 拆分为"读-改-写"三步,不是原子的 洗手间里的私人操作需要连续完成
③ 互斥需求 解药——锁确保临界区像单条原子指令一样不可分割 门上的插销确保同一时间只有一个人

形象比喻:单人洗手间


三、临界区 vs. 竞态条件

回顾 1+1=1 的惨案:

线程 A 进入临界区               线程 B 进入临界区
① 从内存读 counter → 寄存器
                                ① 从内存读 counter → 寄存器
② 寄存器 +1 → 寄存器
                                ② 寄存器 +1 → 寄存器
③ 写回内存
                                ③ 写回内存
概念 角色
临界区 修改 counter 的那段代码("读-改-写"三步组成的代码段)
竞态条件 两个线程同时处于临界区导致的结果不可预测
阻止多个线程同时进入临界区的手段

因果链:共享资源 → 多步操作(非原子)→ 需要互斥 → 临界区概念诞生 → 竞态条件 = 多个线程同时处于临界区 → 锁 = 防止竞态条件的工具


四、锁到底在"锁"什么?

这是最容易被误解的地方。需要理解逻辑层面物理层面的区别:

物理真相

常见误解 物理事实
"给代码加锁" 代码段是只读的,OS 不会修改代码本身
"锁住了某段代码" 锁是一个存在于共享内存中的变量

锁的本质:一个存在于内存中的特殊变量,加上围绕它的一套协议。

锁变量的两种状态:
  ┌─────────────────┐
  │  0 = 未占用      │  ← 可以进入临界区
  │  1 = 已占用      │  ← 有线程在临界区内,请等待
  └─────────────────┘

协议约定

程序员达成协议:"在进入这段危险代码(临界区)之前,必须先检查并修改那个锁变量。"

逻辑修正:不是代码被锁住了,而是线程被协议拦住了。

线程 A                         线程 B
   │                              │
   ▼                              ▼
lock(锁变量)                    lock(锁变量)
   │                              │
   │  将锁变量 0→1 (成功)          │  锁变量 = 1 (失败)
   │                              │
   ▼                              ▼
进入临界区                     被阻塞(自旋或休眠)
   │                              │
   │  执行 counter++               │  (等待)
   │                              │
   ▼                              ▼
unlock(锁变量 1→0)              被唤醒
   │                              │
   │                              ▼
                             lock(锁变量 0→1 成功)
                                   │
                                   ▼
                              进入临界区

五、先有临界区还是先有锁?(因果逻辑)

这是一个"先有鸡还是先有蛋"的问题,正确理解如下:

步骤 内容 说明
先存在 临界区 一段代码只要"访问了共享资源"且"操作是非原子的",它天生就是临界区
后加上 因为我们识别出它是临界区,所以必须在它周围加上锁来保护

误区纠正:不是通过看有没有锁来判断临界区,而是因为发现它是临界区,所以必须在它周围加锁。没有锁的临界区是"不设防的临界区",会导致竞态条件。

标准执行流

  1. 识别:程序员发现一段代码会修改全局变量 counter——这就是临界区
  2. 设防:在代码前写 lock(),在代码后写 unlock()
  3. 运行:线程间通过锁变量的状态协商谁进入临界区

一句话总结

概念 角色 类比
临界区 患处(会出 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 章——如何基于锁构建正确的并发数据结构(计数器、链表、队列),在并发度和性能之间找到平衡。

MOC · 下一章:Ch29 基于锁的并发数据结构