Reading 22: 承诺与异步计算(Promises)
Reading 22: 承诺与异步计算(Promises)
说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。凡属 Java 生态的补充机制(
Future、CompletableFuture、ExecutorService、ReentrantLock等)均显式标注为「Java 类比/补充说明」,而 promise 三态(pending / fulfilled / rejected)、then()组合、async/await语法糖、Promise.all/Promise.any/Promise.race、事件循环与协作式并发等概念以 sp22 原文为准。
概述
本讲讨论如何用「承诺(promise)」这一抽象来表示已经启动但可能尚未完成的计算,以及如何用 await 运算符和 async 函数声明,让并发代码写起来几乎和顺序代码一样自然。核心设计原则有三条:异步计算的结果必须被封装成一个一等值(promise)而不是靠回调层层嵌套;读取这个值只能通过 then() 或 await(绝不允许忙等待或窥探状态);以及所有交错都只发生在 await 这个明确的让出点上,因此可以靠「互斥区间」推理。它与软件构造三大目标的关系是:Safe from bugs——promise 的静态类型与「必须 await/then 才能取值」的规则,保证依赖异步结果的代码不可能在结果就绪前继续运行;Easy to understand——await 把异步代码还原成直线式的同步代码,避免了回调地狱;Ready for change——promise 可以组合、聚合、串联,这是线程与 worker 难以做到的。
核心概念与设计原则详解
并发、并行与异步(Concurrency, Parallelism, Asynchrony)
- 定义与目的:并发(concurrency)指多个计算在时间上重叠地推进;并行(parallelism)指它们在物理上真的同时执行(需要多核);异步(asynchronous)是接口层面的性质——一个异步函数在计算完成之前就把控制权还给了调用者。三者互不等同:单线程的 JavaScript 可以有大量并发但不是并行;一个同步函数调用也可以发生在多线程程序里。区分它们是理解本讲的起点,因为 promise 解决的是「如何表示并组合异步计算」,而不是「如何制造更多 CPU 并行度」(后者是 Reading 21 与 Reading 23 中的线程话题)。
- 直观解释(”它是什么?”):把并发想成「餐厅里一个服务员同时照看五桌客人」(重叠推进),把并行想成「雇了五个服务员」(同时物理执行),把异步想成「点了菜之后服务员不会站在你桌边等厨房做好,而是先去招呼别人」。异步是一种礼貌的返回方式:我先把「菜会端上来」这个承诺交给你,然后我就去干别的了。
- 关键规则与最佳实践:
- 先问「我需不需要并发」,再问「我需不需要并行」;异步不等于更快,它只是不浪费等待时间。
- 异步函数的返回值不是一个值,而是一个尚未就绪的值的表示;调用方必须显式地处理这种「未就绪」。
- 在单线程环境中,任何一处阻塞都会冻结整个程序;在多线程环境中,阻塞只冻结当前线程。混淆这两种后果是并发 bug 的头号来源。
- 不要在同步函数里偷偷做长时间的异步工作;接口的同步/异步性质必须写进规格说明。
承诺抽象与三种状态(Promise and Its Three States)
- 定义与目的:sp22 原文:A promise represents a concurrent computation that has been started but might still be unfinished, whose result may not be ready yet. 类型是泛型的:
Promise<T>表示一个「将来应当产生类型 T 的值」的并发计算。它是可变的,且有且仅有三种状态:pending(进行中)、fulfilled(已完成,持有 T 类型的值)、rejected(已失败,持有描述失败的 Error 对象)。它把「结果还没到」这件事变成了一个可以传递、可以组合、可以等待的一等值,从而支撑起安全性与可理解性。 - 直观解释(”它是什么?”):promise 就是一张取餐号小票。你不会站在柜台前死死盯着厨房(忙等待),而是拿着小票去坐下;厨房做好了会叫你的号(fulfilled),做失败了会告诉你原因(rejected)。小票一旦被叫号,就再也不会变回「等待中」——它没有「重置」这个操作。也可以把
Promise<T>类比成一个最终只装一个元素的列表:then(g)就像map(g),元素到达后立刻被g处理。 - 关键规则与最佳实践:
- 状态转移是单向的、一次性的:pending → fulfilled 或 pending → rejected,之后不再改变;一个 promise 也可能永远停在 pending(例如等待一个永不发生的事件)。
- 消费者(consumer)只能通过
then()或await读取值;承诺者(promiser)才拥有resolve()/reject()这两个修改器。这个「读写权限分离」是 promise 设计的关键安全属性。 - promise 上没有任何直接观察器:没有
isPending(),也没有get()。这不是疏漏,而是刻意的设计(见下文「绝不忙等待」)。 - 创建 promise 的函数立即返回:
readFile、diskSpace、fetch、timeout都是启动计算后马上返回小票,而不是等计算结束。 - 若一个 promise 被 reject 而无人处理,就是一个被静默丢弃的异常——在 Java 类比里对应「
Future的异常永远没人去get()解包」。
异步函数与 async/await 语法糖(Asynchronous Functions and async/await)
- 定义与目的:sp22 原文:An asynchronous function is a function that returns control to its caller before its computation is done. 用
async声明的函数必须返回Promise;await是一个内置运算符,把Promise<T>变成T:它等到 promise 被 fulfilled,然后拆包取出值;如果 promise 被 rejected,await就抛出那个 Error 对象。它的目的是让异步代码在读者眼里退化为普通的直线代码。 - 直观解释(”它是什么?”):
await常被误解成「启动计算」。恰恰相反:计算早已在进行中,是创建 promise 的那个函数启动的;await只是在处理一次「延迟的返回」。更好的心智模型是:await= 等这个函数的返回值/异常,和调用普通函数拿返回值是一回事,只不过中间隔了一段时间。 - 关键规则与最佳实践:
await只能在async函数内部使用,TypeScript 会静态检查这一点;在非 async 函数中使用await是编译错误。async函数体里的return v会自动 fulfills 该 promise,throw e会自动 rejects 它;函数「走到末尾自然结束」也会 fulfills promise。async/await是语法糖:const data = await promise; ...rest...语义上等价于promise.then(function(data) { ...rest... })。理解这个变换,才能理解交错发生在哪里。void和undefined是两个不同的类型:void的取值集合为空,专用于「没有返回值」的函数,因此Promise<void>用于只为了副作用(如定时器延时)而运行的计算;undefined则恰好有一个值。- 一旦某一行引入
await,它就会「传染」:调用者必须await或then,否则拿不到值(Java 中的对应物是Future,见下)。
then() 组合与回调地狱(then() as Composition; Callback Hell)
- 定义与目的:
then是 promise 的基础操作,既是生产者也是观察器:then(callback: T => U\|Promise<U>): Promise<U>。它把一个「将产生 T」的计算与一个「消费 T、产生 U」的计算组合起来。目的有两个:一是让异步计算像函数组合g ∘ f一样被逐段搭起来;二是取代「回调地狱(callback hell)」——层层嵌套的回调金字塔既难读也难做错误处理。 - 直观解释(”它是什么?”):普通函数组合把
f: S → T与g: T → U组合成g ∘ f: S → U;then做的是同一件事,只不过类型上都套了一层:Promise<T>与T → U组合成Promise<U>。回调还不一定要返回已算好的 U,它可以返回Promise<U>(例如fetch只完成了连接,真正下载正文还要等response.text()),此时then会自动把内外两层 promise 拍平(flatten)。 - 关键规则与最佳实践:
then可以调用任意多次,挂上多个互不影响、各做各事的回调。then在 promise 任何状态下都可调用:pending 时回调将来才跑;已经 fulfilled 时回调立即运行。then()是唯一访问 promise 所计算之值的途径——这既是安全属性(客户端永远不可能看到「缺值」的内部状态),也是并发设计特性:一个并发计算由一串受控、可预测的交错点上的then()组成。- 用
thenCompose/then链接「需要前一步结果的下一步计算」;如果只是并行独立的任务,不要串成链,那会白白损失并发性。
事件循环与协作式(非抢占)并发(Event Loop and Cooperative Concurrency)
- 定义与目的:JavaScript 每个全局环境只有一个控制线程(Workers 会创建新的全局环境,并且通常只靠消息传递通信)。那么「多个异步函数同时进行」是怎么实现的?答案是事件循环(event loop):异步函数在
await处不是忙等,而是给 promise 挂一个回调,然后交出控制权返回调用者;promise 最终被 resolved 是一个事件,由事件循环处理,事件循环再调用那个回调,把控制权还给该异步函数,并从await之后恢复执行。这种并发模型称为协作式(cooperative)或非抢占式(non-preemptive)并发。 - 直观解释(”它是什么?”):把事件循环想成一位只有一个服务员的餐厅。服务员永远不会被客人从背后拍醒(没有抢占),但每当客人说「我去等菜,你先忙别的」(
await),他就去招呼别人。所以只要没人主动让出,其他人就全都饿着——这正是忙等待致命的根源。 - 关键规则与最佳实践:
- 每一个
await都是一个可能让出控制权的地方;但如果 promise 已经 fulfilled,也可能不让出、直接继续。 - 异步函数的第一个
await让出控制权的方式,是把自己的 promise 返回给调用者;之后的await则直接把控制权还给事件循环。 await恢复时,控制权是从事件循环回来的。因此如果事件循环因为被阻塞而永远拿不到控制权,异步函数也永远无法推进。- 一个异步函数在语义上被切分成「
await之间的若干段」,这些段可以与其他异步函数、其他回调交错执行——这也解释了为什么每一段没有await的代码就是一个天然的互斥区间(这一点在 Reading 23 中被系统化)。
- 每一个
错误传播(Error Propagation and Rejection)
- 定义与目的:异步计算失败时,异常不能像同步代码那样直接沿调用栈向上抛——因为调用栈早已返回了。promise 的解法是把失败也变成一种状态:rejected,并把 Error 对象存进 promise。
await一个 rejected promise 会重新抛出该异常;then链会把失败沿着链条传下去,直到有人处理。目的:让异步错误处理和同步的try/catch在写法上尽量一致(可理解性),同时保证异常不会被无声吞掉(安全性)。 - 直观解释(”它是什么?”):同步世界里异常是「沿栈向上跳」;异步世界里没有栈可跳,于是把异常装进一个信封,随小票一起传下去,谁拆包谁面对它。
- 关键规则与最佳实践:
async函数里的throw就是「reject 我自己的 promise」;return v就是「fulfill 我自己的 promise」。- 在
async函数内部用try/catch包住await,可以像捕获同步异常那样捕获 rejected promise——这是await相对裸then的最大可读性优势。 - Java 类比(补充说明):
Future.get()会把任务中的异常包成ExecutionException抛出,必须用e.getCause()解包;CompletableFuture则提供exceptionally/handle做恢复。注意区分「传播」与「恢复」:前者让失败继续上浮,后者产出替代值。 - 永远不要用
catch (Exception e) {}把失败静默吃掉;也不要在catch (InterruptedException e)里不恢复中断标志。
承诺聚合:all / any / race(Aggregating Promises)
- 定义与目的:并行跑多个计算时,常常需要把它们的结果像逻辑与/或一样合并。
Promise.all()相当于逻辑与:把一组 promise 合成一个,等全部 fulfilled 后返回结果数组,但只要有任何一个失败,整个Promise.all也失败;Promise.any()相当于逻辑或:等任意一个成功 fulfill,只有全部失败才失败(适合冗余计算);Promise.race()也是逻辑或,但它等任意一个 settle(fulfill 或 reject 都算),立即以同样的方式 settle——常用于给操作加超时。目的:把「多个并发计算」的协调工作交给库,而不是手写状态机。 - 直观解释(”它是什么?”):
all是「人到齐了才开饭」,any是「谁先到谁点菜,全不来才散伙」,race是「谁先有结果(不管好结果坏结果)就按谁来」。 - 关键规则与最佳实践:
- 用
Promise.all取代「一个一个await」,既避免串行化又表达意图;Java 类比是CompletableFuture.allOf(...)(返回CompletableFuture<Void>,需要再逐个join()取值)与anyOf(...)(补充说明)。 Promise.all的失败语义是「一票否决」,如果你希望「部分成功也要结果」,要用Promise.allSettled之类的语义或自己处理每个 promise 的失败。- 用
Promise.race([fetch(url), timeout(5000)])实现超时的写法很常见,但要记住:落败的那个计算并没有被取消,它只是没人等了。 await的求值顺序是从左到右的(TS/JS 如此,并非所有语言都如此),所以(await a) + (await b)先等a。
- 用
Deferred:把 promise 的修改器打包(Deferred)
- 定义与目的:promise 有两种客户:消费者用
then/await安排后续计算;承诺者负责算出值并 resolve/reject 它。普通 promise 的修改器是通过new Promise((resolve, reject) => {...})这个「构造函数 + 回调」的形式交给承诺者的。另一种更直观的设计模式是Deferred<T>:把Promise<T>与它的两个修改器打包成一个对象——deferred.promise交给消费者,deferred.resolve(t)/deferred.reject(err)留给承诺者。目的:让「由自己代码中的某个事件来完成的 promise」写起来干净、不易把修改器泄露给消费者。 - 直观解释(”它是什么?”):
Deferred就是呼叫器(pager):前台把呼叫器给你(promise),服务员拿着主机(resolve/reject)。只有服务员能按下呼叫键。图书馆例子里「预约(hold)」就是这样一个 Deferred——这与 Reading 23 中checkout等待书籍归还的实现完全一致。 - 关键规则与最佳实践:
Deferred不属于标准 Node/JS 库,但可以用Promise构造函数实现;同理timeout也不在标准库里,要用setTimeout+Promise构造函数(或Deferred)自己写。- 修改器绝不能作为 promise 对象上的公开实例方法暴露出去,否则消费者就能自己 resolve 别人的 promise。
- Java 类比(补充说明):
CompletableFuture本身就兼作Deferred——new CompletableFuture<T>()创建后,持有者调用complete(v)/completeExceptionally(e)来满足它,而把该对象交给消费者去thenApply(...)。二者是同一模式在不同语言里的呈现。 - 一个 Deferred 只能被 resolve 一次;重复 resolve 是设计错误(TypeScript 里后续调用会被忽略,Java 的
complete返回false)。
绝不忙等待(Never Busy-Wait)
- 定义与目的:忙等待(busy-waiting)指在一个紧凑循环里空转,等待某个事件发生,期间不让出控制权。promise 刻意不提供观察器,就是为了让忙等待写不出来。忙等待在并发编程中通常是坏主意(自旋锁等少数例外除外),在 promise/async-await 代码中尤其致命:在单线程模型下它会冻结整个程序,而且往往连自己想等的事件都等不到。
- 直观解释(”它是什么?”):
busyWait(2000)就像一个客人在柜台前站着不走、不停问「好了没」。他堵住了唯一的服务员,于是厨房的消息永远没人传出来——越等越等不到。 - 关键规则与最佳实践:
- 只能通过
await或then与 promise 交互;不要试图轮询其状态(promise.isPending()、promise.get()这些假想 API 不存在是有意为之)。 - 在 Java 中轮询
future.isDone()(补充说明)同样会浪费 CPU;应改用get()、get(timeout, unit)或thenAccept风格的回调组合。CompletableFuture的orTimeout/completeOnTimeout(Java 9+)比手写race风格更清晰。 - 忙等待不仅浪费 CPU,还会掩盖时序 bug(heisenbug):加了打印或断点后行为大变,问题反而更难定位(参见 Reading 13 调试)。
- 需要「等一会儿」时使用定时器 promise(
timeout)而不是空转循环;需要「等条件成立」时应该等一个由他人 resolve 的 Deferred。
- 只能通过
Java 对应机制:Future、CompletableFuture、ExecutorService(Java 类比/补充说明)
- 定义与目的:Java 没有与 sp22 完全对应的孪生章节(sp21 的对应阅读是「Concurrency」,讲的是线程与共享内存)。在 Java 中表示「异步计算结果」的一等值主要有两种:
java.util.concurrent.Future(老式、以阻塞式get()为主)与CompletableFuture(可组合、可回调,最接近 promise/then)。ExecutorService则负责在哪个线程上执行这些计算。 - 直观解释(”它是什么?”):
ExecutorService是「后厨团队」,submit()是「下单」,返回的Future就是取餐号。Future.get()相当于站在柜台前等(阻塞线程),而CompletableFuture.thenApply(...)相当于留下电话号码(注册回调,不占线程)。 - 关键规则与最佳实践:
Future.get()会阻塞调用线程,并抛出受检异常InterruptedException(中断)与ExecutionException(任务失败),必须显式处理。CompletableFuture默认在ForkJoinPool.commonPool()上执行;生产代码应显式传入自己的Executor,避免与库代码争抢公共池。ExecutorService必须shutdown(),否则程序可能无法退出(non-daemon 线程仍在运行)。await≈thenApply的语法糖,而不是get()的语法糖:await不占用线程(在 JS 中根本不阻塞线程),get()占用线程。这是本讲最需要辨析的一处对照,详见「代码示例与对比分析」的场景 5。
代码示例与对比分析
场景 1:三个并行的取数任务——先提交全部,还是提交一个等一个?
❌ 错误代码
import java.util.List;
import java.util.concurrent.*;
/** 读取三份文件并拼接,使用线程池并行取数。 */
public class Fetcher {
/** 模拟一次耗时的远程读取。 */
private static String fetch(String url) {
// 假设这里是一次需要数秒的网络或磁盘操作
return url + ":200";
}
/** 错误:提交一个任务就立刻 get 一个,任务被强行串行化。 */
public static String fetchAll(List<String> urls, ExecutorService executor)
throws Exception {
StringBuilder out = new StringBuilder();
for (String url : urls) {
Future<String> future = executor.submit(() -> fetch(url));
// 阻塞等待这一个完成后,才去提交下一个任务
out.append(future.get());
}
return out.toString();
}
}
【错误代码的问题】
- 完全丧失并发性:第二个任务的
submit发生在第一个任务的get返回之后,三个计算实际上变成了串行执行,总耗时是三者之和而不是最大值。 - 违背了本讲的中心思想:sp22 原文明确指出,
totalBalance之所以并发,正是因为它先把两个 promise 都拿到手,把await留到后面;这段代码把顺序写反了。 - 语义上「看起来」是并行的:代码里有线程池、有
Future,很容易骗过代码评审——bug 是性能问题而非正确性问题,测试不会红。 - 异常会立刻中断整个循环:第一个任务的失败会让后面两个任务根本不会被提交,哪怕它们本来可以成功。
✅ 正确代码
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
/** 读取三份文件并拼接,使用线程池并行取数。 */
public class Fetcher {
private static String fetch(String url) {
return url + ":200";
}
/** 正确:先把所有计算都启动,再统一收割结果。 */
public static String fetchAll(List<String> urls, ExecutorService executor)
throws Exception {
List<Future<String>> futures = new ArrayList<>();
for (String url : urls) {
// 第一步:只提交,不等待——所有计算同时在跑
futures.add(executor.submit(() -> fetch(url)));
}
StringBuilder out = new StringBuilder();
for (Future<String> future : futures) {
// 第二步:逐个收割;此时它们多半已经完成,get 立即返回
out.append(future.get());
}
return out.toString();
}
}
【为什么这样更好】 提交与收割被明确拆成两个阶段,于是所有 fetch 调用同时在池中的线程上推进,墙钟耗时从「三者之和」降到「三者最大值」。这段代码与 sp22 的 totalBalance 一一对应:const checkingPromise = getBalance('checking'); const savingsPromise = getBalance('savings'); return (await checkingPromise) + (await savingsPromise);——先把 promise 收集起来,再 await。若把 await 挪到每一个赋值语句上(const checking = await getBalance('checking')),就退化成本场景的错误版本。
【代码对比解说】 关键差别只有一个字:await/get 的位置。sp22 用一组练习专门训练这种眼力,比如 const checking = getBalance('checking'); const savings = getBalance('savings'); return checking + savings; 是静态类型错误(把两个 Promise<number> 相加),而 const checking = await getBalance('checking'); const savings = await getBalance('savings'); 能编译但不并发。在 Java 中这一课同样成立,而且更危险:Future 是值、可以相加、可以放进 List,编译器不会拦你,静默的性能损失只有压测才能发现。顺带一提,CompletableFuture.allOf(f1, f2, f3).join() 是「聚合」版本的写法,语义上等价于 Promise.all。
【设计原则透视】 这属于规格说明与性能契约的交叉点:fetchAll 的方法规格应当写明「三个取数并发进行」,否则调用者无从判断实现是否兑现了承诺。它同时暴露了 ADT 设计的一条老规矩(Reading 10、Reading 11):操作的语义要能被客户端推理。在并发语境下,「什么时候发生交错」也是语义的一部分,因此必须在规格或注释里写清楚,这也就是 sp22 反复强调的「正确地推理交错点」。
场景 2:等待结果——忙等待轮询状态,还是阻塞/回调?
❌ 错误代码
import java.util.concurrent.*;
/** 错误:轮询 Future 的状态,同时在单线程池里做「后台」工作。 */
public class BusyWaiter {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> {
Thread.sleep(2000);
return 42;
});
// 忙等待:空转消耗 CPU,而且完全不做别的事
while (!future.isDone()) {
// 什么也不做,只是不停地问「好了没」
}
System.out.println(future.get());
executor.shutdown();
}
}
【错误代码的问题】
- 白白烧掉 CPU:这两秒里处理器被一个什么都不做的循环占满,其他线程/进程被拖慢。
- 无法被中断:这个循环不响应中断,也无法设置超时;程序卡住时只能强杀。
- 在单线程模型下会彻底死锁:sp22 的
busyWait例子正是这个后果——busyWait的循环体里没有await,因此事件循环永远拿不到控制权,其他所有异步函数都停摆;更糟的是,它等待的事件本身要靠事件循环来处理,所以那个「promise 是否已 fulfilled」的检查永远为假,循环永远出不来。 - 掩盖时序 bug:忙等待对时序极其敏感,加一行
println行为就变(Reading 13 中的 heisenbug)。
✅ 正确代码
import java.util.concurrent.*;
/** 正确:让出 CPU 去等,或用回调/超时把等待交给库。 */
public class Waiter {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
// 写法一:阻塞式等待——线程被挂起,CPU 交给别人用
Future<Integer> future = executor.submit(() -> {
Thread.sleep(2000);
return 42;
});
System.out.println(future.get(5, TimeUnit.SECONDS)); // 可设超时
// 写法二:回调式等待——不占用调用线程,最接近 sp22 的 then()
CompletableFuture<Integer> promised = CompletableFuture.supplyAsync(() -> {
sleepQuietly(2000);
return 42;
}, executor);
promised.thenAccept(answer -> System.out.println("answer = " + answer));
executor.shutdown();
}
private static void sleepQuietly(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
【为什么这样更好】 get() 把线程挂起(不消耗 CPU),get(timeout, unit) 还给出了失败上界;thenAccept 则连调用线程都不占,语义上与 sp22 的 promise.then(function(data) {...}) 完全同构。这段正确代码同时演示了 sp22 的核心纪律:永远用 await/then 与 promise 交互,绝不用轮询。原文还解释了为什么 promise 索性不提供观察器:If a Promise provided observers, then programmers would be tempted to write busy-waiting code like this ——把危险 API 直接删掉,是从设计上「让错误写法写不出来」。
【代码对比解说】 两种等待在「谁占着资源」上截然不同:忙等待占着 CPU,get() 占着线程(但释放 CPU),then 回调两者都不额外占用。在 JavaScript 的单线程世界里,占着「线程」就等于占着「整个事件循环」,所以 promise 必须提供 then 这种不占线程的机制,并且必须禁止忙等待。Java 可以「奢侈地」用阻塞 get(),因为线程便宜(相对 JS 的唯一线程而言)——但请记住 Reading 22 的错误示范:在单线程 executor 上,阻塞就等于 JS 里的忙等待,见场景 5。
【设计原则透视】 这是表示独立性与抽象边界的典范:promise 对外只暴露 then/await 这条「注册后续计算」的通道,把状态与值藏在抽象边界内,客户端因此无法写出依赖内部状态的脆弱代码。用 Reading 6 的话说,promise 的规格规定了「什么时候能得到值」,而把「值以什么形式暂存」留给实现——这使得库可以自由优化,而客户端代码不受影响。
场景 3:异步失败——静默吞掉,还是显式传播/恢复?
❌ 错误代码
import java.util.concurrent.*;
/** 错误:把所有失败都悄悄变成默认值。 */
public class BalanceReader {
private static String readFile(String account) throws Exception {
// 文件不存在时抛出异常
throw new java.io.FileNotFoundException(account);
}
public static int getBalance(String account, ExecutorService executor) {
Future<String> future = executor.submit(() -> readFile(account));
try {
return Integer.parseInt(future.get());
} catch (Exception e) {
// 出什么事都当作余额 0
return 0;
}
}
}
【错误代码的问题】
- 异常被静默吞掉:调用者拿到
0却毫不知情,与「账户真的是 0 元」无法区分——这正是 sp22 所说的「无人处理的 rejection」。 - 异常类型信息丢失:
ExecutionException只是包装,真正的cause(文件不存在 / 权限不足 / 网络中断)被丢弃,故障无法定位。 - 错误地统一了可恢复与不可恢复的失败:
InterruptedException(线程被要求停止)与「业务失败」被同样对待,中断信号被吞,线程池的关闭逻辑会失效。 - 掩盖了「格式非法」与「读取失败」的区别:
parseInt的NumberFormatException(对应 sp22getBalance里的throw new Error('account does not contain a number'))本应是明确的一类 rejection。
✅ 正确代码
import java.io.IOException;
import java.util.concurrent.*;
/** 正确:分类处理中断与失败,并保留原始异常。 */
public class BalanceReader {
private static String readFile(String account) throws IOException {
throw new java.io.FileNotFoundException(account);
}
/**
* @param account 账户文件名
* @return 账户余额
* @throws IOException 如果账户无法读取或内容不是数字
*/
public static int getBalance(String account, ExecutorService executor)
throws IOException {
Future<String> future = executor.submit(() -> readFile(account));
final String data;
try {
data = future.get();
} catch (InterruptedException e) {
// 有人在要求本线程停止:恢复中断标志并向上传递
Thread.currentThread().interrupt();
throw new IOException("interrupted while reading " + account, e);
} catch (ExecutionException e) {
// 解包任务内部的真实原因,不要丢掉它
throw new IOException("could not read " + account, e.getCause());
}
try {
return Integer.parseInt(data);
} catch (NumberFormatException e) {
throw new IOException(account + " does not contain a number", e);
}
}
}
【为什么这样更好】 三种结局被清楚地区分开:中断被恢复标志后继续上浮,任务内部的失败被解包成带 cause 的领域异常,格式错误被明确标注。调用者可以针对性地处理,而不是面对一个来历不明的 0。这正是 sp22 中 await 的语义:If the promise is rejected, then await throws an exception instead, using the Error object that the computation stored in the promise ——失败必须以异常的形式重新出现在等待者面前,而不是变成某个「看起来正常」的返回值。CompletableFuture 的 exceptionally(补充说明)则适用于确实有合理默认值的场景。
【代码对比解说】 用 CompletableFuture 写同一件事会更接近 sp22 的写法:
// 传播:把失败沿 then 链传下去(对应 await 抛异常)
CompletableFuture<Integer> balance =
CompletableFuture.supplyAsync(() -> readFileQuietly(account), executor)
.thenApply(Integer::parseInt);
// 恢复:给失败提供一个替代值(对应 try/catch 包住 await)
CompletableFuture<Integer> safe =
balance.exceptionally(err -> 0);
thenApply 只在成功时运行,失败会自动跳过后续的 thenApply 直到遇到 exceptionally/handle——这就是 promise 链上的「错误传播」,和 sp22 描述的行为一致。注意 exceptionally 不是「吞掉异常」,它是显式声明「这类失败我接受默认值」,这是有意的设计决定而非疏忽。
【设计原则透视】 这是规格说明(Reading 6)与异常设计的结合:方法的 @throws 子句就是它的失败契约,也是客户端唯一能依赖的信息。把 ExecutionException 原样抛出会把「实现用了线程池」这一实现细节泄露到抽象边界之外,等于让表示泄漏(rep exposure 的异常版本);正确做法是翻译成领域异常。同时,静默返回 0 违反了「让错误尽早、响亮地暴露」的原则,会让 bug 漂移到离现场很远的地方才爆发。
场景 4:手写状态标志,还是用一个可以被外部事件完成的 promise?
❌ 错误代码
/** 错误:用共享可变标志位 + 轮询来模拟「由别人完成的结果」。 */
public class Downloader {
private boolean ready = false; // 没有同步,且被多个线程读写
private String content;
/** 在另一个线程里被调用。 */
public void finishDownload(String text) {
this.content = text;
this.ready = true;
}
/** 在等待的线程里被调用。 */
public String await() {
while (!ready) {
// 忙等待,而且根本看不到 ready 的变化(可见性问题)
}
return content;
}
}
【错误代码的问题】
- 忙等待 + 冻结执行流:与场景 2 同样的病,且这次连「等的事件由谁触发」都依赖同一个被堵住的线程。
- 可见性问题:
ready与content都不是volatile,等待线程可能永远读到旧值(sp21 的computeAnswer/useAnswer例子演示了这一点)——甚至可能先看到ready == true再看到空的content(重排序)。 - 没有失败通道:下载失败时无法通知等待者,只能永远等待。
- 接口把实现细节暴露给所有人:任何人都可以调用
finishDownload,消费者与承诺者的权限边界荡然无存。
✅ 正确代码
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
/**
* 正确:CompletableFuture 就是 Java 里现成的 Deferred。
* 承诺者持有它并调用 complete/completeExceptionally,消费者只拿到 CompletionStage。
*/
public class Downloader {
private final CompletableFuture<String> promise = new CompletableFuture<>();
/** 交给消费者:只能注册后续计算,不能修改状态。 */
public CompletionStage<String> content() {
return promise;
}
/** 只有承诺者能调用。 */
public void finishDownload(String text) {
promise.complete(text);
}
/** 失败时也必须有人来 settle 它,否则等待者会永远 pending。 */
public void failDownload(Throwable cause) {
promise.completeExceptionally(cause);
}
}
【为什么这样更好】 CompletableFuture 把「创建」与「完成」分开:创建者拿到可写的对象,消费者只拿到 CompletionStage(只读视图,只能挂回调),于是权限边界由类型系统保证——这正是 sp22 对 Deferred 的描述:only the promiser … ends up having access to these mutators。失败也有一等表示(completeExceptionally),等待者不会无限挂起。同步问题交给库:CompletableFuture 内部保证可见性与线程安全,不需要手写 volatile。
【代码对比解说】 sp22 中的 Deferred<T> 由三部分组成:deferred.promise(给消费者)、deferred.resolve(t)(承诺者)、deferred.reject(err)(承诺者)。Java 的 CompletableFuture 把这三件事放在一个对象上,靠返回视图的类型(CompletionStage vs CompletableFuture)来区分权限;TypeScript 则靠「不把修改器做成公开方法」来区分。这一个小例子几乎就是 Reading 23 图书馆例子中 hold 的雏形:checkout 创建 new Deferred<void>() 放进预约列表并 await hold.promise,checkin 找到它并 deferred.resolve()。
【设计原则透视】 这是抽象边界 + 权限最小化的实践:谁可以改变对象状态,应当由类型而不是文档和自觉来约束。同时它体现了 Reading 11 的表示不变量思想——promise 的 RI 是「状态单调地从 pending 走向 fulfilled/rejected,且不会再变」,而保证这条 RI 的唯一手段就是把两个修改器锁在抽象边界内。
场景 5:在单线程执行器里「等自己」——把 await 误当成 Future.get
❌ 错误代码
import java.util.concurrent.*;
/** 错误:在单线程池里,任务内部又提交任务并等待它完成。 */
public class SelfDeadlock {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> outer = executor.submit(() -> {
// 这个任务运行在唯一的线程上
Future<Integer> inner = executor.submit(() -> 42);
// 排在前面的 inner 只能等这条线程空出来——可这条线程正在等它
return inner.get();
});
System.out.println(outer.get()); // 永远打印不出来
executor.shutdown();
}
}
【错误代码的问题】
- 永久死锁:唯一的线程被外层任务占着并阻塞在
inner.get(),而inner只有等这条线程空出来才能运行。系统就此冻结。 - 这正是 sp22 描述的「事件循环被阻塞」的 Java 版本:if the event loop never gets control, because it is blocked, then asynchronous functions won’t be able to make progress either。单线程池 + 阻塞等待 = 单线程事件循环 + 忙等待。
- 难以察觉:代码在
newFixedThreadPool(4)下往往能正常工作,一换成单线程池就挂,属于典型的环境相关 heisenbug。 - 没有超时与失败出口:一旦发生,程序既不报错也不退出,只是静静地卡住。
✅ 正确代码
import java.util.concurrent.*;
/** 正确:用组合而不是「在线程上阻塞等待同一条线程」。 */
public class NoSelfDeadlock {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
// 写法一:把两步计算组合成一个异步流水线,中途不让出线程去阻塞
CompletableFuture<Integer> pipelined =
CompletableFuture.supplyAsync(() -> 42, executor)
.thenApply(n -> n + 0);
System.out.println(pipelined.get());
// 写法二:确实需要嵌套提交时,给内层换一个执行器
ExecutorService inner = Executors.newSingleThreadExecutor();
Future<Integer> outer = executor.submit(() -> inner.submit(() -> 42).get());
System.out.println(outer.get());
executor.shutdown();
inner.shutdown();
}
}
【为什么这样更好】 写法一把「提交并等待」换成「注册后续计算」,任务之间不再互相占线程,因此单线程池也能跑得动——这正是 sp22 中 await/then 在单线程 JS 里可行的根本原因。写法二承认「阻塞等待」的代价,用独立资源(第二个线程)来消化它,避免循环等待。两种写法都保证了「没有线程被要求等待自己」。
【代码对比解说】 本场景把前面所有对照收束成一个精确的类比:
| sp22(TypeScript) | Java |
|---|---|
await promise | 语义等价于 thenApply 组合;不等价于 Future.get() |
| 事件循环(唯一线程) | 单线程 ExecutorService |
| 阻塞事件循环 → 全部停摆 | 单线程池里阻塞 → 死锁 |
Promise.all | CompletableFuture.allOf |
Promise.race | applyToEither / orTimeout(Java 9+) |
new Deferred<T>() | new CompletableFuture<T>() + complete/completeExceptionally |
| busy-wait 循环 | while (!f.isDone()) {} |
差异的根源是:JS 的 await 不占线程,Java 的 get() 占线程。所以在 Java 里判断「能不能在这儿阻塞」必须回到 Reading 21 与 Reading 23 的问题:这条线程是不是还有别的事要做(比如驱动别的任务)?
【设计原则透视】 这是死锁(liveness)问题的第一个具体面孔,sp22 把它归入 liveness:Does the program keep running and eventually do what you want, or does it get stuck somewhere waiting forever for events that will never happen? 判断依据是「等待图里有没有环」——本场景的环是「外层任务 → 内层任务 → 同一条线程 → 外层任务」。这个「画等待图找环」的方法在 Reading 24 分析消息传递死锁时会再次使用,在那里环出现在两个队列之间。
与其他设计原则的关联
本讲的 promise 抽象建立在 Reading 20(回调函数) 之上:回调是「在计算完成时被调用」的最朴素机制,而 promise 是把它包装成一等值的改进版;sp22 明确指出,在 promise 出现之前回调是 JavaScript 实现异步行为的最常见方式,至今仍广泛存在于生态中。Reading 21(并发) 提供了两个并发模型(共享内存与消息传递)、竞态条件、交错与 heisenbug 的背景知识,并解释了为什么「正确性不该依赖时序的偶然」;本讲则在单线程、协作式并发的语境下把交错点精确到 await。
本讲的直接后续是 Reading 23(互斥):那一讲把 promise 的 ADT 补全(引入 Deferred 的 resolve/reject 修改器),并用图书馆预约(hold)的完整例子展示「用 promise 等待一个由自己代码触发的事件」;它还系统化了本讲最后提到的直觉——没有 await 的代码段是一个天然互斥区间。再往后是 Reading 24(消息传递):那里的 put/take 才是真正会阻塞线程的操作,与本讲的 await(不占线程)形成鲜明对照;理解这个差别,才能理解为什么消息传递中「阻塞是双刃剑」。Reading 25(套接字与网络) 会把消息传递搬到网络上,而网络 I/O 天然是异步的,正是 promise 的用武之地。
与更早的章节也有清晰依赖:Reading 6、Reading 7(规格说明与设计规格) 要求异步 API 的规格写明「何时 fulfills、何时 rejects、是否可取消」;Reading 8(不可变性) 是异步结果能被安全共享的前提(多个回调可能持有同一份结果);Reading 10、Reading 11(ADT、AF 与 RI) 提供理解 promise 的语言——promise 是可变类型,其三态转移就是它的表示不变量,而「只有承诺者能改状态」就是防止表示泄漏的边界;Reading 12(接口、泛型与枚举) 解释了 Promise<T> 的泛型与「消息/结果类型用带标签的联合」这两件事;Reading 13(调试) 提醒我们,异步与时序 bug 极易成为 heisenbug。
关键要点
- promise 表示「已启动但可能未完成的计算」,且只有三态:pending → fulfilled / rejected,单向、一次性、不可重置;
await把Promise<T>变成T,rejected 时抛异常。 await不是启动计算,而是处理延迟返回;计算在创建 promise 的那一刻就开始了。async/await只是then()与 promise 状态转移的语法糖。- 绝不允许忙等待,也不允许窥探 promise 状态:只能通过
await或then与之交互;promise 不提供观察器是刻意的设计,因为「把危险 API 删掉」比「写文档劝阻」更可靠。 - 交错只发生在
await点:(单线程协作式并发下)没有await的代码段必不被打断——这条直觉是 Reading 23 互斥的基础。 - 异步 ≠ 并发 ≠ 并行:异步是接口性质(提前返回),并发是结构性质(重叠推进),并行是硬件性质(同时执行);Java 里
Future.get()阻塞线程,CompletableFuture.thenApply不占线程,而 sp22 的await属于后者。
常见陷阱与注意事项
- 把
await写进循环的赋值里,导致假并发 → 性能损失:for (u : urls) results.add(executor.submit(...).get())与const x = await f(i)都是「提交一个等一个」。后果是任务被静默串行化,测试全绿但墙钟时间翻倍;正确做法是先全部启动,再统一等(Promise.all/allOf)。 - 在单线程执行器里阻塞等待同一条线程上的任务 → 死锁:把
await的直觉直接套到Future.get()上,在 JS 里表现为阻塞事件循环(全部停摆),在 Java 里表现为单线程池自锁。后果是程序既不报错也不推进,只能强杀。 - 忙等待或轮询状态(
while (!f.isDone())、假想的promise.isPending())→ 烧 CPU 且等不到结果:在单线程模型中,被等的事件本身要靠事件循环处理,而事件循环正被这个循环堵死,于是形成「越等越等不到」的活锁式冻结;同时它会掩盖真正的时序问题,形成 heisenbug。 - 忘记给失败留通道(不 reject、不
completeExceptionally、catch (Exception e) {})→ 等待者永远 pending,或错误被静默吞掉:前者是 liveness 问题(程序卡住),后者是 safety 问题(错误结果被当成正常结果继续传播),而且都无法通过测试发现。 - 把 promise 的修改器暴露成公开方法,或把
CompletableFuture直接交给消费者 → 抽象边界失守:任何客户端都能 resolve 别人的 promise,或调用complete、cancel破坏状态机,表示不变量从此不可维护;应只交出CompletionStage这样的只读视图。 - 用「上帝式」
Promise.all吞掉部分失败,或以为Promise.race会取消落败者 → 语义误解:all是一票否决,race只是不再等待、不会停止落败的计算(超时场景下后台请求仍在跑,可能仍在写入共享状态)。
思考题(带答案)
问题 1:下面两段 TypeScript 代码(sp22 原文风格)都用来读两个账户的余额并求和。请分别说明它们的并发行为与静态类型结果,并给出对应的 Java 写法。
// 版本 A
const checking = getBalance('checking');
const savings = getBalance('savings');
return (await checking) + (await savings);
// 版本 B
const checking = await getBalance('checking');
const savings = await getBalance('savings');
return checking + savings;
答案:版本 A 是并发的:两次 getBalance 调用先各自启动一个后台计算并立刻返回 promise,await 被推迟到最后,因此「读 checking」与「读 savings」可以重叠进行,总耗时接近两者最大值。这里两个 await 的顺序并不影响并发性,只影响恢复的顺序(TS/JS 从左到右求值,所以先恢复 checking)。版本 B 是串行的:第一个 await 必须等到 checking 读完,控制权才回到这一行,getBalance('savings') 才被调用;总耗时是两者之和。两者都能通过静态检查(都得到 number),所以错误不会被编译器发现,只会表现为性能问题——这正是本讲要训练的「看 await 位置判断并发性」的眼力。对应的 Java 写法:版本 A 是「先把两个 Future 收进变量,再依次 get()」(或 CompletableFuture 的 allOf/thenCombine);版本 B 是「提交一个、get() 一个」。反过来,若写成 const checking = getBalance('checking'); const savings = getBalance('savings'); return checking + savings;(少了 await),就是静态类型错误:Promise<number> + Promise<number> 不合法——这是静态检查在我们这边的唯一一次帮忙。
问题 2:为什么 Promise 不提供 isPending()、get() 这类观察器?请说明至少两个具体危害,并解释 Java 中对应的「观察器」是什么、该如何避免。
答案:sp22 原文给出的理由是:一旦有了观察器,客户端就会忍不住忙等待——while (promise.isPending()) { } 然后 promise.get()。危害有两个层面:(1)程序冻结:忙等待循环从不交出控制权,在单线程 JS 模型下事件循环永远拿不到控制权,其他异步函数全部停摆;(2)越等越等不到:被等的 promise 之所以能变成 fulfilled,恰恰需要事件循环去处理它的回调;事件循环被堵住,promise 永远不会 fulfilled,isPending() 永远返回 true,循环永远不退出——这是一个自我封闭的死局。此外还有(3)破坏 promise 的安全设计:任何客户端都能窥见「尚未有值」的内部状态,很容易写出「先检查再取值」的竞态代码。Java 中对应的观察器是 Future.isDone() / Future.isCancelled() 加 get() 的轮询组合;避免方式是改用 get()(阻塞但释放 CPU)、get(timeout, unit)(有上界),或 CompletableFuture.thenAccept / orTimeout(不占线程、由库负责唤醒)。核心原则一致:把「等待」表达成注册后续计算,而不是表达成反复询问状态。
问题 3:sp22 把 Promise<void> 用于 timeout(milliseconds) 这样的纯计时器。为什么一个「空 promise」是有用的?void 与 undefined 有何区别?并说明 Deferred 在其中的作用。
答案:timeout 的 promise 不产生任何有用的值,但它的完成本身就是有用的事件:await timeout(2000) 之后我们就知道「2000 毫秒确实已经过去」,可以在那一刻触发后续计算。所以空 promise 完全对应「返回 void 的函数」——它存在的意义是副作用与时序,而不是数据。void 与 undefined 的区别是类型论层面的:void 是「取值集合为空」的类型,专门用作无返回值函数的返回类型,没有任何值属于它;而 undefined 是「恰好有一个值 undefined」的类型,那个值是货真价实的一等值,可以赋给变量、放进数据结构、作为参数传递。因此「不会产生值的计算」用 Promise<void>,而不是 Promise<undefined>。Deferred 的作用在于:timeout 这类函数既不在标准库里(Node 没有现成的 timeout),也无法靠外部设备完成——它必须由我们自己的代码在计时器触发时完成,因此需要一个持有 resolve 修改器的对象。实现就是 const deferred = new Deferred<void>(); setTimeout(() => deferred.resolve(), milliseconds); return deferred.promise;——把 promise 交给消费者,把修改器留在承诺者手里。Java 类比是 new CompletableFuture<Void>() 配合一个定时任务调用 complete(null),或直接用 CompletableFuture.delayedExecutor(补充说明)。
