Skip to content

2.5 异步编程:Promise 与 async/await

本章解决什么问题:JavaScript 只有一条执行线程,而 Agent 的日常几乎全是「等」——等模型响应、等文件读写、等命令跑完。本章解释单线程程序如何做到「等待时不卡死」:从最原始的回调,到 Promise,再到 async/await 与 Promise.all 并发。这是后面所有章节的地基——Pi 源码里几乎每个核心函数都是 async 函数。

前置知识2.2 interface、type 与函数类型(函数类型与 =>;那里已经见过工具的 execute 返回 Promise<...>),2.4 泛型、class 与类型收窄(泛型尖括号、unknowninstanceof)。

学习目标:读完本章后你能

  • 用事件循环解释「一条线程为什么能同时等很多件事」,并说出没有这套机制程序会怎样;
  • 说出 Promise 的三种状态和转换规则,读懂 .then / .catch 链;
  • 用 async/await 改写异步代码,解释「await 只能出现在 async 函数里」与「async 函数不是多线程」;
  • Promise.all 并发等待多个互不依赖的操作,并解释它与顺序 await 的耗时差别;
  • 用 try/catch 接住 async 函数里的失败,并在 Pi 源码里读懂一个真实的 async 工具实现。

为什么需要异步:只有一条线程,却不能停下来等

先补一个前几章一直没有明说的事实:JavaScript 一次只做一件事。你的代码运行在一条线程(可以理解为一条独立的执行流水线)上,同一时刻只有一段代码在跑,跑完一段才轮到下一段。很多语言靠开多条线程来同时做几件事,JavaScript 没有走这条路——它选了「单线程 + 异步」的组合,这正是本章的主题。

对小脚本来说单线程不算问题。但想想 Pi 的处境:把请求发给模型服务,等响应回来往往要几秒到几十秒。如果这条唯一的线程停在原地干等,整个程序就「死」了——终端界面不再响应按键,动画不再刷新,取消操作的处理逻辑也排不上队。等网络、等磁盘、等计时器,这类「等待远比计算多」的场景正是 Agent 程序的日常。

「线程被占死」是什么感觉,可以用一个小实验直接看到。setTimeout 是运行时(Node.js 和浏览器都有)提供的函数,含义是「过指定毫秒后,调用我给你的函数」。存成 blocking.ts

ts
setTimeout(() => {
  console.log("计时器:100 毫秒到了");
}, 100);

const end = Date.now() + 2000;
while (Date.now() < end) {
  // 空转 2 秒:什么也不做,但把唯一的线程占死了
}
console.log("空转结束,主线程终于空出来了");

两处新语法:() => { ... }箭头函数——2.2 章 (tool) => ... 的写法就是它,相当于匿名函数的简写,这里它作为参数传给 setTimeout。这种「交给别人、将来某个时刻才被调用的函数」叫回调(Callback)Date.now() 返回当前时刻的毫秒数。用 tsx blocking.ts 运行,真实输出:

空转结束,主线程终于空出来了
计时器:100 毫秒到了

注意顺序:明明 100 毫秒就该响的计时器,却排在了 2 秒空转之后。因为线程被 while 循环占着,计时器到点也只能等——JavaScript 不会打断正在运行的代码

事件循环:单线程的调度台

setTimeout 的「到点调用」到底是谁在管?答案是一套叫**事件循环(Event Loop)**的机制,它是理解一切异步代码的心智模型:

  1. 你的代码遇到「要等的事」(计时、网络请求、读文件),不亲自等:把事情登记给运行时的后台部分,附上一个回调,然后继续往下跑;
  2. 运行时后台替你计时、收网络包、读磁盘——这部分不占用 JavaScript 线程;
  3. 某件事完成时,它的回调被排进一个队列
  4. 主线程每跑完一段代码,事件循环就从队列里取出下一个回调来执行,如此往复。
图加载中…

图 2.5-1 事件循环:一条线程如何同时等很多件事
阅读顺序:从左上角的主线程出发,沿箭头绕圈。关键是最上面那条边:遇到异步操作只「登记」就继续跑,等待本身发生在运行时后台,不占用主线程。回头看 blocking.ts 的输出——计时器 100 毫秒后就把回调排进了队列,但主线程被 while 占着,始终没有走到「当前这段代码执行完毕」,事件循环就轮不到它。这就是「JavaScript 不会打断正在运行的代码」的机制原因。

这套机制给所有 JavaScript 程序定下了一条纪律:任何一段代码都要尽快跑完、让出主线程;一切等待都交给后台。后面几节的回调、Promise、async/await,全都是这条纪律的不同写法——写法越来越舒服,机制始终是同一个。

回调:最早的写法,以及它的麻烦

按上面的纪律直接写代码,就是回调风格。存成 callback.ts

ts
console.log("1 发出请求");

setTimeout(() => {
  console.log("3 收到响应(300 毫秒后,由事件循环调用这个回调)");
}, 300);

console.log("2 请求已发出,先去做别的事");

真实输出:

1 发出请求
2 请求已发出,先去做别的事
3 收到响应(300 毫秒后,由事件循环调用这个回调)

输出顺序 1 → 2 → 3,而不是代码的书写顺序:setTimeout 登记完立刻返回,主线程接着打印第 2 行;300 毫秒后事件循环才调用回调打印第 3 行。「不卡死」做到了。

但真实程序很少只等一件事。设想一个 Agent 的一轮工作:请求模型 → 拿到回复后执行工具 → 拿到工具结果后再请求模型。用回调写,每一步都要嵌进上一步的回调里(伪代码示意,省略了真实逻辑):

ts
requestModel(prompt, (reply) => {
  runTool(reply, (result) => {
    requestModel(result, (finalReply) => {
      // 层层缩进,还没算每一层的错误处理……
    });
  });
});

这种「金字塔」有个绰号叫回调地狱。嵌套只是表面问题,更深的麻烦有两个:错误没有统一的通道——每层回调都得单独约定「失败了怎么通知我」,漏一层,错误就无声消失;控制权交了出去——回调会不会被调用、调用几次,全凭对方守信。JavaScript 需要一个更好的抽象,这就是 Promise。

Promise:把「未来才有的值」变成一个对象

它是什么:Promise 是一个对象,代表「现在还没有、将来才会有(或者要不来)的值」。发起异步操作的函数不再收你的回调,而是立刻还你一张「取货凭证」——Promise;你拿着凭证,用统一的方式登记「货到了怎么办、砸了怎么办」。

一个 Promise 一生只有三种状态:

  • pending(等待中):刚创建,结果未定;
  • fulfilled(已成功):有了结果值;
  • rejected(已失败):有了失败原因(通常是个 Error)。
图加载中…

图 2.5-2 Promise 的三种状态与两条单行道
阅读顺序:从左到右。两条转换都是单行道:一旦离开 pending,状态和携带的值就永远定格——不能从成功改回等待,也不能从成功变失败。这个「定格」的性质让 Promise 可以被安全地传递、缓存、反复读取,这是裸回调做不到的。

自己动手造一个 Promise,就能看清三态从哪来。用 setTimeout 模拟一次网络请求,存成 promise-then.ts

ts
function fakeRequest(name: string, ms: number): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(`${name}:响应正文`);
    }, ms);
  });
}

console.log("发出请求");
fakeRequest("模型 API", 300)
  .then((text) => {
    console.log(`收到:${text}`);
    return fakeRequest("工具列表", 100);
  })
  .then((text) => {
    console.log(`又收到:${text}`);
  })
  .catch((err) => {
    console.log("失败了:", err);
  });
console.log("请求已发出,先去做别的事");

真实输出:

发出请求
请求已发出,先去做别的事
收到:模型 API:响应正文
又收到:工具列表:响应正文

逐段拆开:

  • new Promise((resolve, reject) => { ... }):创建一个 pending 的 Promise。你传入的函数会立刻执行,拿到两个「遥控器」:调用 resolve(值) 把状态扳到 fulfilled,调用 reject(原因) 扳到 rejected。这里 300 毫秒后调用了 resolve,用不到 reject 就没写它;
  • Promise<string>:返回类型是泛型——尖括号里是「成功时装的值」的类型,和 2.4 章的 Box<T> 一个道理;
  • .then(回调):登记「成功后做什么」。它返回一个新的 Promise,所以能一路点下去,把「第一步完成后做第二步」写成平铺的链,不再金字塔嵌套。回调里返回另一个 Promise(这里的第二个 fakeRequest)时,链会等它完成再走下一环;
  • .catch(回调):登记「失败后做什么」。链上任何一环失败都会跳到最近的 .catch——错误终于有了统一通道。
📘 概念Promise(Promise)
代表「未来才有的值」的对象,三种状态:pending、fulfilled、rejected,离开 pending 后永远定格。异步函数立刻返回 Promise,调用方用 .then / .catch(或下一节的 await)登记后续处理。它解决回调的两大痛点:链式写法消除嵌套,失败沿链传播、有统一出口。

async/await:把异步代码写回同步的样子

.then 链已经比回调舒服,但「后续逻辑都装进回调函数」的本质没变,条件、循环写起来仍然别扭。ES2017 引入的 async/await 让异步代码看起来和同步代码几乎一样。把上面的链改写一遍,存成 async-await.tsfakeRequest 不变):

ts
async function run(): Promise<void> {
  console.log("发出请求");
  const first = await fakeRequest("模型 API", 300);
  console.log(`收到:${first}`);
  const second = await fakeRequest("工具列表", 100);
  console.log(`又收到:${second}`);
}

run();
console.log("run 已经调用,先去做别的事");

真实输出:

发出请求
run 已经调用,先去做别的事
收到:模型 API:响应正文
又收到:工具列表:响应正文

新语法只有两个词:

  • async:把函数标记为异步函数。async 函数的返回值一定是 Promise——return 的值会被自动包进去;没有有意义的返回值时写 Promise<void>void 表示「无返回值」);
  • await 某个Promise:暂停这个函数,等 Promise 出结果:fulfilled 就把成功值当作表达式的值继续往下走,rejected 就在这里抛出错误(下一节接住它)。

盯着输出顺序看,能发现 await 的关键性质:run() 被调用后先同步打印了「发出请求」,跑到第一个 await 就把函数挂起、立刻把控制权还给调用方——所以「run 已经调用」抢在了「收到」前面。挂起的不是整个程序,只是 run 函数剩下的部分;主线程照常干活,等 Promise 定格后,事件循环再把 run 从暂停点唤醒。await 是「登记回调」的语法糖,不是「站住不动」——图 2.5-1 的机制一点没变。

⚠️ 常见误解以为 async 函数在别的线程上运行
async 不会让代码变成多线程。async 函数的每一行都在主线程上执行,await 只是「挂起自己、让出主线程」的标记。因此把大计算写进 async 函数照样卡死一切——blocking.ts 里那个 while 空转,套上 async 也一样堵住整个程序。async 解决的是「等待时不占线程」,不是「计算时多用几条线程」。

还有一条语法规则:await 只能写在 async 函数里。普通函数里写 await 是编译错误——「挂起再唤醒」需要编译器把函数改造成状态机,只有标了 async 的函数才会被改造。

🌱 初学者提示顶层 await
有一个例外:在 ES 模块的最外层(不在任何函数里)允许直接写 await,称为顶层 await(Top-level await),本书实验的 main.ts 偶尔会用到。但「函数里用 await,函数必须 async」这条规则没有例外。

忘写 await 是新手最常见的错误,好在 TypeScript 抓得住。把 async-await.ts 里 const first = await fakeRequest(...)await 删掉,再把下一行改成打印 first.toUpperCase()(字符串的转大写方法),存为 src/missing-await.tstsc --noEmit 的真实报错:

src/missing-await.ts(12,27): error TS2339: Property 'toUpperCase' does not exist on type 'Promise<string>'.

没有 await,拿到的是 Promise 这个「凭证」本身而不是里面的字符串——类型对不上,编译器直接拦下。这也是 2.1 章「类型是文档」的又一次兑现:函数签名里的 Promise<string> 时刻提醒你「这是个未来值,取用前先 await」。

并发等待:Promise.all

顺序 await 有一个隐藏代价。三个互不依赖的请求分别要 300、200、100 毫秒,逐个 await 就要等约 600 毫秒——后一个必须等前一个完成才开始。但这三件事本可以同时等:先把三个函数都调用起来(每个立刻返回 pending 的 Promise,三个计时同时开始走),再用 Promise.all 统一等待:

ts
const results = await Promise.all([
  fakeRequest("请求模型 API", 300),
  fakeRequest("读取配置文件", 200),
  fakeRequest("查询可用工具", 100),
]);

Promise.all(数组) 返回一个新 Promise:数组里全部成功,它才成功,值是按传入顺序排列的结果数组(谁先完成不影响顺序);任何一个失败,它立刻失败。总耗时由最慢的那个决定——本章实验里实测:顺序 await 三次共 603 毫秒,Promise.all 并发只要 301 毫秒(数字每次运行略有浮动)。

这就是并发(Concurrency):不是多线程同时计算,而是「同时处于等待中」——三个计时器都在运行时后台走表,主线程谁也不用陪。对 Agent 这类等待密集的程序,识别出「哪些事互不依赖、可以一起等」是常见的优化手段。

失败与 try/catch

.then 链用 .catch 收错误;async 函数里更自然——await 处的失败以抛异常的形式出现,用普通的 try/catch 就能接住。存成 try-catch.ts

ts
function fakeFailingRequest(name: string, ms: number): Promise<string> {
  return new Promise((_resolve, reject) => {
    setTimeout(() => {
      reject(new Error(`${name}:请求超时`));
    }, ms);
  });
}

async function main(): Promise<void> {
  try {
    const text = await fakeFailingRequest("坏掉的接口", 100);
    console.log(`收到:${text}`); // 永远走不到这一行
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    console.log(`捕获到失败:${message}`);
  }
  console.log("程序没有崩溃,继续往下走");
}

main();

真实输出:

捕获到失败:坏掉的接口:请求超时
程序没有崩溃,继续往下走

注意 catch (err) 里那行代码——这是 2.4 章的知识在上岗:strict 模式下 err 的类型是 unknown,先用 instanceof Error 收窄再取 .message,兜底转成字符串。这正是 Pi 的 toError 干的事。

反过来,如果一个 rejected 的 Promise 没有任何人接住(没有 .catch,await 处也没有 try/catch),Node.js 会在报告「未处理的 Promise 失败」后以非零退出码结束进程。所以规矩很朴素:每条异步链的尽头,要么有人 await 并 try/catch,要么挂着 .catch

Pi 中哪里用到了它

2.2 章看 AgentTool 接口时,execute 的返回类型 Promise<...> 曾被「按下不表」,现在可以正面回答了:Pi 的每个工具的 execute 都是 async 函数——读文件、写文件、跑命令都是异步操作,Agent 在等结果时,界面还要继续渲染、还要能响应取消。看 write 工具(把内容写进文件)的真实实现:

earendil-works/pi@c13ffe1第 194–226 行在 GitHub 查看 ↗
write 工具的 execute:一个真实的 async 函数,顺序 await 两步文件操作,每步之后检查是否被取消。
ts
async execute(
	_toolCallId,
	{ path, content }: { path: string; content: string },
	signal?: AbortSignal,
	// …(省略:另外两个此处用不到的参数)
) {
	const absolutePath = resolveToCwd(path, cwd);
	const dir = dirname(absolutePath);
	return withFileMutationQueue(absolutePath, async () => {
		// …(省略:解释取消时机的注释)
		const throwIfAborted = (): void => {
			if (signal?.aborted) throw new Error("Operation aborted");
		};

		throwIfAborted();
		// Create parent directories if needed.
		await ops.mkdir(dir);
		throwIfAborted();

		// Write the file contents.
		await ops.writeFile(absolutePath, content);
		throwIfAborted();
		// …(省略:把成功信息包装成工具结果并返回)
	});
},

本章的知识足够读懂它的骨架:这是个 async 函数,先 await ops.mkdir(dir) 建父目录,再 await ops.writeFile(...) 写内容——两步有依赖(目录不存在就没法写文件),所以必须顺序 await,正对应本章「顺序」的那一侧。几处面孔:参数里的 { path, content }: {...}解构——直接把对象参数的两个字段取出来命名;ops 是一组文件操作函数(默认封装 Node.js 的文件 API,2.8 章介绍);signal?.aborted 里的 ?.可选链——signalundefined 时整个表达式取 undefined 而不报错;AbortSignal 是取消机制,每次 await 回来都查一下「用户是不是按了取消」,2.7 章专门讲。而 withFileMutationQueue,从源码结构看,是把同一文件上的写操作排成队,防止并发写入互相覆盖——正是「并发虽好,共享资源要排队」的实例。

Promise.all 在 Pi 里同样有教科书式的用例。交互模式启动时要确保两个外部命令行工具(fdrg,缺了会自动下载)可用,两件事互不依赖,于是并发等待:

earendil-works/pi@c13ffe1第 705–716 行在 GitHub 查看 ↗
交互模式的 init:用 Promise.all 并发确保 fd 与 rg 两个工具就绪,缩短启动等待。
ts
async init(): Promise<void> {
	if (this.isInitialized) return;
	// …(省略:注册信号处理、加载更新日志)
	// Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
	// Both are needed: fd for autocomplete, rg for grep tool and bash commands
	const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
	this.fdPath = fdPath;
	// …(省略:后续初始化)

const [fdPath] = ... 也是解构,取结果数组的第一项。两次 ensureTool 最坏各要下载一次文件,顺序等就是两段下载时间相加,并发等只花较慢的那段——和你在实验里将要亲手测出的差距一模一样。至于「execute 被谁 await」——Agent Loop 在收到模型的工具调用请求后 await 它,拿到结果再发回模型,这条完整链路是 5.3 一次 Tool Call 的完整循环的主角。

实践任务

🛠 实践任务亲手测量顺序与并发的耗时差labs/typescript-basics/08-promises

目标:用 setTimeout 模拟三次网络请求,亲手测出「顺序 await」与「Promise.all 并发」的真实耗时差,并观察 async 函数里 try/catch 接住失败的全过程。实验目录:labs/typescript-basics/08-promises(全书实验索引见实践任务索引)。

步骤

  1. 进入实验目录,安装依赖并运行:

    sh
    cd labs/typescript-basics/08-promises
    npm install
    npm start
  2. 对照 src/main.ts 找出:new Promiseresolve / reject 各在哪里;第 1、2 部分代码的差别具体在哪几行——为什么一个 600 毫秒上下、一个 300 毫秒上下;

  3. 把第 2 部分改成「三行 await fakeRequest(...) 再拼数组」,重新运行,观察耗时退回 600 毫秒上下——体会「并发的关键是先发起、后统一等待」,然后改回来;

  4. 删掉第 3 部分的 try/catch(直接 await fakeFailingRequest(...))运行一次,观察「未处理的 Promise 失败」如何让程序报错退出、后面的结论行不再打印,然后改回来。

预期现象npm start 输出与实验目录下 expected-output.txt 一致:

== 第 1 部分:顺序 await ==
请求模型 API:完成,模拟延迟 300ms
读取配置文件:完成,模拟延迟 200ms
查询可用工具:完成,模拟延迟 100ms
顺序总耗时:603ms(约等于 300 + 200 + 100)

== 第 2 部分:Promise.all 并发 ==
请求模型 API:完成,模拟延迟 300ms
读取配置文件:完成,模拟延迟 200ms
查询可用工具:完成,模拟延迟 100ms
并发总耗时:301ms(约等于最慢的那个:300)

== 第 3 部分:用 try/catch 接住失败 ==
catch 捕获到:坏掉的接口:请求超时
程序没有崩溃,还能继续往下走

结论:同样三次模拟请求,顺序 603ms,并发 301ms
总耗时的具体数字每次运行都会略有不同,这是正常现象

两处「总耗时」的毫秒数每次运行都会略有浮动(setTimeout 保证「至少等这么久」,不保证精确),不必逐字一致;其余各行应当完全相同。

如何判断成功:顺序耗时在 600 毫秒上下、并发耗时在 300 毫秒上下且明显更短;第 3 步改动后并发优势消失;第 4 步看到了真实的「未处理失败」报错。

常见错误

  • command not found: tsx:没有先在实验目录里 npm install
  • Promise.all 数组里的每个调用加上 await:这会先顺序等完三个请求,Promise.all 收到的已经是现成的值,并发就消失了——正是第 3 步要观察的现象;
  • 以为耗时必须精确等于 600 / 300 毫秒:计时器和事件循环都有毫秒级开销,略大于理论值才是正常的。

对应源码位置packages/coding-agent/src/core/tools/write.tsexecute(顺序 await);packages/coding-agent/src/modes/interactive/interactive-mode.tsinitPromise.all 并发)。

本章小结

  • JavaScript 单线程;事件循环让「等待」发生在运行时后台:遇到异步操作只登记回调、立刻继续,事情完成后回调进队列、排队上主线程。纪律是任何代码都尽快让出主线程。
  • 回调能用但难扩展:嵌套成金字塔、错误没有统一通道。Promise 把「未来的值」变成对象:pending → fulfilled / rejected,单行道、定格后不变;.then 链平铺步骤,.catch 统一收错。
  • async/await 是 Promise 之上的语法糖:await 挂起当前函数、让出主线程,不是站住不动,更不是多线程;await 只能出现在 async 函数里(ES 模块顶层除外);忘写 await 会被类型系统当场抓住。
  • 互不依赖的等待用 Promise.all 并发:先全部发起、再统一等待,总耗时由最慢者决定(实测 603ms 对 301ms)。
  • await 处的失败以异常形式出现,用 try/catch 接住;catch (err)errunknown,按 2.4 章的流程收窄。没人接住的失败会让 Node.js 报错退出。

关键术语:线程、事件循环(Event Loop)、回调(Callback)、Promise、pending / fulfilled / rejected、resolve / reject、async 函数、await、顶层 await、并发(Concurrency)、Promise.all、未处理的 Promise 失败

关键源码索引packages/coding-agent/src/core/tools/write.tsexecute(async 工具实现,顺序 await + 取消检查);packages/coding-agent/src/modes/interactive/interactive-mode.tsinitPromise.all 并发准备)

自测问题

  1. blocking.ts 里 100 毫秒的计时器为什么 2 秒后才响?用「队列」「主线程」「事件循环」三个词解释。
  2. Promise 的三种状态是什么?「单行道」指的是哪两条规则?
  3. 三个各需 300、200、100 毫秒且互不依赖的请求,顺序 await 和 Promise.all 各需要约多久?如果第二个请求依赖第一个的结果,还能用 Promise.all 吗?
  4. async 函数里 await 了一个会失败的 Promise 而没有 try/catch,会发生什么?加上 try/catch 后,catch (err) 里的 err 是什么类型、该怎么处理?

下一章预告2.6 异步迭代器与 for await——Promise 解决「等一个未来的值」;但模型的流式输出是「一串陆续到来的值」:字一个个蹦出来,每个都要立刻显示。这需要 Promise 的连续剧版本——异步迭代器,Pi 处理模型响应的核心语法。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)