互斥与临界区:锁的最小问题模型

如果你写过后端服务,几乎一定遇到过这种事:单元测试跑得好好的,上线后偶尔出现一个对不上的数字——计数器少加了一次、库存超卖了一件、余额算错了几分。重跑一遍又复现不出来。这种"薛定谔的 bug"绝大多数都指向同一个根源:多个执行流在没有保护的情况下同时改了同一份数据

很多人对锁的认识停留在"加锁就对了",但这其实是把结论当成了起点。真正该先搞清楚的是:到底是哪一步出了错,错在什么粒度上,为什么单纯一条赋值语句也会出错。把这个最小问题模型看透,你才会明白互斥、临界区、锁这一连串概念是怎么一层层长出来的,也才知道后面的信号量、条件变量、管程在解决什么。

这一篇我们坚持先讲机制:先把"竞态到底是怎么发生的"拆到指令级别,再回过头看互斥和临界区是为了堵住哪个缺口而设计的。

一、先在指令级别看清楚:错误到底发生在哪一步

我们从最朴素的一行代码开始:count = count + 1。在你眼里它是"一步",但 CPU 并不这么看。这一行被编译后,通常会变成三条机器指令:

  1. LOAD:把内存里的 count 读进寄存器;
  2. ADD:在寄存器里加 1;
  3. STORE:把寄存器的值写回内存。

关键在于,操作系统随时可能在任意两条指令之间把当前线程切走,去跑别的线程。现在假设 count 初值是 100,线程 A 和线程 B 都要执行 count++,调度恰好这样穿插:

  • A 执行 LOAD,读到 count = 100,存进自己的寄存器;
  • 时间片到,操作系统切换到 B;
  • B 完整执行 LOAD / ADD / STORE,把 count 写成 101
  • 切回 A,A 接着用它寄存器里那个过期的 100ADD,得到 101,再 STORE 写回。

两个线程各加了一次,count 本该是 102,结果却是 101。丢掉的那一次,就消失在 A 那个被切走时还停留在寄存器里的过期值上。

竞态条件的本质不是"两个线程同时执行",而是"一个线程基于一份已经过期的共享状态做了决定"。

这就是竞态条件(race condition):程序的结果依赖于多个执行流的相对执行时序,而这个时序是不确定的。它平时复现不出来,是因为大多数时候穿插点恰好没踩中那个危险的间隙;可一旦并发量上来,踩中只是时间问题。

二、临界区:把"不能被打断"的那段代码圈出来

看清了错误发生在 LOAD…STORE 这个序列被打断的中间,解决思路就浮出水面了:这一段读—改—写必须作为一个整体执行,不允许另一个执行流在中途插进来操作同一份数据

这段"必须整体执行、不能被并发进入"的代码区域,就叫临界区(critical section)

临界区不是某种特殊语法,它只是你识别出来的一段代码——在这段代码里你会访问共享资源,且这段访问必须排他进行。

注意临界区是相对"某一份共享资源"而言的。两个线程如果操作的是完全不相干的两份数据,它们各自的代码即使并发执行也毫无问题,谈不上临界区。只有当它们触碰同一份共享状态时,那段触碰代码才构成需要互斥保护的临界区。一个正确的并发程序,对每一个临界区都要满足三个性质:

  • 互斥(mutual exclusion):同一时刻最多一个执行流处在临界区内;
  • 空闲让进:临界区没人占用时,想进的线程应当能立即进入,不能无谓阻塞;
  • 有限等待:想进临界区的线程不能无限期等下去,必须在有限时间内轮到它。

第一条是安全性底线(不出错),后两条是活性保证(不饿死、不空等)。后面你会反复看到,很多锁的实现"互斥"做得很好,却在"有限等待"上翻车,导致某些线程长期抢不到锁——那同样是 bug。

三、互斥的核心机制:进入区与退出区

要保证临界区互斥,标准做法是在它前后各加一段控制代码,把临界区"包"起来:

1
2
3
4
entry section      // 进入区:申请进入许可,进不去就等
critical section // 临界区:访问共享资源
exit section // 退出区:释放许可,让别人能进
remainder section // 剩余区:与共享资源无关的代码

整个互斥机制的运转就靠进入区退出区这两段:进入区负责"判断现在能不能进,不能进就挡住",退出区负责"我走了,放下一个进来"。所有的锁、信号量,本质上都是在实现这两段的逻辑。

那进入区怎么才能可靠地"挡住"别人?最容易想到的是用一个共享标志位 lock:进去前看 lock 是不是 0,是 0 就置成 1 表示"我占了",出来时再置回 0。但你立刻会发现,这个方案本身又掉进了第一节的陷阱——"检查 lock 是否为 0"和"把它置 1"又是两步,两个线程可能都读到 0,然后都以为自己拿到了锁。

用普通的读写变量来实现互斥,会陷入"保护机制自己也需要被保护"的无限递归。

这就逼出了一个关键结论:互斥不可能纯靠普通内存读写实现,它必须依赖硬件提供的原子操作

四、原子操作:互斥的硬件地基

现代 CPU 都提供了原子的"读—改—写"指令,最典型的是 test-and-set(测试并置位)和 compare-and-swap(比较并交换,CAS)。它们的特点是:一条指令内部完成"读出旧值、写入新值",硬件保证这中间不会被任何其他核心或线程插入。

test-and-set 为例,它的语义等价于:

1
2
3
4
5
原子地执行 {
old = *lock; // 读出旧值
*lock = 1; // 无条件置 1
return old; // 返回旧值
}

有了它,最简单的自旋锁就能写出来:

1
2
3
4
5
6
7
acquire():
while (test_and_set(&lock) == 1)
; // 旧值是 1,说明别人占着,原地打转继续试
// 旧值是 0,说明我刚把它从 0 抢成了 1,进入成功

release():
lock = 0; // 释放

它为什么对?因为"读旧值 + 置 1"是原子的:两个线程同时调 test_and_set,硬件会让它们串行化,只有一个能读到旧值 0(抢锁成功),另一个必然读到 1(继续自旋)。第三节那个"两个线程都读到 0"的窟窿,被硬件从根上焊死了。

互斥的整条链路是这样的:应用层的临界区 → 锁 → 锁依赖原子指令 → 原子指令由 CPU 硬件保证。每一层都建立在下一层的保证之上。

五、忙等还是阻塞:等待的两种姿态

进入区挡住一个线程后,这个线程在干什么?这里有两种截然不同的策略,对性能影响巨大。

忙等(busy-waiting / 自旋):就是上面那个 while 循环,线程不停地试,占着 CPU 空转。好处是一旦锁释放它立刻就能抢到,没有切换开销;坏处是它在等待期间纯属浪费 CPU——如果锁要等很久,这个核心就一直在空转烧电。

阻塞(blocking):线程发现进不去,就主动告诉操作系统"把我挂起,等锁可用了再叫醒我"。这期间它让出 CPU,操作系统去跑别的线程;等持锁者释放时,再唤醒它。好处是不浪费 CPU,坏处是挂起和唤醒都要走内核、做上下文切换,有固定开销。

怎么选?核心看临界区预计要持有多久

  • 临界区极短(几十条指令、纳秒级),用自旋更划算——切换的开销比空转还大;
  • 临界区较长(涉及 IO、可能阻塞),用阻塞更合理——别为了省一次切换而空转几毫秒。

这也是为什么 Linux 内核里 spinlock(自旋)和 mutex(可睡眠)是两种不同的锁,工程上还有先自旋一小会儿、不行再阻塞的"自适应锁"。理解了忙等和阻塞的取舍,你才能看懂这些设计为什么存在。

六、为什么说这是"最小问题模型"

到这里可以回答标题里那个问题了:互斥与临界区为什么是锁问题的最小模型?

因为它把并发的复杂性压缩到了最干净的形态——只剩下"一份共享资源 + 多个想访问它的执行流 + 同一时刻只能进一个"这三个要素。没有等待条件、没有资源计数、没有多方协作,就是最纯粹的"排他访问"。

这个模型之所以重要,是因为后面所有同步机制都是它的扩展:

  • 把"只能进一个"放宽成"最多能进 N 个",就是信号量
  • 在互斥之上再加一个"条件不满足就等待、满足了被唤醒"的维度,就是条件变量
  • 把互斥和条件等待打包进一个语言级模块,就是管程
  • 干脆不共享、改用消息协作,就是消息传递

互斥是并发控制的"原点"。理解了这个最小模型,后面的机制你都能看成"在原点上加了一个新维度",而不是一堆互不相干的名词。

七、锁的边界:它保证什么,不保证什么

工程上踩坑往往是因为对锁的能力边界有误解,这里明确划清。

锁保证的:在它保护的临界区内,同一时刻只有一个执行流操作共享资源。仅此而已。

锁不保证的

  • 不保证业务逻辑正确。锁只提供"排他执行"这块地基,你在临界区里写错了逻辑,加再多锁也是错的。
  • 不保证"加了锁就线程安全"。如果同一份数据有的地方加锁访问、有的地方不加锁就读,那等于没锁——保护必须覆盖对该资源的所有访问路径。
  • 不保证解决顺序依赖。互斥管的是"不能同时进",但管不了"A 必须在 B 之前发生"。后者是同步问题,要靠条件变量、信号量解决。这是互斥和同步最容易被混为一谈的地方。

互斥解决的是"同时访问"的冲突,不解决"先后顺序"的协调。把这两类问题分清,是写对并发代码的前提。

八、临界区的粒度:正确之外的性能维度

只要把所有共享访问都塞进一个大锁里,正确性当然能保证——但你会得到一个几乎串行的系统,并发量再高也压不上去。所以临界区还有第二个维度:粒度

临界区不是越大越安全。临界区越大,持锁时间越长,其他线程排队等待的时间就越长,锁竞争(lock contention)越激烈,吞吐量越低,尾延迟越高。常见的反模式是把一堆和共享资源无关的逻辑(日志、RPC 调用、复杂计算)也圈进了临界区。

正确的做法是临界区尽量小——只把真正访问共享资源的那几行包进去,无关的准备工作和后续处理都挪到锁外面。这就是"锁的粒度"优化:

  • 粗粒度锁:一把大锁保护一大片数据,简单但并发差;
  • 细粒度锁:把数据拆开,每块用各自的锁(比如分段锁、桶锁),并发好但实现复杂、还容易引入死锁。

ConcurrentHashMap 早期用分段锁、数据库用行锁而非表锁,背后都是同一个权衡:在"实现简单"和"并发吞吐"之间找平衡点。

九、和工程实践 / 后端开发的联系

把这个最小模型放回真实系统,你会发现它无处不在:

  • 本地、数据库、分布式三层锁:进程内的 mutex、数据库的行锁/SELECT ... FOR UPDATE、Redis/Zookeeper 实现的分布式锁,形态各异,但解决的都是同一件事——保护一个跨执行流的临界区。换了层次,模型没变。
  • "偶发错乱"问题的排查方向:库存超卖、计数偏差、状态机跳错,这类靠重试难复现的 bug,第一反应就该是"这里是不是有个没保护的临界区"。
  • 锁竞争是性能杀手:当你看到 CPU 没跑满但吞吐上不去、线程大量卡在锁等待上时,往往是临界区太大或锁粒度太粗。优化方向是缩小临界区、拆分锁、或改用无锁结构。
  • 分布式锁的额外陷阱:网络会超时、持锁节点会宕机,所以分布式锁必须带过期时间、要考虑锁过期后的安全性——这些都是单机互斥模型在分布式环境下衍生出的新问题,但内核仍是同一个临界区模型。

学习这一部分最容易踩的坑

1. 以为"一条语句"就是原子的

count++i = i + 1 在源码里是一行,但编译成机器指令是多步,随时可能被打断。竞态恰恰发生在这些"看起来不可分"的语句内部。判断要不要保护,要看机器指令层面,而不是源码行数。

2. 把互斥问题和同步问题混为一谈

互斥解决"不能同时进入",同步解决"必须按某种顺序发生"。用互斥锁去硬凑顺序依赖,往往写出又丑又错的代码。看到"A 要等 B 完成"这类需求,该想到的是条件变量或信号量,而不是再加一把互斥锁。

3. 默认"加了锁就万事大吉"

锁只在它实际覆盖的访问路径上有效。只要有一条路径绕过锁去碰共享数据,整个保护就形同虚设。线程安全是对某份数据所有访问的整体约束,不是某个方法上的局部标签。

4. 临界区越做越大

为了"保险"把无关逻辑也圈进锁里,是新手最常见的性能坑。临界区里多待一毫秒,高并发下就是成倍放大的排队延迟。原则永远是:锁的范围恰好覆盖共享访问,一行都不多

总结

互斥与临界区是整个并发世界的最小问题模型:一份共享资源、多个执行流、同一时刻只进一个。把它从指令级看透,后面的机制就都有了根。核心要点:

  • 竞态的本质是"基于过期的共享状态做决定",错误发生在 读—改—写 序列被打断的中途;
  • 临界区是必须排他执行的那段代码,正确的互斥要同时满足互斥、空闲让进、有限等待;
  • 互斥机制靠进入区/退出区实现,而进入区无法用普通读写变量实现,必须依赖 test-and-set、CAS 这类硬件原子指令;
  • 等待有忙等和阻塞两条路,按临界区预计持有时长来选;
  • 锁只保证排他访问,不保证业务正确、不保证全覆盖、不解决顺序依赖;
  • 临界区粒度是正确性之外的性能维度,原则是尽量小、按需拆分。

把这一篇吃透,再往下看信号量、条件变量、管程,你会发现它们都在回答同一个问题:在"排他访问"这个原点上,还需要加上哪一个维度的协调能力

参考资源

  • 《操作系统导论》(Operating Systems: Three Easy Pieces)并发部分
  • 《现代操作系统》(Andrew S. Tanenbaum)
  • OSTEP - Locks
  • 《深入理解计算机系统》并发编程章节