Reading 22: 承诺与异步计算(Promises)

目录 · ← l21 · l23 →

Reading 22: 承诺与异步计算(Promises)

说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。凡属 Java 生态的补充机制(FutureCompletableFutureExecutorServiceReentrantLock 等)均显式标注为「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 的函数立即返回readFilediskSpacefetchtimeout 都是启动计算后马上返回小票,而不是等计算结束。
    • 若一个 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 声明的函数必须返回 Promiseawait 是一个内置运算符,把 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... })。理解这个变换,才能理解交错发生在哪里。
    • voidundefined 是两个不同的类型void 的取值集合为空,专用于「没有返回值」的函数,因此 Promise<void> 用于只为了副作用(如定时器延时)而运行的计算;undefined 则恰好有一个值。
    • 一旦某一行引入 await,它就会「传染」:调用者必须 awaitthen,否则拿不到值(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 → Tg: T → U 组合成 g ∘ f: S → Uthen 做的是同一件事,只不过类型上都套了一层:Promise<T>T → U 组合成 Promise<U>。回调还不一定要返回已算好的 U,它可以返回 Promise<U>(例如 fetch 只完成了连接,真正下载正文还要等 response.text()),此时 then 会自动把内外两层 promise 拍平(flatten)。
  • 关键规则与最佳实践
    • then 可以调用任意多次,挂上多个互不影响、各做各事的回调。
    • then 在 promise 任何状态下都可调用:pending 时回调将来才跑;已经 fulfilled 时回调立即运行。
    • then()唯一访问 promise 所计算之值的途径——这既是安全属性(客户端永远不可能看到「缺值」的内部状态),也是并发设计特性:一个并发计算由一串受控、可预测的交错点上的 then() 组成。
    • thenComposethen 链接「需要前一步结果的下一步计算」;如果只是并行独立的任务,不要串成链,那会白白损失并发性。

事件循环与协作式(非抢占)并发(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 对象存进 promiseawait 一个 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) 就像一个客人在柜台前站着不走、不停问「好了没」。他堵住了唯一的服务员,于是厨房的消息永远没人传出来——越等越等不到。
  • 关键规则与最佳实践
    • 只能通过 awaitthen 与 promise 交互;不要试图轮询其状态(promise.isPending()promise.get() 这些假想 API 不存在是有意为之)。
    • 在 Java 中轮询 future.isDone()补充说明)同样会浪费 CPU;应改用 get()get(timeout, unit)thenAccept 风格的回调组合。CompletableFutureorTimeout / 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 线程仍在运行)。
    • awaitthenApply 的语法糖,而不是 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();
    }
}

【错误代码的问题】

  1. 完全丧失并发性:第二个任务的 submit 发生在第一个任务的 get 返回之后,三个计算实际上变成了串行执行,总耗时是三者之和而不是最大值。
  2. 违背了本讲的中心思想:sp22 原文明确指出,totalBalance 之所以并发,正是因为它先把两个 promise 都拿到手,把 await 留到后面;这段代码把顺序写反了。
  3. 语义上「看起来」是并行的:代码里有线程池、有 Future,很容易骗过代码评审——bug 是性能问题而非正确性问题,测试不会红。
  4. 异常会立刻中断整个循环:第一个任务的失败会让后面两个任务根本不会被提交,哪怕它们本来可以成功。

✅ 正确代码

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();
    }
}

【错误代码的问题】

  1. 白白烧掉 CPU:这两秒里处理器被一个什么都不做的循环占满,其他线程/进程被拖慢。
  2. 无法被中断:这个循环不响应中断,也无法设置超时;程序卡住时只能强杀。
  3. 在单线程模型下会彻底死锁:sp22 的 busyWait 例子正是这个后果——busyWait 的循环体里没有 await,因此事件循环永远拿不到控制权,其他所有异步函数都停摆;更糟的是,它等待的事件本身要靠事件循环来处理,所以那个「promise 是否已 fulfilled」的检查永远为假,循环永远出不来。
  4. 掩盖时序 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 直接删掉,是从设计上「让错误写法写不出来」。

【代码对比解说】 两种等待在「谁占着资源」上截然不同:忙等待占着 CPUget() 占着线程(但释放 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;
        }
    }
}

【错误代码的问题】

  1. 异常被静默吞掉:调用者拿到 0 却毫不知情,与「账户真的是 0 元」无法区分——这正是 sp22 所说的「无人处理的 rejection」。
  2. 异常类型信息丢失ExecutionException 只是包装,真正的 cause(文件不存在 / 权限不足 / 网络中断)被丢弃,故障无法定位。
  3. 错误地统一了可恢复与不可恢复的失败InterruptedException(线程被要求停止)与「业务失败」被同样对待,中断信号被吞,线程池的关闭逻辑会失效。
  4. 掩盖了「格式非法」与「读取失败」的区别parseIntNumberFormatException(对应 sp22 getBalance 里的 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 ——失败必须以异常的形式重新出现在等待者面前,而不是变成某个「看起来正常」的返回值。CompletableFutureexceptionally补充说明)则适用于确实有合理默认值的场景。

【代码对比解说】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;
    }
}

【错误代码的问题】

  1. 忙等待 + 冻结执行流:与场景 2 同样的病,且这次连「等的事件由谁触发」都依赖同一个被堵住的线程。
  2. 可见性问题readycontent 都不是 volatile,等待线程可能永远读到旧值(sp21 的 computeAnswer/useAnswer 例子演示了这一点)——甚至可能先看到 ready == true 再看到空的 content(重排序)。
  3. 没有失败通道:下载失败时无法通知等待者,只能永远等待。
  4. 接口把实现细节暴露给所有人:任何人都可以调用 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.promisecheckin 找到它并 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();
    }
}

【错误代码的问题】

  1. 永久死锁:唯一的线程被外层任务占着并阻塞在 inner.get(),而 inner 只有等这条线程空出来才能运行。系统就此冻结。
  2. 这正是 sp22 描述的「事件循环被阻塞」的 Java 版本if the event loop never gets control, because it is blocked, then asynchronous functions won’t be able to make progress either。单线程池 + 阻塞等待 = 单线程事件循环 + 忙等待。
  3. 难以察觉:代码在 newFixedThreadPool(4) 下往往能正常工作,一换成单线程池就挂,属于典型的环境相关 heisenbug。
  4. 没有超时与失败出口:一旦发生,程序既不报错也不退出,只是静静地卡住。

✅ 正确代码

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.allCompletableFuture.allOf
Promise.raceapplyToEither / 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,单向、一次性、不可重置;awaitPromise<T> 变成 T,rejected 时抛异常。
  • await 不是启动计算,而是处理延迟返回;计算在创建 promise 的那一刻就开始了。async/await 只是 then() 与 promise 状态转移的语法糖。
  • 绝不允许忙等待,也不允许窥探 promise 状态:只能通过 awaitthen 与之交互;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、不 completeExceptionallycatch (Exception e) {})→ 等待者永远 pending,或错误被静默吞掉:前者是 liveness 问题(程序卡住),后者是 safety 问题(错误结果被当成正常结果继续传播),而且都无法通过测试发现。
  • 把 promise 的修改器暴露成公开方法,或把 CompletableFuture 直接交给消费者 → 抽象边界失守:任何客户端都能 resolve 别人的 promise,或调用 completecancel 破坏状态机,表示不变量从此不可维护;应只交出 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()」(或 CompletableFutureallOf/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」是有用的?voidundefined 有何区别?并说明 Deferred 在其中的作用。

答案timeout 的 promise 不产生任何有用的值,但它的完成本身就是有用的事件await timeout(2000) 之后我们就知道「2000 毫秒确实已经过去」,可以在那一刻触发后续计算。所以空 promise 完全对应「返回 void 的函数」——它存在的意义是副作用与时序,而不是数据。voidundefined 的区别是类型论层面的: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补充说明)。