LabHub
学习 学习路径 课程

操作系统

并发 — 不定顺序,结果就不确定

在 LabHub 中继续学习

一句话总结

多个执行流接触同一数据时,结果就依赖执行顺序。锁用于强制顺序,但错误使用锁会使所有执行流都无法前进。

概念图: 竞态条件(race condition) · 临界区 · 互斥 · 进展

为什么需要它

counter = counter + 1 这一行在机器指令中至少分为读取、加一、写回三步。两个线程同时执行时,可能读取相同值并写回相同结果,丢失一次递增,这就是竞态条件(race condition)

它难在不可复现:大多数时候正常,负载升高或核心增加后才出现,所以测试通过却在生产环境爆发。

工作原理

访问共享资源的代码段称为临界区,正确方案必须满足:

  1. 互斥——同时只有一个执行流进入临界区。
  2. 进展——无人位于其中时,等待者中必须有人能进入。
  3. 有限等待——等待者不会无限期被推迟。

纯软件也能实现(Peterson 算法),但现代 CPU 提供原子指令。典型代表是比较相等时交换的 CAS(compare-and-swap),几乎所有锁与无锁数据结构都建立在它之上。

同步工具性质不同:

等待方式也不同。自旋锁持续占用 CPU 等待解锁,只在临界区极短且核心充足时有利。阻塞锁让线程睡眠,使 CPU 可做其他工作,但有上下文切换成本。

死锁只在四个条件同时成立时出现:互斥、持有并等待、不可抢占、循环等待。四者缺一不可,也意味着打破任意一个即可预防。实践中最常见的是打破循环等待:规定所有代码按相同顺序获取锁。数据库频繁死锁时,应先检查事务访问行的顺序是否不同。

还应了解优先级反转。低优先级线程持锁却被中优先级线程压制时,等待该锁的高优先级线程也被阻塞。火星探测器 Pathfinder 的著名重启事故即源于此,解决方案是优先级继承:暂时提高持锁线程的优先级。

实际工作中的表现

应用中“检查库存,若有则扣减”这类先读、判断、再写的模式没有锁必然出错。提高数据库隔离级别也未必解决:两个事务若读取同一集合,却更新不同行,没有写冲突,快照隔离无法发现。这称为写偏斜(write skew),只能通过可串行化隔离、显式锁或约束防止。

减少使用锁

锁虽正确,却昂贵且可能死锁。实践方向是让锁变得不必要,主要有三种方式。

不共享。 各线程只处理自己的数据,最后合并。例如各自计数后求和,只在合并时同步一次,消除竞争。

不修改。 不原地更新,而是创建新值并原子替换引用。读取者可无锁安全访问,适合读多写少的配置或查询表,但不适合频繁更新。

用消息传递。 数据所有权集中在一处,其他执行流发送请求由它处理。价值不只在性能,而是所有修改都集中在一个位置,调试范围从整套代码缩小到一个函数。

必须使用锁时,应保持范围小,临界区内不做 I/O 或获取其他锁;明确固定顺序;并且持锁时不要调用回调,否则无法知道外部代码会获取什么锁,顺序规则将被破坏。

最后,竞态很难靠普通测试发现,因为大多数运行都会通过。在 CI 中加入静态分析或运行时竞态检测工具,并单独进行长时间压力测试,通常更有效。

下个练习将做什么

让四个线程递增同一值,亲自观察更新丢失;连续运行五次确认结果各不相同,再分别用锁和完全不共享的方式修复。最后以相反顺序获取锁制造死锁,再仅通过统一顺序消除它。