2.5 异步编程:Promise 与 async/await
本章解决什么问题:JavaScript 只有一条执行线程,而 Agent 的日常几乎全是「等」——等模型响应、等文件读写、等命令跑完。本章解释单线程程序如何做到「等待时不卡死」:从最原始的回调,到 Promise,再到 async/await 与
Promise.all并发。这是后面所有章节的地基——Pi 源码里几乎每个核心函数都是 async 函数。前置知识:2.2 interface、type 与函数类型(函数类型与
=>;那里已经见过工具的execute返回Promise<...>),2.4 泛型、class 与类型收窄(泛型尖括号、unknown与instanceof)。学习目标:读完本章后你能
- 用事件循环解释「一条线程为什么能同时等很多件事」,并说出没有这套机制程序会怎样;
- 说出 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:
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)**的机制,它是理解一切异步代码的心智模型:
- 你的代码遇到「要等的事」(计时、网络请求、读文件),不亲自等:把事情登记给运行时的后台部分,附上一个回调,然后继续往下跑;
- 运行时后台替你计时、收网络包、读磁盘——这部分不占用 JavaScript 线程;
- 某件事完成时,它的回调被排进一个队列;
- 主线程每跑完一段代码,事件循环就从队列里取出下一个回调来执行,如此往复。
图 2.5-1 事件循环:一条线程如何同时等很多件事
阅读顺序:从左上角的主线程出发,沿箭头绕圈。关键是最上面那条边:遇到异步操作只「登记」就继续跑,等待本身发生在运行时后台,不占用主线程。回头看 blocking.ts 的输出——计时器 100 毫秒后就把回调排进了队列,但主线程被 while 占着,始终没有走到「当前这段代码执行完毕」,事件循环就轮不到它。这就是「JavaScript 不会打断正在运行的代码」的机制原因。
这套机制给所有 JavaScript 程序定下了一条纪律:任何一段代码都要尽快跑完、让出主线程;一切等待都交给后台。后面几节的回调、Promise、async/await,全都是这条纪律的不同写法——写法越来越舒服,机制始终是同一个。
回调:最早的写法,以及它的麻烦
按上面的纪律直接写代码,就是回调风格。存成 callback.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 的一轮工作:请求模型 → 拿到回复后执行工具 → 拿到工具结果后再请求模型。用回调写,每一步都要嵌进上一步的回调里(伪代码示意,省略了真实逻辑):
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:
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——错误终于有了统一通道。
async/await:把异步代码写回同步的样子
.then 链已经比回调舒服,但「后续逻辑都装进回调函数」的本质没变,条件、循环写起来仍然别扭。ES2017 引入的 async/await 让异步代码看起来和同步代码几乎一样。把上面的链改写一遍,存成 async-await.ts(fakeRequest 不变):
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 的机制一点没变。
还有一条语法规则:await 只能写在 async 函数里。普通函数里写 await 是编译错误——「挂起再唤醒」需要编译器把函数改造成状态机,只有标了 async 的函数才会被改造。
忘写 await 是新手最常见的错误,好在 TypeScript 抓得住。把 async-await.ts 里 const first = await fakeRequest(...) 的 await 删掉,再把下一行改成打印 first.toUpperCase()(字符串的转大写方法),存为 src/missing-await.ts,tsc --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 统一等待:
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:
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 工具(把内容写进文件)的真实实现:
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 里的 ?. 是可选链——signal 为 undefined 时整个表达式取 undefined 而不报错;AbortSignal 是取消机制,每次 await 回来都查一下「用户是不是按了取消」,2.7 章专门讲。而 withFileMutationQueue,从源码结构看,是把同一文件上的写操作排成队,防止并发写入互相覆盖——正是「并发虽好,共享资源要排队」的实例。
Promise.all 在 Pi 里同样有教科书式的用例。交互模式启动时要确保两个外部命令行工具(fd 和 rg,缺了会自动下载)可用,两件事互不依赖,于是并发等待:
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(全书实验索引见实践任务索引)。
步骤:
进入实验目录,安装依赖并运行:
shcd labs/typescript-basics/08-promises npm install npm start对照
src/main.ts找出:new Promise和resolve/reject各在哪里;第 1、2 部分代码的差别具体在哪几行——为什么一个 600 毫秒上下、一个 300 毫秒上下;把第 2 部分改成「三行
await fakeRequest(...)再拼数组」,重新运行,观察耗时退回 600 毫秒上下——体会「并发的关键是先发起、后统一等待」,然后改回来;删掉第 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.ts 的 execute(顺序 await);packages/coding-agent/src/modes/interactive/interactive-mode.ts 的 init(Promise.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)的err是unknown,按 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.ts 的 execute(async 工具实现,顺序 await + 取消检查);packages/coding-agent/src/modes/interactive/interactive-mode.ts 的 init(Promise.all 并发准备)
自测问题:
- blocking.ts 里 100 毫秒的计时器为什么 2 秒后才响?用「队列」「主线程」「事件循环」三个词解释。
- Promise 的三种状态是什么?「单行道」指的是哪两条规则?
- 三个各需 300、200、100 毫秒且互不依赖的请求,顺序 await 和
Promise.all各需要约多久?如果第二个请求依赖第一个的结果,还能用Promise.all吗? - async 函数里 await 了一个会失败的 Promise 而没有 try/catch,会发生什么?加上 try/catch 后,
catch (err)里的err是什么类型、该怎么处理?
下一章预告:2.6 异步迭代器与 for await——Promise 解决「等一个未来的值」;但模型的流式输出是「一串陆续到来的值」:字一个个蹦出来,每个都要立刻显示。这需要 Promise 的连续剧版本——异步迭代器,Pi 处理模型响应的核心语法。