Reading 23: 互斥与锁(Mutual Exclusion)
Reading 23: 互斥与锁(Mutual Exclusion)
说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。sp22 的「互斥」概念是用单线程协作式并发(
await交错点)来讲的,而 sp21 的孪生阅读(Locks and Synchronization)讲的是多线程下的synchronized、监视器模式与死锁;本笔记以 sp22 的概念框架(交错、互斥、临界区、竞态、死锁、safety/liveness)为主结构,用 sp21 的 Java 代码(synchronized、ReentrantLock、SimpleBuffer、GapBuffer、findReplace、Wizard/Castle、ConcurrentMap)作为实现示例。凡属 Java 生态的补充内容(ReentrantLock的全部细节、死锁的四个必要条件、System.identityHashCode等)均显式标注为「补充说明」。
概述
本讲把并发与承诺两条线索汇合到异步抽象数据类型(asynchronous ADT)上:这个 ADT 的操作可能彼此并发运行,并访问同一份共享的(可变)表示,于是产生了竞态条件(race condition)与死锁(deadlock)两类威胁抽象函数、表示不变量与规格说明的 bug。防御手段是两手:一是推理交错(在哪里可能发生危险的交错?),二是构造互斥(哪里必须绝对禁止交错?),从而得到只允许一个计算独占地访问共享可变数据的区间——临界区(critical section)。在 sp22 的单线程模型里,互斥区间就是「不含 await 的代码段」;在多线程的 Java 里,互斥区间由锁(lock)提供,最常用的是 synchronized 的监视器模式(monitor pattern)。它与三大目标的关系是:Safe from bugs——互斥是防止竞态的根本手段,但锁本身又引入死锁,威胁liveness;Easy to understand——锁的纪律(哪个锁保护哪些数据)必须写进代码注释,否则后来者无法维护;Ready for change——锁的粒度、是否把锁暴露给客户端、是否改用消息传递,都是影响可修改性的重大设计决策。
核心概念与设计原则详解
线程安全与四种并发策略(Thread Safety and Four Strategies)
- 定义与目的:线程安全指一个数据类型或函数在被多个线程使用时,无论这些线程如何被执行,都表现正确,且不需要调用方做额外协调。贯穿全课的总原则是:并发程序的正确性不应依赖时序的偶然。sp21 归纳了四种策略:限定(confinement)——不共享,把变量与其指向的数据限制在单个线程内可访问;不可变性(immutability)——共享但不可变(
final字段、不可变类型);使用已有的线程安全类型——让库替你协调;同步(synchronization)——阻止线程同时访问共享数据。本讲的主题是第四种。 - 直观解释(”它是什么?”):想象一间只有一支笔的办公室。限定 = 每人发一支笔(不共享);不可变 = 笔上刻死了字,谁看都一样;用现成的线程安全类型 = 用一台自动售笔机;同步 = 笔上栓了把锁,谁用谁锁上。前三种都「不靠时序」;第四种则明确规定了时序(谁先拿到锁谁先用),因此它最强大也最危险。
- 关键规则与最佳实践:
- 优先考虑前三种策略,因为它们不引入阻塞,也就不会死锁;只有在必须共享可变数据时才用同步。
- 使用同步意味着接受阻塞,而阻塞意味着可能死锁——这是 Reading 21 中「并发很难」的具体化。
- 线程安全论证(thread safety argument)必须写进代码:that discipline needs to be written down, or maintainers won’t know what it is。
- 不要指望测试:线程交错的数量是天文数字,测试不可能覆盖,且竞态 bug 往往是 heisenbug(加
println就消失)。
竞态条件与交错(Race Condition and Interleaving)
- 定义与目的:竞态条件指程序的正确性(后置条件与不变量的满足)取决于并发计算中事件的相对时序。交错(interleaving)是理解它的工具:把并发执行看成「各模块的低层操作可以被任意地穿插排列」。一个实现若对某些交错正确、对另一些交错错误,它就含有竞态条件。目的:把「时序相关」这个模糊的担忧,变成可以逐个交错检查的具体问题。
- 直观解释(”它是什么?”):
balance = balance + 1在处理器层面是「读—加—写」三步。两个线程各自读到 0,各自算出 1,各自写回 1——结果只存进了 1 美元,另一美元凭空消失。sp21 特别强调:balance = balance + 1、balance += 1、++balance三个版本有同样的竞态,现代编译器甚至生成完全相同的代码——所以看一行 Java 代码根本判断不出它是否安全。更糟的是重排序(reordering):处理器可能把变量缓存到寄存器再回写,回写顺序与代码顺序不同,所以answer = 42; ready = true;之后,另一个线程可能先看到ready == true却仍看到旧的answer。 - 关键规则与最佳实践:
- 「一行 Java 代码」不等于「一个原子操作」;原子操作的边界由处理器与内存模型决定,不由源代码的行数决定。
- 任何「读—改—写」序列(自增、
check-then-act、get-modify-set)都是竞态的高危候选。 - 在 sp22 的 TS 模型里,交错只发生在
await点;在 Java 多线程模型里,线程可以在任意点被抢占(preemptive),所以推理难度大得多——这正是必须用锁这类显式机制的原因。 - 消息传递不能消除竞态:当一个客户端必须向服务端发送多条消息才能完成一件事时,这些消息与其他客户端的消息会交错(见 Reading 24 的「LOOK before you TAKE」)。
互斥与临界区(Mutual Exclusion and Critical Section)
- 定义与目的:互斥指「一段代码在同一时刻只有一个计算在运行,其他可能访问同一份共享数据的并发计算被排除在外」。这样的代码区间称为临界区。互斥是防止竞态的根本思想。sp22 给出了两个必须反复自问的问题:(1)哪里可能发生危险的交错?(找出需要防御的地方);(2)哪里必须绝对禁止交错?(建造抵御 bug 的墙)。
- 直观解释(”它是什么?”):互斥就像手术室的独占时段:医生(一个线程)在手术期间,别人不能进来动同一具身体;其他手术室(其他数据)互不影响。sp22 的关键观察是:在单线程协作式并发里,只要一段代码里一个
await都没有,就可以确信没有任何异步回调或异步函数会与它交错——代码可能提前返回或抛异常,但如果控制流干净地走到末尾,那它一定是不被打断地跑完的。 - 关键规则与最佳实践:
- 先找交错点,再决定在哪里建墙;不要盲目地把
synchronized撒满全程序。 - 在 TS 中:每一个
await都是可能失去控制权的地方;恢复控制权时可能需要对条件重新检查。 - 把「变更(mutation)」推迟到所有条件都就绪之后,然后一次性做完,中途不失去控制权。
- 在 Java 中:临界区由
synchronized块/方法(或Lock.lock()/unlock())划定;互斥只对获取同一把锁的其他线程有效。
- 先找交错点,再决定在哪里建墙;不要盲目地把
锁作为抽象数据类型:acquire 与 release(Lock as an ADT)
- 定义与目的:锁是一种抽象,允许「至多一个线程」在某一时刻拥有它。它有两个操作:acquire 获取所有权(若已被别的线程持有,就阻塞直到对方 release,然后与其它竞争者争抢,胜者不确定);release 释放所有权。持有锁是一个线程向其他线程宣告:「我正在处理这个东西,现在别碰。」使用锁还会告诉编译器与处理器「这里在并发使用共享内存」,从而避免寄存器/缓存回写顺序导致的重排序问题。
- 直观解释(”它是什么?”):锁就是更衣室的钥匙。你拿走钥匙(acquire)才能进去(访问共享数据),出来还钥匙(release)别人才进得去。注意:钥匙放在那儿本身不会阻止任何人硬闯——必须所有进更衣室的人都遵守「先拿钥匙」的约定。
- 关键规则与最佳实践:
- 锁只是一种约定(a convention):如果有一个写得不好的客户端没有获取正确的锁,系统就不再是线程安全的。
- 阻塞(blocking) 的一般含义是「线程不做别的事,一直等到某个事件发生」;
acquire阻塞时等的事件就是「持有者 release」。 - 锁只对获取同一把锁的线程提供互斥;不获取锁的代码可以随意破坏数据。
- 锁通常还要保护数据,而不是只保护代码:持有某对象的锁,并不能阻止别的线程访问那个对象——它只能阻止别的线程进入它们自己的、以同一对象为锁的
synchronized块。 - 用锁的代码必须保证 release 一定发生(Java 的
synchronized由语言保证;ReentrantLock必须写在finally里)。
Java 的内置锁与 synchronized(Intrinsic Locks and synchronized)
- 定义与目的:Java 把锁做成了语言内建特性:每一个对象都隐式地关联着一把锁——
String、数组、ArrayList、你自己创建的每个类的实例,甚至一个平凡的Object都有锁,所以Object lock = new Object();常被用作纯粹的锁对象。Java 不允许你直接调用acquire/release,而是用synchronized语句块在块的作用域内自动获取与释放锁。 - 直观解释(”它是什么?”):
synchronized (lock) { ... }就是「进门拿钥匙、出门还钥匙」的自动门:进门时若钥匙不在就等着,出门时(无论是正常结束、return还是抛异常)一定还钥匙。这种块提供互斥:在由同一个对象锁保护的临界区里,同一时刻只有一个线程——就与那个对象相关的其它synchronized区而言,你又回到了「顺序编程的世界」。 - 关键规则与最佳实践:
synchronized (obj) { ... }只做一件事:阻止其他线程进入它们自己的、以同一个对象为锁的synchronized块。仅此而已。- 读也要加锁,不只是写:如果读不加锁,读线程可能看到表示被修改到一半的状态。
- 你必须显式地、小心地把每一次访问都用恰当的
synchronized块或方法关键字保护起来。 - 构造方法不允许加
synchronized关键字(语法上被禁止),因为构造中的对象应当被限定在单个线程内,直到构造方法返回;确实需要时可以在构造方法体内写synchronized (this) { ... }。 - Java 的锁是可重入的(reentrant,补充说明):同一个线程可以重复获取自己已持有的锁,因此
synchronized (obj) { synchronized (obj) { ... } }不会死锁,内层退出后线程仍然持有该锁。这条性质使得「对象方法互相调用」很自然,但也让死锁更容易在两个不同对象之间悄悄发生。
监视器模式与锁的纪律(Monitor Pattern and Locking Discipline)
- 定义与目的:写类的方法时最方便的锁就是对象实例自身(
this)。监视器模式的做法是:把整个表示(rep)用一把锁保护起来,所有访问 rep 的方法都在synchronized (this)内执行;监视器(monitor) 就是「方法之间互斥、同一时刻只有一个线程能进入其实例」的类。Java 提供了语法糖:在方法签名上加synchronized,效果等同于把整个方法体包在synchronized (this)里。配套的锁的纪律(locking discipline)有两条:每个共享可变变量都必须被某把锁保护,除了在该锁的synchronized块内不得读写;如果一个不变量涉及多个共享可变变量(甚至跨对象),那么所有相关变量必须由同一把锁保护,并且在释放锁之前必须重建该不变量。 - 直观解释(”它是什么?”):监视器模式是「一个房间一把钥匙」:类的实例就是一整间房,所有方法(包括看似微不足道的
length()、toString())都必须拿同一把钥匙进门。为什么不给length()免检?因为它读的正是别人正在改的东西。 - 关键规则与最佳实践:
- 每一个公开方法都加锁,包括观察器(observer);不要有例外。
- 把线程安全论证写在类里、紧挨着表示不变量:例如「对
text的所有访问都发生在SimpleBuffer的方法内,而这些方法全部由SimpleBuffer的锁保护」。 - 封装是论证成立的前提:如果
text是public的,客户端就能不加锁地读写它,监视器模式立刻失效(这是表示泄漏在并发语境下的后果)。 - 锁的对象必须是所有客户端都能拿到且都不会换的:
this、专用的private final Object lock、或文档中明示可用的对象。 - 别把
synchronized加到static方法上指望它保护实例数据:那会获取整个类的静态锁,既伤害性能又保护不到正确的东西。
原子性与复合操作(Atomicity and Compound Operations)
- 定义与目的:一个操作是原子的(atomic),意指它相对于其他线程不可被拆分、不可被打断。互斥的意义正在于把「若干步骤」变成一个原子区。危险之处在于:每个方法各自原子,不等于「若干个方法的组合」也原子。
findReplace就是典型:它先buf.toString()找到下标,再delete,再insert——三次调用各自原子,但整个方法不是,因为别的线程可能在中途改动缓冲区,导致删错区域、插错位置。 - 直观解释(”它是什么?”):原子性像复印合同:只签一页、只盖一个章,都不算签完;必须「检查—签字—盖章」一口气完成,中途别人不能把合同抽走。
- 关键规则与最佳实践:
- 需要「多个操作合起来原子」时,必须让它们位于同一个
synchronized区域——可以扩大方法内的同步区,也可以在客户端synchronized (buf) { ... }把三次调用包起来。 - 若要在客户端加锁,必须在接口的规格/注释里明确写出「客户端之间可以用该对象本身互相同步」,否则锁的约定不成立(Clients may synchronize with each other using the EditBuffer object itself.)。
- 更好的做法是从 ADT 设计上消灭这种需求:为并发设计的数据类型应当提供语义良好的原子操作,例如
ConcurrentMap.putIfAbsent(key, value)是if (!map.containsKey(key)) map.put(key, value);的原子版本,map.replace(key, value)是if (map.containsKey(key)) map.put(key, value);的原子版本(补充说明:这两个方法来自java.util.concurrent)。 - 不要用「加锁」来掩盖「接口本身对并发不友好」:
EditBuffer依赖整数下标,而下标对别人的增删极其脆弱;更友好的设计是引入Position(游标位置)或Selection(选区)类型,让位置能在周围文本被修改时保持含义并主动报告冲突。
- 需要「多个操作合起来原子」时,必须让它们位于同一个
锁的粒度(Lock Granularity)与并发性能
- 定义与目的:粒度指一把锁保护多少数据。细粒度锁(每个对象一把锁)允许更多并行,但需要同时获取多把锁,容易死锁;粗粒度锁(coarse-grained locking) 用一把锁保护许多对象实例甚至整个子系统,简单、不易死锁,但会牺牲并行度。目的是在「性能」与「正确性/可维护性」之间做出有意识的取舍。
- 直观解释(”它是什么?”):细粒度像每间办公室一把钥匙(互不干扰,但你要进两间就可能和人对撞);粗粒度像整层楼一把钥匙(绝不会对撞,但同一时间只能有一个人在这层楼里干活)。
- 关键规则与最佳实践:
- 应用层编程通常优选粗粒度锁或限定;细粒度锁主要用于操作系统内核与设备驱动,那里需要极致性能并配合锁顺序(补充说明:这是 sp21 原文的原话归纳)。
- 库数据结构通常不加同步(把协调留给调用者以保证单线程性能),或者采用监视器模式。
- 图形界面工具包(如 Java Swing)常采用线程限定:只允许一个专用线程访问整棵组件树,其他线程必须通过消息传递请求它代劳。
- 搜索类问题常用不可变数据类型:没有可变状态,就没有竞态也没有死锁。
- 同步是有代价的:一次同步方法调用可能显著变慢,因为要获取锁、操作共享存储、与其他处理器通信——不需要同步时就不要同步。
死锁:成因、四个必要条件与预防(Deadlock)
- 定义与目的:死锁发生在并发模块互相等待对方做某件事时;可能涉及两个以上模块(A 等 B、B 等 C、C 等 A)。死锁的本质特征是依赖关系中存在环。它不威胁正确性(safety),而威胁存活性(liveness):程序不再推进。经典的操作系统理论给出四个必要条件(补充说明):互斥(资源独占)、持有并等待(hold and wait)、不可抢占(no preemption)、循环等待(circular wait)——四者同时成立才可能死锁,因此预防策略就是打破其中至少一条。
- 直观解释(”它是什么?”):银行转账是标准剧本:A 与 B 同时做两账户之间的转账,A 先锁住「转出账户 1」,B 先锁住「转出账户 2」,然后 A 等账户 2 的锁、B 等账户 1 的锁——致命拥抱(deadly embrace),两人都卡住。sp22 的图书馆版本是:Frodo 持有 Two Towers 等着 Return of the King,Gandalf 持有 Return of the King 等着 Two Towers。
- 关键规则与最佳实践:
- 预防方案一:锁顺序(lock ordering)。给需要同时获取的锁定一个全序,所有代码都按该顺序获取。这样 A 若先拿到 Harry 的锁,也必然先拿到 Snape 的锁,等待图中不可能出现环。(sp21 用的是「按人名首字母排序」,并留了一个思考:真实社交网络里人名会重复,更好的排序键是稳定唯一的标识,如账户号或对象身份哈希。)
- 锁顺序的缺点:不模块化(代码必须知道系统里所有的锁),而且在拿到第一把锁之前往往无法知道还需要哪些锁(例如对图做深度优先搜索)。
- 预防方案二:粗粒度锁。用一把锁保护多个对象甚至整个子系统(如让所有
Wizard共用所属Castle的锁),简单可靠,但可能把程序退化成「同一时刻只有一个线程能推进」。 - 重入性可以避免「自己等自己」(同一线程重复获取同一把锁),但不能避免两个对象之间的循环等待;
Wizard.friend的经典死锁正是后者。 - 值得注意:死锁常常可以不发生(例如 A 在 B 拿到第一把锁之前就完成了两把锁的获取与释放),这种「时有时无」使它和竞态一样难以复现与调试。
ReentrantLock 与 synchronized 的取舍(ReentrantLock vs synchronized,补充说明)
- 定义与目的:sp22 原文在「其它互斥技术」中提及 locks、mutexes、semaphores 属于更底层的原语;在 Java 中,除了内建的
synchronized,java.util.concurrent.locks.ReentrantLock提供了显式的锁对象:lock()/unlock()、tryLock()、tryLock(timeout, unit)、lockInterruptibly()、公平锁选项,以及可以绑定多个Condition(用于等待/通知)。补充说明:这些是 Java 生态的机制,不在 6.031 的必讲范围内,但它们解释了「为什么有时要用显式锁」。 - 直观解释(”它是什么?”):
synchronized是自动门(进出自动上锁/解锁,简单但只能在块结构内结束);ReentrantLock是手动门(可以试着推一下看能不能进、可以定个等待上限、可以被中断叫停,但你必须记得出来时把门锁上)。 - 关键规则与最佳实践:
- 用
ReentrantLock时,unlock()必须放在finally里,否则一旦抛异常,锁将永久泄漏。 - 需要「尝试获取、失败就做别的事」或「设定等待上限」时用
tryLock——这是打破死锁四条件中「持有并等待/不可抢占」的实用手段。 - 默认选择仍应是
synchronized(或干脆用监视器模式):更少的代码、更少的出错机会、更容易维护。 - 无论用哪种锁,锁顺序与锁的纪律的论证都要写下来。
- 用
安全性、存活性与其它互斥技术(Safety, Liveness, and Other Techniques)
- 定义与目的:把并发程序的正确性拆成两类性质:安全性(Safety)——程序是否满足其不变量与规格说明?即「能否证明坏事永不发生」(竞态威胁安全性);存活性(Liveness)——程序是否会持续运行并最终做到你想做的事?即「能否证明好事终将发生」(死锁威胁存活性)。此外还有公平性(fairness):模块是否被给予推进所需的处理能力,主要由操作系统的线程调度器决定,但可以通过线程优先级施加影响。
- 直观解释(”它是什么?”):安全性是「绝不闯红灯」,存活性是「最终能到达目的地」。一个既不撞车也永远开不动的程序,是「安全但不活」的。
- 关键规则与最佳实践:
- 分析并发程序时,分别问「会不会把数据搞坏」(safety)与「会不会卡住」(liveness),两者需要不同的推理与不同的防御。
- sp22 指出其他互斥技术包括:锁/互斥量/信号量(多线程抢占式环境),以及数据库事务——事务为一组读写提供互斥式的原子效果,广泛用于分布式客户端/服务器系统;事务不一定要显式加锁,冲突时可以失败并回滚,数据库还能自动管理加锁顺序。
- 协作式并发(
async/await)不只存在于 TypeScript/JavaScript:Python、Swift、Rust、C# 都有类似机制(补充说明,sp22 原文提及)。 - 抢占式并发超出 6.031 范围,sp21 原文指向 6.033(计算机系统工程)与 6.039(操作系统工程)进一步学习。
代码示例与对比分析
场景 1:银行账户——裸的共享可变字段,还是监视器模式?
❌ 错误代码
/** 一台可以被多个取款机共享的银行账户。 */
public class BankAccount {
// 错误:共享可变数据,没有任何保护
private long balance;
public BankAccount(long initial) {
this.balance = initial;
}
/** 存钱。 */
public void deposit(long amount) {
balance = balance + amount; // 读—加—写,不是原子操作
}
/** 取钱。 */
public void withdraw(long amount) {
balance = balance - amount; // 同样不是原子操作
}
/** 查询余额。 */
public long getBalance() {
return balance; // 读也不安全:可能读到改了一半的状态
}
}
【错误代码的问题】
- 丢更新(lost update):两个线程同时
deposit(1),可能都读到 0、都算出 1、都写回 1,最终余额只增加了 1。sp21 的取款机例子中,成对的存/取交易本该让余额保持为 0,实际却经常不为 0。 - 不可复现:竞态是 heisenbug,取决于调度、其他进程、机器负载;加一行
System.out.println常常就让 bug「消失」(只是被掩盖)。 getBalance()也不安全:long在现代 64 位 JVM 上通常是原子的,但 Java 语言规范不保证这一点(非volatile的long/double允许被撕裂读取),而且一旦 rep 变成多个字段(例如还要维护交易计数),读操作就可能看到「改到一半」的表示,违反表示不变量。- 没有任何线程安全论证:代码里既没有锁也没有注释说明它是单线程专用的,维护者无法判断能否安全地在多线程环境使用它。
✅ 正确代码
/**
* 一台可以被多个取款机共享的银行账户。
*
* Rep invariant:
* balance >= 0
* Abstraction function:
* AF(balance) = 一个余额为 balance 分的银行账户
* Safety from rep exposure:
* balance 是 private 的,且方法不返回 rep 的别名
* Thread safety argument:
* 所有对 balance 的访问都发生在 BankAccount 的方法内,
* 而这些方法全部由 BankAccount 实例自身的锁(监视器模式)保护;
* 由于每一次读—改—写都在同一个临界区内完成,操作是原子的。
*/
public class BankAccount {
private long balance;
public BankAccount(long initial) {
this.balance = initial;
checkRep();
}
private void checkRep() {
assert balance >= 0;
}
public synchronized void deposit(long amount) {
balance = balance + amount;
checkRep();
}
/** @throws IllegalArgumentException 如果余额不足 */
public synchronized void withdraw(long amount) {
if (amount > balance) {
throw new IllegalArgumentException("insufficient funds");
}
balance = balance - amount;
checkRep();
}
public synchronized long getBalance() {
return balance;
}
}
【为什么这样更好】 每个公开方法都被同一把锁(this)保护,「读—加—写」被整体变成一个原子区,因此不可能再出现两个线程各自读到同一个旧值的情形。类注释里的线程安全论证与 checkRep() 一起,把「这个类型为什么安全」变成可核查的文字,符合「锁的纪律要写下来」的要求。getBalance() 同样加锁,因为读也可能看到部分修改的状态;在 sp21 的 SimpleBuffer 例子中,连 length() 和 toString() 都被刻意加了锁,理由完全相同。
【代码对比解说】 有人会问:balance = balance + amount 只有一行,为什么需要锁?sp21 的回答是:你无法从 Java 代码看出处理器会执行哪些原子操作——=、+=、++ 三个版本的编译结果甚至完全相同,却都含有同样的竞态。唯一可靠的办法是划定临界区。另一个常见误区是把 long balance 改成 volatile(补充说明):volatile 只保证可见性与不重排序,不保证「读—改—写」的原子性,所以两个并发的 deposit(1) 依然可能丢更新;正确做法要么加锁,要么用 AtomicLong.addAndGet(后者是「使用已有的线程安全类型」策略的例子)。
【设计原则透视】 这是表示不变量(RI)+ 抽象边界与并发的结合:balance >= 0 这条不变量只有在「检查—扣减」被同一把锁保护时才能维持;一旦客户端能直接读到、写到 rep(public 字段),不变量就无从保证——sp21 明确说:If text were public, then clients would be able to read and write it without first acquiring the lock, and SimpleBuffer would no longer be threadsafe. 换言之,封装是线程安全论证的前提条件,这与 Reading 11 中「防止表示暴露」的论证是同一条原则。
场景 2:两账户转账——先锁自己的账户,还是按全局顺序锁?
❌ 错误代码
/**
* 错误:每个线程都先锁「转出账户」,再锁「转入账户」。
* 两个方向相反的转账会互相等待,形成死锁。
*/
public class DeadlockingTransfer {
public static void transfer(BankAccount from, BankAccount to, long amount) {
synchronized (from) { // 线程 A 拿到账户 1;线程 B 拿到账户 2
synchronized (to) { // A 等账户 2;B 等账户 1 → 致命拥抱
from.withdraw(amount);
to.deposit(amount);
}
}
}
}
【错误代码的问题】
- 死锁(liveness 失败):线程 A 持有 1 等 2,线程 B 持有 2 等 1,等待图中出现环,两者永久卡住;账户被锁住,系统停止服务。
- 时有时无,极难复现:若 A 在 B 拿到第一把锁之前就完成了两次获取与释放,就一切正常——这是典型的 heisenbug 式缺陷(sp21 原文:If the locks involved in a deadlock are also involved in a race condition … then the deadlock will be just as difficult to reproduce or debug.)。
- 可扩展性差:任何第三处「先锁 B 再锁 A」的代码(比如一个审计方法、一个批量转账)都会重新引入环,靠人工审查很难维持。
- 持有锁期间做危险工作:如果
withdraw/deposit内部还要做 I/O 或回调,持锁时间会被拉长,加剧争用与死锁概率。
✅ 正确代码
/**
* 正确:给锁定一个全局顺序(按账户号的自然序),所有代码都按这个顺序获取,
* 从而在等待图中不可能出现环。
*/
public class OrderedTransfer {
public static void transfer(BankAccount from, BankAccount to, long amount) {
if (from == to) { // 自转账:一把锁就够,避免自锁比较的歧义
from.withdraw(amount);
to.deposit(amount);
return;
}
BankAccount first = from.accountId() < to.accountId() ? from : to;
BankAccount second = (first == from) ? to : from;
synchronized (first) { // 所有线程都从「小账户号」开始
synchronized (second) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
}
【为什么这样更好】 有了全序之后,A 若先拿到账户 1 的锁,也必然先拿到账户 2 的锁;B 只有在 A 释放账户 1 之后才能开始,于是两个线程的获取顺序一致,等待图中不可能出现环。这正是 sp21 的锁顺序方案(原文用 this.name.compareTo(that.name) < 0 按人名排序),并且回答原文留下的问题:真实社交网络里人名会重复,所以更好的锁序键是稳定且唯一的标识(账户号、用户 ID;补充说明:Java 中常用 System.identityHashCode(obj) 作为兜底,但它有极小概率碰撞,严谨实现需要再加一层 tie-breaker,例如 ConcurrentHashMap 内部的做法)。
【代码对比解说】 synchronized (first) { synchronized (second) { ... } } 是标准的「嵌套锁」写法,注意它之所以可行,前提是方法内部调用的 withdraw/deposit 又去获取同一批锁时不会卡住自己——这依赖 Java 锁的可重入性。可重入性可以消解「同一线程重复获取同一把锁」的自锁,但不能消解两个线程之间的循环等待:Wizard.friend 的死锁就是两把不同的锁在两条线程间形成的环。因此「嵌套锁」的正确用法永远是配合顺序或粗粒度策略,而不能指望可重入性救场。
用等价但更明确的显式锁写法(补充说明)可以再加一层保险:
import java.util.concurrent.locks.ReentrantLock;
public final class LockedAccount {
private final ReentrantLock lock = new ReentrantLock();
private long balance;
public boolean tryTransferFrom(long amount) {
if (!lock.tryLock()) { // 拿不到就先做别的事,绝不死等
return false;
}
try {
if (amount > balance) return false;
balance -= amount;
return true;
} finally {
lock.unlock(); // 必须放在 finally 里
}
}
}
【设计原则透视】 死锁是存活性问题,它不会破坏不变量(钱不会凭空出现或消失),但会让系统「永不推进」。因此分析死锁要用「画等待图找环」的方法,而不是检查后置条件。锁顺序策略体现了「把全局约束集中到一处」的设计思想,但代价是不模块化——代码必须知道系统里所有的锁;粗粒度锁用「牺牲并行度」换取「局部可推理」,而 tryLock 则通过打破「持有并等待/不可抢占」来直接消除环。三种手段对应的是同一组死锁必要条件的不同破法。
场景 3:只给修改器加锁 + 暴露 rep——还是把整个表示关进监视器?
❌ 错误代码
/** 错误:rep 暴露,观察器不加锁。 */
public class SimpleBuffer implements EditBuffer {
// 错误一:public 字段,客户端可以完全绕开锁读写
public String text = "";
public SimpleBuffer() {
text = "";
}
// 错误二:只有 mutator 加锁,observer 不加
public synchronized void insert(int position, String insertion) {
text = text.substring(0, position) + insertion + text.substring(position);
}
public synchronized void delete(int position, int len) {
text = text.substring(0, position) + text.substring(position + len);
}
/** 未加锁的观察器:可能读到修改到一半的表示。 */
public int length() {
return text.length();
}
/** 未加锁的观察器:返回以后 text 立刻可能被别的线程替换。 */
public String toString() {
return text;
}
}
【错误代码的问题】
- 封装失守导致论证失效:
text是public的,任何客户端都能不加锁地读写它;此时无论类内部多小心,锁的约定已经被破坏,sp21 原文明确指出这会直接使类型不再是线程安全的。 - 观察器可能看到「改到一半」的状态:
insert的实现是「切两段 + 拼接 + 整体赋值」,虽因引用赋值而瞬间完成,但若 rep 变成多字段(例如GapBuffer的char[] a+gapStart+gapLength),未加锁的length()完全可能读到gapStart与gapLength不匹配的中间态,违反0 <= gapLength <= a.length - gapStart。 - 返回内部别名的风险:
toString()返回text本身(String不可变所以这里侥幸安全),但如果 rep 是可变类型(数组、List),返回别名就等于把 rep 交给客户端,安全性与线程安全同时崩塌。 - 无文档化的锁纪律:维护者看不出「哪个锁保护哪些字段」,后续改动极易漏加锁。
✅ 正确代码
/**
* SimpleBuffer 是一个线程安全的 EditBuffer,使用简单的 rep。
*
* Rep invariant:
* true
* Abstraction function:
* AF(text) = 字符序列 text[0], ..., text[text.length()-1]
* Safety from rep exposure:
* text 是 private 且不可变
* Thread safety argument:
* 所有对 text 的访问都发生在 SimpleBuffer 的方法内,
* 而这些方法全部由 SimpleBuffer 的锁(监视器模式)保护。
*/
public class SimpleBuffer implements EditBuffer {
private String text;
public SimpleBuffer() {
text = ""; // 构造方法不加 synchronized:对象尚未逸出
checkRep();
}
private void checkRep() {
assert text != null;
}
public synchronized void insert(int position, String insertion) {
text = text.substring(0, position) + insertion + text.substring(position);
checkRep();
}
public synchronized void delete(int position, int len) {
text = text.substring(0, position) + text.substring(position + len);
checkRep();
}
public synchronized int length() {
return text.length();
}
public synchronized String toString() {
return text;
}
}
【为什么这样更好】 所有共享可变数据(此处即 text,也就是表示不变量所依赖的全部字段)都由同一把锁保护,因此两条锁的纪律同时满足:每个共享可变变量都有锁,且涉及不变量的所有变量都在同一把锁下、在释放前重建不变量。观察器也加锁,杜绝了「读到部分修改状态」的可能。类注释里的线程安全论证与 checkRep() 一起使这个类可维护——后来者改动时会看到论证并知道必须保持它。
【代码对比解说】 sp21 用一个很尖锐的练习说明「锁对象≠对象」:假设 list 是 ArrayList<String>,某线程进入 synchronized (list) { ... } 时,它拥有 list 的锁,但这并不阻止其他线程使用 list 的观察器或修改器——只有那些自己也去获取同一把锁的线程才会被挡住。所以两个加法缺一不可:加锁 + 所有访问者都遵守同一约定。synchronized 关键字写在方法签名上只是 synchronized (this) { ... } 的语法糖,用哪种写法不重要,重要的是锁的对象对所有访问者一致。
【设计原则透视】 这里体现了 AF / RI / 表示暴露防护 / 线程安全论证 四者的合流:RI 描述「表示始终必须满足什么」,线程安全论证描述「谁来保证它在并发下仍然成立」,而防止表示暴露是论证能够成立的前提。synchronized 方法把「读—改—写」变成原子区,等价于在 AF 的层面上保证「客户端观察到的永远是一个合法的抽象值」。
场景 4:跨多个方法的原子操作——客户端的发散调用,还是共享一个锁?
❌ 错误代码
/**
* 错误:findReplace 对 buf 做了三次调用,虽然每次调用各自原子,
* 但整个方法不是原子的——别的线程可以在中间改动缓冲区。
*/
public final class TextOps {
/**
* 把 buf 中第一处 pattern 替换为 replacement。
* @return 发生了替换则为 true
*/
public static boolean findReplace(EditBuffer buf, String pattern, String replacement) {
int i = buf.toString().indexOf(pattern); // ① 观察
if (i == -1) {
return false;
}
buf.delete(i, pattern.length()); // ② 删除(此时别的线程可能已插入文本)
buf.insert(i, replacement); // ③ 插入(位置可能已经错了)
return true;
}
}
【错误代码的问题】
- 检查—再行动(check-then-act)竞态:
indexOf得到下标i之后,别的线程可能在i之前插入或删除文本,于是 ② 删掉的是错误的区域,③ 把替换文本插到了错误的位置——数据被静默破坏。 - 三次调用之间失去互斥:每个方法内部虽原子,但方法之间存在交错窗口(在 sp22 的语境里,这相当于在两次
await之间丢失了控制权却没做检查)。 i == -1的返回值语义不可靠:即使返回true,也无法保证「替换谁替换成了什么」与调用者的预期一致。- 错误地依赖了「每个方法原子」这一弱保证:这正是本讲反复强调的——原子性的单位是临界区,不是方法。
✅ 正确代码
/**
* 正确:客户端之间约定用 EditBuffer 对象本身互相同步,
* 从而把三次调用扩大成同一个原子区。
*
* 与之配套,EditBuffer 的接口必须写明:
* Clients may synchronize with each other using the EditBuffer object itself.
*/
public final class TextOps {
/**
* 把 buf 中第一处 pattern 替换为 replacement。
* @return 发生了替换则为 true
*/
public static boolean findReplace(EditBuffer buf, String pattern, String replacement) {
synchronized (buf) { // 与 buf 的所有其他客户端互斥
int i = buf.toString().indexOf(pattern);
if (i == -1) {
return false;
}
buf.delete(i, pattern.length());
buf.insert(i, replacement);
return true;
}
}
}
【为什么这样更好】 这把监视器模式已经在每个方法周围建立的同步区扩大成一个更大的原子区,保证三次方法调用连续执行、不受其他线程干扰。它之所以成立,前提是 EditBuffer 的规格明确宣告「客户端可以用该对象本身互相同步」——这既是文档,也是协议:锁的约定必须所有参与方都遵守才有意义。
【代码对比解说】 一个诱人的「偷懒修法」是给方法加上 static synchronized:
// 看似修好了,其实两个目标都没达到
public static synchronized boolean findReplace(EditBuffer buf, String pattern, String replacement) { ... }
这样确实获取了一把锁,但因为是 static 方法,它获取的是整个类的静态锁,而不是实例对象的锁。后果有两重:其一,性能灾难——同一时刻只允许一个线程执行 findReplace,哪怕它们在编辑完全不同的文档(对多用户编辑器来说,相当于全系统只能有一个人做查找替换);其二,保护无效——真正改动文档的其他代码并不会获取这把类锁,所以竞态依旧存在。这个例子是对「线程安全就是把 synchronized 撒满全程序」这一误解最有力的反驳。
另一个更进一步的修法是从接口层面消除问题:EditBuffer 依赖整数下标,而下标对别人的增删极其脆弱。更好的设计是引入 Position(能抵抗周围插入删除的游标位置)或 Selection 类型;若 Position 周围的文本被其他线程删光,它可以主动告知后续客户端(例如抛出异常),让客户端决定怎么办。这正是「为并发而设计数据类型」的含义。
【设计原则透视】 本场景把 Reading 6 的规格说明与锁的纪律绑在了一起:锁的约定(谁能拿哪把锁)是接口契约的一部分,不写进规格就无法被客户端遵守;同时它还展示了抽象边界的另一面——如果接口的形状(整数下标)本身就迫使客户端做非原子操作,那么再好的加锁也治不了根,得回到 Reading 10/12 的 ADT 设计层面改操作集合。ConcurrentMap.putIfAbsent / replace 正是「把常用复合操作做成原子操作」的标准范例。
场景 5:社交网络好友关系——细粒度锁互相调用,还是一把粗粒度锁?
❌ 错误代码
import java.util.HashSet;
import java.util.Set;
/**
* 错误:用监视器模式实现双向好友关系,friend() 会去调用对方的方法,
* 于是同时持有两把锁——两条线程方向相反时必然死锁。
*/
public class Wizard {
private final String name;
private final Set<Wizard> friends;
// Rep invariant:
// 好友链是双向的:对每个 f in friends,f.friends 包含 this
// Concurrency argument:
// 监视器模式:对 rep 的所有访问由本对象的锁保护
public Wizard(String name) {
this.name = name;
this.friends = new HashSet<Wizard>();
}
public synchronized boolean isFriendsWith(Wizard that) {
return this.friends.contains(that);
}
public synchronized void friend(Wizard that) {
if (friends.add(that)) {
that.friend(this); // 拿着自己的锁,去请求对方的锁 → 死锁风险
}
}
public synchronized void defriend(Wizard that) {
if (friends.remove(that)) {
that.defriend(this); // 同上
}
}
}
【错误代码的问题】
- 经典致命拥抱:线程 A 执行
harry.friend(snape)拿住 Harry 的锁,线程 B 执行snape.friend(harry)拿住 Snape 的锁,随后 A 等 Snape、B 等 Harry,程序直接停住。sp21 原文形容:The program simply stops. - 问题的本质是「持有一些锁的同时等待另一些锁」:即使两个方法各自都正确、都加锁,这个组合仍然可死锁。
- 时对时错:若 A 在 B 拿到第一把锁之前就完成了整个调用,程序看起来完全正常——又是一个难以复现的死锁。
- 不可扩展:每新增一个需要同时操作两个对象的操作(接受好友申请、批量导入好友),都要重新做一次死锁审查。
✅ 正确代码
import java.util.HashSet;
import java.util.Set;
/**
* 正确:粗粒度锁——所有 Wizard 属于同一个 Castle,
* 统一用 Castle 对象的那把锁来同步,任何时刻最多持有一把锁。
*/
public class Wizard {
private final Castle castle;
private final String name;
private final Set<Wizard> friends;
// Rep invariant:
// 好友链是双向的:对每个 f in friends,f.friends 包含 this
// Concurrency argument:
// 粗粒度锁:凡是访问任何 Wizard 的 rep 的方法,
// 都必须在持有 castle 的锁的情况下进行;因此同一时刻
// 只有一个线程能操作本社交网络中的任何关系,绝不会出现
// 「持有 A 的锁等待 B 的锁」的情形。
public Wizard(Castle castle, String name) {
this.castle = castle;
this.name = name;
this.friends = new HashSet<Wizard>();
}
public boolean isFriendsWith(Wizard that) {
synchronized (castle) {
return this.friends.contains(that);
}
}
public void friend(Wizard that) {
synchronized (castle) {
if (this.friends.add(that)) {
that.friend(this); // 仍然是嵌套调用,但只用一把锁 → 不会死锁
}
}
}
public void defriend(Wizard that) {
synchronized (castle) {
if (this.friends.remove(that)) {
that.defriend(this);
}
}
}
}
【为什么这样更好】 所有涉及好友关系的操作都只获取同一把锁,所以「持有 A 的锁等待 B 的锁」这种情形在结构上不可能出现,等待图里不可能有环。维护双向不变量的代码(that.friend(this))依旧可以自然书写,只是它不再需要第二把锁。代价是并行度:整个社交网络同一时刻只有一个线程能推进(相当于退化为顺序执行),这正是粗粒度锁的典型权衡。
【代码对比解说】 两条路线对应死锁必要条件的不同破法:锁顺序(排序后按序获取,破「循环等待」)保持细粒度、保留并行度,但要求代码知道所有锁,且常常「拿到第一把锁之前不知道还需要哪些锁」(sp21 提出的深度优先搜索难题);粗粒度锁(Castle 一把锁)简单、模块化味道更好(锁只属于子系统),但牺牲并行。第三种是用 tryLock 超时/退避(补充说明),破「不可抢占」或「持有并等待」,代价是要处理「拿不到锁时怎么办」这一新问题。工程上,应用层代码通常选粗粒度或限定,操作系统内核才用细粒度 + 严格锁顺序。
【设计原则透视】 本场景展示了不变量跨越多个对象时的锁纪律:f.friends ∋ this 与 this.friends ∋ f 必须同时成立,因此「所有涉及该不变量的变量必须由同一把锁保护」这条规则直接指向粗粒度方案。它同时说明:加锁的位置是一个设计决策,不是机械动作——加了锁却选错锁(或选多把锁),会把正确性问题换成存活性问题,而后者更难发现。
场景 6:复合操作与线程安全集合——synchronizedList 就够了么?
❌ 错误代码
import java.util.*;
/** 错误:以为用了线程安全集合就万事大吉。 */
public class SharedQueue {
private final List<String> list = Collections.synchronizedList(new ArrayList<>());
/** 检查—再行动:两个原子操作合起来并不原子。 */
public String takeFirst() {
if (!list.isEmpty()) { // ① 此刻为空则返回 null
return list.remove(0); // ② 但两者之间别的线程可能已把它清空
}
return null;
}
/** 迭代也需要加锁:否则可能在遍历途中抛 ConcurrentModificationException。 */
public String join() {
StringBuilder sb = new StringBuilder();
for (String s : list) { // 底层迭代器不是线程安全的
sb.append(s);
}
return sb.toString();
}
}
【错误代码的问题】
- 检查—再行动竞态:
isEmpty()与remove(0)各自原子,但组合起来不是;另一个线程可能在两步之间把列表清空,导致remove(0)抛IndexOutOfBoundsException,或者取走「不打算取走」的元素。 - 迭代不是原子的:
Collections.synchronizedList的文档明确要求,遍历时必须自行在列表上加锁,否则可能抛ConcurrentModificationException或读到不一致的内容。 - 误以为「线程安全集合 = 我的操作线程安全」:库只保证单个方法调用的原子性,不保证客户端自己拼出的复合操作。
- 接口语义模糊:
takeFirst()返回null既可能是「队列为空」也可能是「元素确实是 null」,调用者无法区分。
✅ 正确代码
import java.util.*;
import java.util.concurrent.*;
/** 正确:复合操作必须整体加锁;或者干脆使用提供原子复合操作的并发类型。 */
public class SharedQueue {
private final List<String> list = Collections.synchronizedList(new ArrayList<>());
/** 检查与删除在同一个临界区内完成。 */
public String takeFirst() {
synchronized (list) { // 与所有其他使用 list 的客户端互斥
if (list.isEmpty()) {
return null;
}
return list.remove(0);
}
}
/** 迭代也必须持锁,防止遍历途中被修改。 */
public String join() {
synchronized (list) {
StringBuilder sb = new StringBuilder();
for (String s : list) {
sb.append(s);
}
return sb.toString();
}
}
}
/** 另一种更彻底的做法:用 BlockingQueue 把「等非空 + 取出」做成一个原子操作。 */
class BlockingSharedQueue {
private final BlockingQueue<String> queue = new LinkedBlockingQueue<>();
/** 原子操作:要么取到元素,要么一直等到有元素为止。 */
public String takeFirst() throws InterruptedException {
return queue.take();
}
/** 原子操作:offer 与 take 都无需客户端额外加锁。 */
public void put(String s) throws InterruptedException {
queue.put(s);
}
}
【为什么这样更好】 第一种写法把复合操作包进以 list 为锁的临界区,并遵循库文档给出的「迭代时需自行加锁」的约定——这既是正确性要求,也是 sp21 强调的「锁只是一种约定」。第二种写法从接口设计上根治问题:take()(阻塞直到取到元素)本身就是原子操作,客户端根本不需要自己拼装「检查—再行动」,因而也不可能拼错。这正是 Reading 24 的主题,也是 ConcurrentMap.putIfAbsent / replace 这类补充 API 存在的理由。
【代码对比解说】 两种写法代表两种思路:扩大临界区(承认需要客户端协作,把协议写进文档)与改进操作集合(让每个操作本身语义完整、原子)。sp21 说得直白:It’s sometimes useful to make your datatype’s lock available to clients, so that they can use it to implement higher-level atomic operations using your datatype. 但更好的方向是减少这种需要——这也是为什么 ConcurrentMap 要在 Map 之上补几个原子方法。选择哪条路,取决于你能否修改接口:能改就改接口,不能改就得文档化锁协议。
【设计原则透视】 本场景把 ADT 的操作选择与线程安全直接联系起来:操作的语义决定了客户端是否必须做复合操作,而复合操作正是竞态的温床。ConcurrentMap.putIfAbsent 之于 Map 就是这种「为并发补操作」的范例;它也与 Reading 24 的消息传递设计呼应——那里的「LOOK before you TAKE」之所以会出错,正是因为协议强迫客户端用多条消息完成一件本可以是一条消息完成的原子操作。
sp22 原文的异步版本:用 promise 与 holds 实现互斥(TypeScript 对照)
sp22 的 Running Example 是一个图书馆:checkout 是异步的,若书不在馆就等待它被归还。下面的最终实现体现了本讲的两条核心思路:(1)用循环反复检查条件(因为 await 之后世界可能已经变了);(2)把「借出」这一组变更放在一个不含 await 的区间里一次性做完:
// sp22 原版 TypeScript 写法(对照用)
public async checkout(books: Array<Book>, user: User): Promise<void> {
const isInLibrary = (book: Book) => this.inLibrary.has(book);
const notInLibrary = (book: Book) => ! isInLibrary(book);
const waitForBook = (book: Book) => { // requires notInLibrary(book)
const hold = new Deferred<void>();
this.holdsForBook(book).push(hold);
return hold.promise;
};
// 保守地反复等待:await 之后条件可能已被别人破坏
while ( ! books.every(isInLibrary) ) {
await Promise.all(books.filter(notInLibrary).map(waitForBook));
}
// 借出:这一段没有任何 await,因此是一个互斥区间
assert(books.every(isInLibrary));
for (const book of books) {
assert(isInLibrary(book));
this.inLibrary.delete(book);
this.borrowedByUser(user).add(book);
}
this.checkRep();
}
对应的 Java 类比(补充说明):Java 的互斥区间由 synchronized 划定,而「等待条件成立」由 synchronized 配合 wait()/notifyAll() 或 Condition.await()/signalAll()(ReentrantLock)完成。两者的结构惊人地相似:「在循环里等待条件 + 在临界区内一次性完成变更」——sp22 的 while (!books.every(isInLibrary)) 就相当于 Java 中 while (!condition) lock.wait(); 里那个必须存在的 while(防止虚假唤醒与「醒来后条件又被人破坏」)。
与其他设计原则的关联
本讲是 Reading 21(并发) 的直接延续:那一讲建立了共享内存与消息传递两个模型、线程与时间片、交错与竞态、heisenbug 以及四种线程安全策略,并演示了银行账户丢更新的例子;本讲把第四种策略(同步)展开成完整的实现技术。Reading 22(承诺) 提供了本讲的另一条线索——交错点与「没有 await 的代码段是天然互斥区间」,以及 Deferred(承诺者/消费者分离)这一工具,它正是图书馆预约(hold)的实现基础。
本讲的后继是 Reading 24(消息传递):那里给出不靠锁的替代路线——让并发模块只通过线程安全的消息通道通信,把可变状态限定在各模块内部,从而绕开「共享可变数据」这个万恶之源;同时也会看到阻塞队列同样会引入死锁(队列满/空导致的循环等待),与锁的死锁是同一类问题。再往后 Reading 25(套接字与网络) 把消息传递搬到网络上,形成客户端/服务器架构。
向上游追溯:Reading 6、Reading 7(规格说明与设计规格) 说明了为什么「锁协议」必须写进规格(例如 EditBuffer 要声明客户端可用它自己互相同步);Reading 8(不可变性) 给出了最省心的替代策略(共享不可变数据既无竞态也无死锁,sp21 提到布尔可满足性搜索天然适合并行化);Reading 10、Reading 11(ADT、AF 与 RI) 提供了「用锁保护表示不变量」「防止表示暴露是线程安全论证的前提」这些论断的理论基础,checkRep() 的写法也来自那里;Reading 12(接口、泛型与枚举) 关系到「为并发设计操作集合」的接口形态;Reading 3、Reading 4、Reading 13(测试、代码评审、调试) 则解释了为什么竞态与死锁不能靠测试发现、必须在代码评审阶段用「找交错点/找环」的方式审查。
关键要点
- 并发程序的正确性不应依赖时序的偶然:任何以「读—改—写」或「检查—再行动」形式出现的操作,都是竞态候选,必须用同一把锁把整段变成原子区。
- 互斥 = 临界区 = 同一把锁下的独占:在 sp22 的协作式并发里是「不含
await的代码段」,在 Java 里是synchronized块/方法;每一个访问共享可变数据的路径都必须进入临界区(包括观察器)。 - 锁只是约定,封装是前提:只有所有客户端都获取同一把锁,锁才有效;一旦 rep 暴露(
public字段、返回内部别名),线程安全论证立即失效。 - 监视器模式 + 锁的纪律:一个类的所有方法都由同一个锁(通常是
this)保护;涉及同一不变量的所有变量必须由同一把锁保护,并在释放锁前重建不变量;把线程安全论证写在代码里。 - 加锁换来的是存活性风险:死锁源于「持有一些锁并等待另一些锁」形成的环;解法是锁顺序(破循环等待)、粗粒度锁(结构上只持一把)、或
tryLock/超时(破持有并等待/不可抢占);synchronized的可重入性只能救「同一线程重复取同一把锁」,救不了两个对象之间的环。
常见陷阱与注意事项
- 只给修改器加锁,观察器不加锁 → 读到「改到一半」的表示:sp21 反复强调监视器模式里连
length()、toString()都要加锁。后果是客户端可能观察到违反表示不变量的中间状态,且这种 bug 只在特定时序下出现。 - 给
static方法加synchronized以「修复」实例数据的竞态 → 既慢又无效:获取的是整个类的静态锁,导致不同实例的操作被迫串行(多用户编辑器里同一时刻只能有一个人做查找替换),而且真正改动数据的其他代码并不获取这把锁,竞态依旧。 - 把自己的锁暴露出去 / 暴露 rep(
public字段、返回可变内部对象)→ 锁协议被客户端绕过:任何人不加锁就能读写,第 4 种策略立即失效。补充提醒:即便是Collections.synchronizedList返回的「线程安全」列表,迭代与复合操作仍需客户端自行加锁。 - 持有锁期间调用外部代码、做 I/O、或发送消息 → 死锁与性能双重风险:持锁时间被不可控地拉长,并且外部代码可能反过来请求你的锁(或另一把锁);
Wizard.friend之所以危险,正是因为它持着自己的锁去调用对方的方法。 - 以为「用了
volatile或原子类就一定安全」→ 忽略复合操作仍需原子性:volatile只保证可见性与重排序约束,不保证「读—改—写」原子;AtomicLong.incrementAndGet原子,但「先判断再自增」依旧需要compareAndSet循环或锁(补充说明)。 - 靠测试或调试来验证并发正确性 → 永远验证不了:交错数量是天文数字,且缺陷是 heisenbug;正确做法是写出线程安全论证并在代码评审中审查锁的纪律,把「哪把锁保护哪些数据」当作接口契约的一部分来维护。
思考题(带答案)
问题 1:sp22 在单线程的 TypeScript 里说「不含 await 的代码段是互斥区间」,而 sp21 在多线程的 Java 里说「synchronized 块是临界区」。请解释这两种说法为什么是同一个概念的两个版本,并指出 Java 中不能照搬「看代码有没有特殊关键字」这一简单判断的原因。
答案:两者都在描述同一个性质——在某个区间内,访问同一份共享可变数据的其他计算被排除在外。在单线程协作式并发的 TS 中,控制权只在 await(以及 return/throw)处转移,所以「不含 await 的代码段」天然满足互斥,不需要额外机制;在多线程抢占式并发的 Java 中,线程可以在任意指令处被中断,所以互斥不会自动出现,必须由 synchronized(或 Lock)显式划定,而这个区间恰好就是「不含释放点」的区间——从「不让出控制权」的角度看,两者是同构的。不能照搬的原因有三:(1)Java 的交错点不是显式标记的,while (!ready) {} 这种代码在里面毫无 await 式的标记,却可能被抢占,所以「有没有关键字」不是判据,「有没有对共享数据的访问」才是;(2)synchronized 方法/块只对获取同一把锁的线程互斥,锁选错等于没有互斥,而 TS 的关键字 await 是语言级的、不存在「用错锁」的问题;(3)重排序与可见性问题(sp21 的 answer/ready 例子)在 Java 中额外存在,synchronized 同时承担了内存屏障的职责,而在单线程 TS 中没有这个问题。结论:TS 里你推理的是「哪里让出了控制权」,Java 里你推理的是「哪里获取了哪把锁、谁还在不遵守约定」。
问题 2:下面这个类有任何并发缺陷吗?请指出并给出修复方案。
public class Counter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
return count; // 注意这里没有加锁
}
public boolean isZero() {
synchronized (lock) {
return getCount() == 0;
}
}
}
答案:有。getCount() 没有获取 lock,因此它对 count 的读取不受互斥保护:其一,isZero() 虽然持有锁,但它调用的 getCount() 却绕过了锁——所以 isZero() 的临界区实际上并没有保护到那次读,它可能读到别的线程正在 count++ 过程中的值(int 的读在 JVM 上通常不会撕裂,但可见性与重排序不保证,仍可能读到陈旧值,而且这个类一旦把 count 改成 long 或多个字段组成的不变量,问题立刻变成实质性错误);其二,只要有一条访问路径不遵守锁的约定,整个论证就失效——正如 sp21 所说,锁只是一种约定,一个不守约定的客户端就能让系统不再线程安全。修复:让所有访问 count 的路径都获取同一把锁,例如把 getCount() 也改成 synchronized (lock) { return count; }(或改用 this 作为锁对象、按监视器模式统一写 public synchronized int getCount())。修复之后,isZero() 与 getCount() 都以 lock 为锁,由于锁可重入,嵌套调用不会死锁。更好的做法是从接口设计上避免这种复合:让 isZero() 直接比较 count == 0(虽然结果相同,但语义更清楚),并把线程安全论证(「count 的所有访问都由 lock 保护」)写进类注释。
问题 3:图书馆场景中,checkout 早期版本在等待书被归还时先记录下「哪些书在馆」,等所有书到齐后再统一借出,结果出现了竞态:Frodo 在等 Two Towers 期间,Gandalf 把 Fellowship 借走了,而 Frodo 醒来后却认为自己借到了两本书。请说明这个 bug 的性质、sp22 给出的修法,以及修法为什么又引入了死锁。
答案:性质是竞态条件(safety 被破坏):checkout 的行为对某些交错正确、对另一些交错错误——它的正确性取决于「Frodo 等待期间没有人借走 Fellowship」这个时序偶然,而这不是它能保证的。修复分两步:(1)尽早变更——书一到手就立刻在本地标记为已借出(Frodo sees that Fellowship is still in the library and immediately marks it as checked out to himself),不要等到循环结束才统一变更;(2)保守地反复检查——把等待写成循环 while (!books.every(isInLibrary)) { await Promise.all(...); },因为 await 之后条件可能已经被别人破坏;等到条件成立后,再在一段不含 await 的区间里一次性完成所有借出操作(这就是互斥)。这样 Gandalf 在 Frodo 持有 Fellowship 时只能等待。但「一拿到就变更」又引入了新问题:Frodo 按 [fellowship, twoTowers, returnOfKing] 顺序借、Gandalf 按 [returnOfKing, twoTowers, fellowship] 顺序借时,Frodo 持有 Fellowship 等 Two Towers,Gandalf 持有 Return of the King 等 Two Towers,而 Two Towers 归还后又可能被其中一方先取走并继续等待对方的书——形成循环等待的死锁(liveness 被破坏):Frodo 等 Return of the King(Gandalf 持有),Gandalf 等 Two Towers(Frodo 持有),谁都动不了。这正是 Java 中「细粒度锁 + 无顺序」的图书馆版本;它的预防思路与锁顺序一致:让所有客户端按同一种顺序请求多本书(例如按书的唯一标识排序后依次 checkout),并且不要让「持有已借到的书」与「等待尚未借到的书」同时发生——sp22 在练习中提示的思路是改 checkout 的语义(例如先一次性把需要的书全部预留,或者让 checkout 在无法一次满足时回滚已占用的书再重试),从而在依赖图中不产生环。此外,按 Reading 24 的视角,还可以干脆改成消息传递:把「借书」做成一条原子请求消息,由图书馆模块独自串行处理,让客户端根本无法构造出这种交错。
