并发 — 不定顺序,结果就不确定
一句话总结
多个执行流接触同一数据时,结果就依赖执行顺序。锁用于强制顺序,但错误使用锁会使所有执行流都无法前进。
为什么需要它
counter = counter + 1 这一行在机器指令中至少分为读取、加一、写回三步。两个线程同时执行时,可能读取相同值并写回相同结果,丢失一次递增,这就是竞态条件(race condition)。
它难在不可复现:大多数时候正常,负载升高或核心增加后才出现,所以测试通过却在生产环境爆发。
工作原理
访问共享资源的代码段称为临界区,正确方案必须满足:
- 互斥——同时只有一个执行流进入临界区。
- 进展——无人位于其中时,等待者中必须有人能进入。
- 有限等待——等待者不会无限期被推迟。
纯软件也能实现(Peterson 算法),但现代 CPU 提供原子指令。典型代表是比较相等时交换的 CAS(compare-and-swap),几乎所有锁与无锁数据结构都建立在它之上。
同步工具性质不同:
- 互斥锁——有所有者,只有加锁线程能解锁。
- 信号量——是计数器,没有所有者;A 获取后 B 也可释放,适合限制资源数量。
- 条件变量——表达“等到某条件为真”,必须配合互斥锁,并在唤醒后重新检查条件,因为存在虚假唤醒。
等待方式也不同。自旋锁持续占用 CPU 等待解锁,只在临界区极短且核心充足时有利。阻塞锁让线程睡眠,使 CPU 可做其他工作,但有上下文切换成本。
死锁只在四个条件同时成立时出现:互斥、持有并等待、不可抢占、循环等待。四者缺一不可,也意味着打破任意一个即可预防。实践中最常见的是打破循环等待:规定所有代码按相同顺序获取锁。数据库频繁死锁时,应先检查事务访问行的顺序是否不同。
还应了解优先级反转。低优先级线程持锁却被中优先级线程压制时,等待该锁的高优先级线程也被阻塞。火星探测器 Pathfinder 的著名重启事故即源于此,解决方案是优先级继承:暂时提高持锁线程的优先级。
实际工作中的表现
应用中“检查库存,若有则扣减”这类先读、判断、再写的模式没有锁必然出错。提高数据库隔离级别也未必解决:两个事务若读取同一集合,却更新不同行,没有写冲突,快照隔离无法发现。这称为写偏斜(write skew),只能通过可串行化隔离、显式锁或约束防止。
减少使用锁
锁虽正确,却昂贵且可能死锁。实践方向是让锁变得不必要,主要有三种方式。
不共享。 各线程只处理自己的数据,最后合并。例如各自计数后求和,只在合并时同步一次,消除竞争。
不修改。 不原地更新,而是创建新值并原子替换引用。读取者可无锁安全访问,适合读多写少的配置或查询表,但不适合频繁更新。
用消息传递。 数据所有权集中在一处,其他执行流发送请求由它处理。价值不只在性能,而是所有修改都集中在一个位置,调试范围从整套代码缩小到一个函数。
必须使用锁时,应保持范围小,临界区内不做 I/O 或获取其他锁;明确固定顺序;并且持锁时不要调用回调,否则无法知道外部代码会获取什么锁,顺序规则将被破坏。
最后,竞态很难靠普通测试发现,因为大多数运行都会通过。在 CI 中加入静态分析或运行时竞态检测工具,并单独进行长时间压力测试,通常更有效。
下个练习将做什么
让四个线程递增同一值,亲自观察更新丢失;连续运行五次确认结果各不相同,再分别用锁和完全不共享的方式修复。最后以相反顺序获取锁制造死锁,再仅通过统一顺序消除它。