成为全栈·基础补充·Node.js 运行时:事件循环与异步
这个项目里有一段代码,它的外形是 async,但内部调用的是一个同步函数:
/** 同一批 SQL 在 SQLite 事务或 D1 batch 中执行;异常整体回滚。 */
export async function atomic(db: Db, statements: Statement[]): Promise<number[]> {
const client = (db as unknown as { $client: Database.Database | D1Client }).$client;
if ('batch' in client) {
const results = await client.batch(
statements.map((s) => client.prepare(s.sql).bind(…s.params)),
);
return results.map((r) => r.meta.changes);
}
return client.transaction(() =>
statements.map((s) => client.prepare(s.sql).run(…s.params).changes),
)();
}
一个 async 函数,却在本地路径上完全同步执行。

这在 TypeScript 里合法(async 函数返回 Promise,里面可以是同步逻辑),但它立刻带来一个问题:调用方能假设它是异步的吗?
答案是不能。而这个项目里有一整类代码,专门处理"我不确定那个 Promise 会不会已经结束"。
这篇讲这个项目的 Node 运行时经验,以及为什么 Flutter 那边的每一处异步都要记身份(M4-09 那些 epoch 检查,其实根源在这里)。
单线程意味着:await 之间的代码会被插队
先把最关键的前提说清楚,因为它解释了后面所有的复杂度。
Node 的 JavaScript 执行是单线程的。 而单线程不代表"顺序执行到底"——它代表的是:
一次只执行一段代码,但会在 await 这些让出点主动把控制权交出去。

而"让出点"具体是哪些,取决于具体是什么操作:
| 读取文件(fs.readFile) | 会,文件在磁盘上,得等 I/O 完成 |
| 网络请求(fetch) | 会,要等对端响应 |
| 数据库查询 | 会,数据可能在远端 |
| 纯计算(排序、大循环) | 不会,一直占着 |
| 定时器(setTimeout) | 会等,但等的是时间 |
而"让出"期间,同一个线程会去处理别的事——包括另一个请求的代码。
这就是事件循环的核心,也是所有异步 bug 的根源。
一个必须记住的后果:await 之后的世界可能变了
用一个真实场景说明。看这个项目的刷新逻辑(简化):
Future<void> refresh() {
if (_refresh != null) return _refresh!;
final start = epoch;
final task = () async {
final token = await vault.read(); // ← 第一次让出
if (start != epoch) throw SessionChanged(); // ← 检查世界有没有变
…
final data = await request(Endpoints.refresh, …); // ← 第二次让出
await install(data, expectedEpoch: start); // ← 第三次让出
}();
三处 await,两处检查。
而检查的内容是 start != epoch——“我发起这次操作时,那个会话还在吗?”
为什么必须检查?因为在每个 await 处,用户完全可能已经做了别的事:
时刻 T1:refresh() 开始,记下 epoch = 3
时刻 T1+ε:await vault.read() 让出
↓ 此时用户点了"退出登录"
↓ clear() 执行,epoch 变成 4
时刻 T2:await 返回,继续执行
如果不检查 → 用 epoch 3 的上下文完成刷新
结果 → token 被写回一个已经登出的会话
这就是 Flutter 那边的 epoch。而它在 Node 这边同样存在——M1-32 那个微信登录就是同一套:atomic 里的 SessionChanged 异常处理的就是这个。
所以这一篇的核心结论是:
在单线程事件循环里,await 不只是"等一会儿",它是一个"世界可能已经变了"的边界。
而这个判断标准是通用的,不限于异步 I/O:
| 用户切换了账号 / 登出 | 要 |
| 用户关闭了页面(Flutter Widget dispose) | 要 |
| 页面从后台切到前台 | 可能要 |
| 单纯的定时器到期 | 不需要 |
为什么这个项目里的异步都要"记身份"
现在讲这个项目里反复出现的那个模式。
M4-12 的列表页:
final ticket = ++serial;
setState(() { busy = true; error = null; });
…
final p = await repo.track(dependencies, () => repo.articles(…));
if (!mounted || ticket != serial) return; // ← 记了号,回来核对
M4-10 的 AsyncPane:
final ticket = ++serial;
…
if (mounted && ticket == serial) {
setState(() { value = result; error = null; keys = nextKeys; });
}
M4-15 的 WebView 标题轮询:
final url = currentUrl;
final next = (await controller!.getTitle())?.trim();
if (mounted && url == currentUrl && next != title) { // ← 记了 URL,回来核对
setState(() => title = next?.isNotEmpty == true ? next : null);
}
M4-09 的 epoch 检查。
四处看起来毫不相干的代码,用的是同一个模式:发起时记下身份,回来时核对,不匹配就丢弃。
而 Flutter 那边还有两个条件:
| mounted | Widget 已经销毁 → setState 会抛异常 |
| ticket == serial | 有更新的请求已发出 → 旧结果不该覆盖 |
mounted 那个是 Flutter 特有的,因为 Widget 生命周期是框架管理的。而 ticket/epoch/url 那一层是通用的。
所以如果只看这个模式,你会以为这四处的代码是四个不同的技巧。实际上它们是同一句话:
异步操作的结果,只对发起它的那个世界有效。
单飞:一个特例
而这个模式有一个特殊形态,M4-09 讲过,叫"单飞":
Future<void> refresh() {
if (_refresh != null) return _refresh!; // ← 不记新身份,而是复用已有的
…
}
为什么刷新要复用而不是各刷各的?
因为它是切换语义的操作(第一次刷成功,第二次就会因 token 已轮换而失败)。而五个并发 401 会触发五次刷新,而轮换过的 token 用一次就作废。
所以这里的处理不是"记身份然后丢弃旧的",而是"后来的直接用前一个的结果"。
| 列表加载 | 记 ticket,旧结果丢弃 | 多个请求都合法,只要最新的 |
| 标题轮询 | 记 url,不匹配丢弃 | 同上 |
| 令牌刷新 | 复用 _refresh | 重复执行会失败,必须共享 |
这个区别很重要:前者是"多个都可以,只要一个结果",后者是"多个会互相破坏"。
而判断标准是:重复执行这个操作,是否安全?
- 读操作 → 安全(记身份即可)
- 切换语义的操作 → 不安全(必须共享)
- 创建操作 → 不安全(会重复创建,M4-20 讲的那个)

并行不是并行:async 只是"不阻塞"
现在讲一个常见误解。
const [a, b, c] = await Promise.all([
fetchA(), fetchB(), fetchC(),
]);
看起来是三个并行,实际是:三个都发起 → 等第一个完成 → 等第二个 → 等第三个。
而"三个都发起"才是并行的部分——它们几乎同时把请求送出去了。而 Node 在这个过程中不阻塞:
- 发起 fetchA → 让出,去处理别的连接
- 发起 fetchB → 让出
- 发起 fetchC → 让出
- 三者的响应陆续到达,回调进事件队列
所以它是"并发 I/O",不是"并行计算"。
| 场景 | 多个网络请求 | CPU 密集计算 |
| Node 能力 | ✅ 单线程足够 | ❌ 需要 worker_threads |
| 瓶颈 | 网络延迟 | CPU 核数 |
而这个项目里那个"登录接口很慢"的问题,正属于第二类——B-06 讲过,慢在 bcrypt 的 12 轮哈希,那是纯 CPU 计算。
它不会因为改成 async 而变快。 因为 async 只解决"等待"问题,不解决"计算"问题。
怎么判断自己遇到了哪一种
一个简单的区分方法:把输入换成常量再测一次。
- 如果慢在数据量 → 大概率是 I/O 或查询
- 如果慢在计算 → 常量输入也会慢
这个项目里 bcrypt 就是后者——传 1 个字符和传 100 个字符一样慢,因为轮数固定。
同步代码会阻塞:最容易犯的错
回到开头那个 atomic:
return client.transaction(() =>
statements.map((s) => client.prepare(s.sql).run(…s.params).changes),
)();
这一段是同步的——transaction() 立即执行完,返回数组,函数结束。
而调用方看到的是一个 async 函数,所以它 await 了一次:
const changed = await atomic(db, […]);
而这个 await 在本地路径上实际上没有让出——因为 Promise 已经 resolve 了。
所以这段代码在本地和线上的行为是"看起来一致"的:调用方都 await,都拿到 changes[]。
而这正是它的设计目的(M1-33 讲过):业务代码不用关心当前是哪种运行时。
但要注意一个副作用:在本地路径上,这个 await 让出一个微任务。如果这段代码在一个循环里调一万次,就有一万个微任务。 这在实测中不构成问题(因为这类批量操作本身就很重),但它说明"加了 async 不等于零成本"。
更一般的教训是:把同步 API 声明成 async,调用方就会被迫 await。 而 await 一个已 resolve 的 Promise 会让出到微任务队列,所以在热路径上这是有成本的。
正确做法是分成两个函数——同步版和异步版,让调用方自己选。这个项目没做,是因为这个调用点不在热路径上。但它是一个真实的取舍,不是无成本的优雅。
错误处理属于异步流程的一部分
最后讲这块,因为它和同步代码的习惯不同。
同步代码:
const user = getUser(id); // 抛错就抛错
use(user);
而异步代码里,throw 变成"reject 一个 Promise",而没人 await 的话,它就变成一个 unhandled rejection。
这个项目里有个真实的例子:
unawaited(_fetch(key, policy, fetch, tags, forbidden)
.catchError((Object _) => null));
unawaited() + .catchError() ——两个都是刻意的。
- unawaited():告诉 linter “我知道我没 await,这是故意的”
- .catchError():吞掉后台任务的错误
而这两者缺一不可:只写 unawaited 会产生 unhandled rejection(Node 会打印警告,某些配置下会直接崩溃);只写 catchError 而不用 unawaited,linter 会报"未处理的 Promise"。
这个写法把"我知道这里有个 Promise,我没等它"这件事显式化了——而不是让它看起来像忘了 await。
而真正需要 await 的地方,错误就正常往上抛,由顶层的错误处理收口:
export const errorHandler: ErrorHandler = (err, _c) => {
if (err instanceof AppError) {
return failResponse(err.code, err.httpStatus, err.details);
}
// 兜底:未知异常不应向客户端泄露堆栈
console.error('[unhandled]', err);
return failResponse(ErrCode.INTERNAL, 500);
};
注意那个 [unhandled] 前缀——它和那些被 .catchError 吞掉的错误形成对照。能到这里的都是"没人处理"的问题,所以要打日志。
一条我一开始想省掉的事
那个"记身份、回来核对"的模式,我最初的实现是"只检查 mounted"。
理由是"Flutter 里 Widget 销毁了就不该 setState,够了"。
结果是一个真实的问题:用户在搜索页快速输入,两次请求几乎同时发出,第一个请求先回来。
那时候 mounted 是 true(页面还在),所以第一个结果直接渲染了。然后第二个结果回来,覆盖掉它。
用户看到的是:输入"a",看到的结果对应"ab"。
而这个 bug 极其难发现,因为:单次搜索完全正常、结果通常"差不多对"、而两次输入之间的间隔通常只有几百毫秒。
加上 serial 之后,这类问题才消失。 而它的成本只有两行。
而这件事和 Node 那边的教训是同一个:单线程不代表逻辑不会交错——await 处就是交错的边界。
顺带说 M4-10 那个 loadKey 机制(防"同一个 Widget 被复用但参数变了")也是同一族的问题。它的表现更隐蔽:文章页显示的是 A 作者的文章,标题却是 B 的——因为组件复用了,而基线值没重置。
三个问题三个现象,一个根因:异步结果被用在了它不再成立的上下文里。
小结
Node 运行时在这个项目里沉淀下来的判断:
而第 1 条是这个专栏贯穿多个系列的模式——M4-09 的 epoch、M4-12 的 serial、M4-10 的 ticket、M4-15 的 url 比较,四个地方都是同一句话的不同措辞:
异步结果只对发起它的那个世界有效。
而 Flutter 那边的 mounted 检查是 Node 这边没有的,因为框架会销毁 Widget,而 Node 的请求没有"被销毁"这个概念——它的"上下文消失"表现为用户登出,而那要靠 epoch 这样的业务标记才能知道。
这说明同一个模式在不同环境里需要不同的"身份标记",而标记的来源是环境特有的。
下一篇讲服务端的心智模型——把一个请求从浏览器到数据库的完整链路画出来,标出每一层可能失败的地方。
延伸阅读
- 后端工程从零搭建:TypeScript、目录与热更新
- 点赞系统:幂等点赞与计数原子增减
- 结构化日志与请求链路追踪
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

网硕互联帮助中心







评论前必须登录!
注册