js-async/await
JavaScript 单线程模型与 async/await 执行顺序
从"为什么单线程"到"为什么 await 要配 async",再到"微任务到底怎么排队"的一次完整梳理。
目录
- 一、为什么 JavaScript 是单线程的
- 二、单线程对性能的真实影响
- 三、为什么 await 必须写在 async 函数里、并且返回 Promise
- 四、为什么主线程"一定"不能阻塞
- 五、闭包 + 共享变量的时序竞态
- 六、JS 执行顺序的完整模型
- 七、速查表与实践原则
一、为什么 JavaScript 是单线程的
根本原因:DOM
JavaScript 在 1995 年被设计成浏览器里的"胶水语言",核心用途是操作 DOM。如果多线程同时修改同一棵 DOM 树,就必须引入:
- 锁与死锁处理
- 内存模型与可见性规则
- 重入(reentrancy)保护
这对一门面向非专业开发者的脚本语言来说是不可接受的复杂度。因此规范直接规定:只有一个执行线程可以碰 DOM。
Brendan Eich 后来的说法大意是:如果当年是在多线程上做 DOM,JS 大概会变成另一门语言,而且会更糟。
辅助原因
| 原因 | 说明 |
|---|---|
| 简单 | 没有共享内存 → 没有 data race、没有锁、没有内存可见性问题 |
| UI 框架的共识 | Win32 / Swing / Qt 最终都收敛成"UI 只能主线程碰",JS 只是提前定死了 |
| 时代背景 | 1995 年单核是常态,多线程收益有限 |
关键澄清:单线程 ≠ 整个运行时单线程
"单线程"限制的只是你写的 JS 代码同一时刻只有一处执行,宿主环境本身是多线程的:
| 部分 | 线程 |
|---|---|
| JS 主线程(执行栈) | 1 个 |
| 渲染(合成、栅格化、布局) | 浏览器另起线程 |
| 网络 / DNS / 文件 I/O | 浏览器进程或 libuv 线程池 |
| GC | 并发/并行标记(V8 的 Orinoco) |
| Worker / worker_threads | 由你显式创建 |
所以 await 等 I/O 时,主线程是空闲的,不是"在等"。
二、单线程对性能的真实影响
吃亏的地方
1. 一个阻塞操作拖垮一切
CPU 密集计算(大数组排序、复杂正则回溯、同步解析巨型 JSON、死循环)会卡住事件循环 → 掉帧、点击无响应、服务端所有并发请求一起变慢。
2. 无法利用多核
多核时代这是硬伤,只能靠 Worker 手动拆分。
3. 线程间通信成本高
没有共享内存(SharedArrayBuffer + Atomics 是后补的,且需要 COOP/COEP 响应头),主线程与 Worker 之间只能结构化克隆或转移,大对象复制很贵。
4. 尾延迟差
服务端一个 200ms 的同步操作,会让这一瞬间所有排队请求都多等 200ms。
反而更快的地方
1. 零锁开销
没有互斥量竞争、没有伪共享(false sharing)、没有线程唤醒的调度成本。高频小粒度任务下,多线程版本的吞吐可能还不如单线程。
2. 并发单元极便宜
回调 / Promise 的创建成本约等于一次堆分配。对比线程创建(几十微秒)+ 栈内存(默认 1–8MB)。Node 靠这一点单线程扛上万并发连接——每个连接只是一个挂在堆上的闭包(几 KB)。
3. 可预测
没有"偶发死锁"和"只在生产环境出现的竞态"。
4. V8 可以更激进地优化
隐藏类(hidden class)、内联缓存、逃逸分析做得非常精密,部分原因是它只需要考虑单线程语义。
工程解法
| 场景 | 方案 |
|---|---|
| CPU 密集 | Web Worker / Node worker_threads / child_process / cluster |
| 真正共享内存并行 | SharedArrayBuffer + Atomics,或 WebAssembly 多线程 |
| 把重活外包 | 拆成微服务,Go/Rust/Java 做计算密集部分,Node 只做 I/O 编排 |
| 切分任务 | requestIdleCallback / scheduler.postTask / 分片让出主线程 |
结论
单线程是为 DOM 和简单性做的取舍:代价是"你不许阻塞",收益是"没有锁 + 并发单元极廉价"。
>
真正的规则不是"JS 慢",而是"JS 不允许阻塞"。CPU 密集的活交给 Worker / WebAssembly / 后端,主线程只做 I/O 编排。
三、为什么 await 必须写在 async 函数里、并且返回 Promise
一句话
因为 await 意味着这个函数可能中途让出控制权、稍后恢复,而 JS 的调用方不能阻塞等待——所以被调函数必须立刻交回一个"凭证"(Promise)。既然返回值类型变了,它就必须写进签名里。
层面 1:运行时——挂起需要"容器",容器必须先返回
await 不是普通运算,它要冻结当前函数的执行栈。引擎把 async 函数编译成一个状态机(类似 generator),在 await 处保存现场、注册回调、返回。
async function f() {
const a = await g();
return a + 1;
}
大致等价于:
function f() {
return g().then(a => a + 1);
}
区别在于 await 用状态机而非嵌套 then 实现,所以不会深层嵌套,且 try/catch 能跨越 await 生效。
关键点:函数在 await 处就已经返回了,return a + 1 发生在未来的某个微任务里。那第一次返回的是什么?只能是一个占位符 —— Promise。
层面 2:语言——返回值类型是签名的一部分
假设允许 function f() { await g(); } 而没有 async:
const x = f(); // x 是同步返回值,还是 Promise?
- 如果"没遇到 await 就同步返回"——你有 50% 概率忘记处理 Promise,bug 随机出现。这就是臭名昭著的 Zalgo:函数有时同步、有时异步。
- 如果"总是异步"——返回值类型和普通函数不同,必须对调用方可见。
签名就是契约。让调用方靠读函数体判断是不是 Promise 是不可接受的:函数体是私有实现,改一行就可能改变整个 API 的语义,波及所有调用点。
async 保证一件事:永远返回 Promise,永远异步,绝不会"有时同步"。
层面 3:语法——await 是上下文敏感的
这是 async 必须显式、不能从函数体推断的真正原因:
// ES2017 之前,await 完全可以用作变量名
var await = 1;
function f() { return await; } // 合法!
await 不是保留字,它只在 async 函数内部被当作运算符。如果改成"函数体里有 await 就自动成为 async":
1. 歧义:await(x) 是"await 表达式"还是"调用名为 await 的函数"?无法解析。
2. 破坏兼容:所有现存用 await 做标识符的代码全部失效。
3. 脆弱:在函数体里加/删一行 await,会静默改变整个函数的调用契约。
显式 async 把作用域切干净了:只有 async 函数里,await 才是运算符,其他地方它仍是普通标识符。
附带的两个语义
async function f() { return 1; }
f() instanceof Promise // true —— return 值被自动包了一层
async function g() { throw new Error('x'); }
g() // 不抛异常,返回 rejected promise
因为调用方拿到的永远是 Promise,错误也只能通过 Promise 通道传出——所以 throw 自动变成 reject,同步 try/catch 抓不住它,必须 await 或 .catch()。
对比:为什么 Go 不需要这个关键字
| | 调用方能否阻塞 | 是否需要 async 标记 |
|---|---|---|
| Go | 能。goroutine 阻塞只是挂起自己,调度器换一个跑 | 不需要,time.Sleep() 直接写 |
| Rust (tokio) | 需要 .await,但 async fn 同样必须显式标注 | 需要 |
| C#(async/await 的源头) | UI 线程不能阻塞 → 必须返回 Task | 需要 |
| JS | 主线程绝对不能阻塞 | 需要,且强制 |
只要"被调方不敢阻塞调用方",返回值就得变成凭证,那这个凭证就必须写进签名。
Go 敢阻塞,是因为运行时替它换了栈;JS 不敢,因为栈就是主线程。
async/await 的设计借自 C# / F#,TC39 提案由微软的 Luke Hoban 主导,2017 年进入 ES2017。"传染性"(async 调 async,一路向上)是刻意保留的——让"这是异步的"在类型上无法被忽略。
四、为什么主线程"一定"不能阻塞
先纠正措辞:不是"物理上不能"——你写 while(true){} 没人拦得住,浏览器只会弹"页面无响应"。
准确说法是:主线程一旦阻塞,没有任何机制能把它救回来。
原因 1:主线程上没有"别人"
在主线程上,"你写的 JS"和"浏览器要派发给你的事件"是同一个队列、同一个执行栈:
主线程
├─ 执行栈 ← 你的函数在这里跑
└─ 任务队列 ← 点击事件、定时器、Promise 回调、rAF、布局都排在这
只要有东西在栈上跑,队列就不流动。阻塞主线程丢掉的不是"性能",而是这期间所有的输入事件、定时器、Promise 续体、动画帧——它们不是被"延迟",而是没机会进入执行栈。
对比 Go:goroutine 阻塞时调度器切到别的 goroutine,机器照转。JS 主线程阻塞时没有"别的 goroutine",因为属于你页面逻辑的线程就这一个。
原因 2(更深):JS 的执行模型没有抢占
操作系统调度线程是抢占式的——时间片一到,强制打断,保存现场,换人(Go 1.14+ 也给 goroutine 加了异步抢占)。所以线程敢阻塞,因为系统随时能把它踹下来。
而 JS 是协作式的:一个任务一旦开始,必须自己跑完、自己交还控制权(return / 让栈清空),事件循环才拿得回控制权。引擎没有任何机制能中断一个正在运行的函数。
while (true) { /* 引擎:我插不进手 */ }
于是硬约束是推论出来的:既然无法被中断,就不能长时间不让出。
浏览器只能做两件野蛮的事:弹"无响应"对话框(让你杀),或把渲染进程整个换掉——那是处决,不是让出。
原因 3:阻塞时具体丢什么
| 丢失的能力 | 后果 | 阈值 |
|---|---|---|
| rAF 回调 → 渲染管线 | 掉帧、动画卡死 | 16.7ms / 帧 |
| 输入事件派发 | 点击/输入无反应 | ~100ms 内无响应即"卡" |
| 布局计算 | 页面停在旧样子 | — |
| 定时器 / Promise / microtask | 异步流程全部停在半路 | — |
| Long Task 标记 | 超过 50ms 被标记为长任务 | 50ms |
反直觉点:滚动可能还能动。因为滚动和合成被下放到合成线程(compositor thread),不需要主线程。但"点按钮没反应"——因为事件必须进主线程。
架构分工:主线程是"逻辑线程",不是"渲染线程"。它阻塞不是画面不出像素,而是页面的"脑子"停转了。
为什么"允许阻塞 + 加锁"这条路走不通
(a) 多线程直接操作 DOM + 锁
DOM 不是几个变量,是一棵巨大的、互相引用的可变图(几万节点 + 样式 + 布局 + 事件监听)。要给每次 appendChild、每次 style.width 加锁,锁粒度要么粗到毫无并发,要么细到没法维护。getBoundingClientRect() 这类读操作还会触发同步布局,依赖一大堆其他状态——它要锁住半个 DOM。
(b) 允许阻塞 + 抢占
抢占意味着在任意两条指令之间中断。如果中断后跑的是别的 JS(比如事件回调),就是重入:你的函数跑到一半,DOM 被另一个回调改了,局部变量与 DOM 状态对不上。
这条路其实被试过:alert()、confirm()、同步 XMLHttpRequest、showModalDialog 就是"在主线程堵着直到结果回来"。它们能跑,是因为浏览器把事件循环嵌套进去了。结果就是灾难现场:
btn.onclick = () => { // 用户点按钮
while (Date.now() - start < 3000) {} // 内部假设 DOM 不变
// 期间另一个 click 回调被重入执行,把 DOM 改了
};
同步 XHR 因此被规范废弃,正因为它引入了不可推理的重入。这不是"没实现好",是这条路本身漏底。
Node 是同一个道理
Node 主线程没有 UI,但它是所有并发请求的唯一处理者:
app.get('/', (req, res) => {
const data = JSON.parse(hugeString); // 同步、阻塞
res.json(data); // 这 200ms 里,其他请求全在等
});
不是"浏览器特殊",而是"一个线程服务所有人"这个结构性事实决定了:让出 = 别人能跑,不让出 = 全都停。
单线程 ──┬─→ 没有"别人"可以接盘
├─→ 没有抢占,所以无法被中断
└─→ 于是"阻塞"不可恢复 → 必须让出(await / 事件循环)
五、闭包 + 共享变量的时序竞态
场景
async 方法接收一个闭包,闭包改主流程里的变量;外部又用 .then 改闭包捕获的同一个变量。顺序是什么?
结论:不是不可预测,是"顺序不由书写位置决定,而由入队时刻决定"
JS 的执行顺序 100% 确定,没有随机性。觉得"不清楚",是因为按书写顺序在读,而实际按微任务入队顺序在跑。
规则只有两条:
1. async 函数体在遇到第一个 await 之前,是同步执行的——不产生任何 tick。
2. await 的续体、.then 的回调,都是 microtask,FIFO 执行。谁先入队谁先跑。
await e:在求值到那一行时(若 e 已 resolve)就把续体入队.then(cb):在调用.then()那一刻把 cb 入队
可运行的例子
let v = 'init';
const closure = () => { v = 'from-closure'; }; // 闭包写 v
async function run(cb) {
console.log('① async 同步段', v);
v = 'from-async-sync';
await null; // ← 此刻入队 [Q1]
console.log('③ await 之后', v);
cb(); // 闭包改 v
console.log('④ 闭包执行后', v);
}
run(closure);
Promise.resolve().then(() => { // ← 此刻入队 [Q2]
v = 'from-external-then';
console.log('⑤ 外部 then', v);
});
console.log('② 主流程同步段结束', v);
输出完全确定:
① async 同步段 init
② 主流程同步段结束 from-async-sync ← 同步段,没让出
③ await 之后 from-async-sync ← Q1 先入队,先跑
④ 闭包执行后 from-closure
⑤ 外部 then from-external-then ← Q2 后入队,后跑
时间线:
同步任务: [① ②] ──栈空──▶ 微任务队列: [Q1(await续体)] [Q2(外部then)]
先 ──────────────▶ 后
把外部那行挪到 run() 前面,Q1/Q2 角色立刻互换,③④⑤ 整个翻转:
Promise.resolve().then(() => { v = 'from-external-then'; }); // 先入队
run(closure); // await 后入队
① ...
② ...
⑤ 外部 then from-external-then
③ await 之后 from-external-then
④ 闭包执行后 from-closure
若把 await null 换成 await fetch(...) 或 await new Promise(r => setTimeout(r, 0)),外部 .then 一定先跑——I/O 完成属于宏任务,当前这一轮的所有微任务早已排空。
什么时候会"看起来翻转"
| 写法 | 续体入队时刻 | 相对早晚 |
|---|---|---|
| await 已 resolve 的 Promise | 求值到该行时 | 早 |
| .then() on 已 resolve 的 Promise | 调用 .then() 时 | 取决于代码位置 |
| await p(p 尚未 resolve) | p settle 时 | 取决于 p |
| await new Promise(r => setTimeout(r,0)) | 下一个宏任务后 | 最晚 |
| await fetch() | 网络返回后 | 时间不可控,但返回后仍走微任务 FIFO |
两个容易踩的细节:
await至少让出一个 microtask tick,即使等的是普通值(await 1)。所以await后的代码永远不可能和前面的在同一个 tick 里连续执行。- tick 数会随嵌套变化。
return await xvsreturn x的微任务层数不同,长链条下差出好几个 tick。所以"数 tick"这种推理方式本身是脆的。
真正的问题不是"不可预测",是"隐性且脆弱"
即使把规则背熟,依然不该依赖这个顺序来保证正确性:
1. 顺序由"求值位置"而非"语义依赖"决定,跟业务意图毫无关系。
2. 改动是局部的,后果是全局的。别人把某个 await 改成 Promise.all,或调整一行调用位置,时序就静默翻转——而且"偶尔出错"。
3. 调试器会骗你。单步时栈是暂停的,看起来线性;运行时时序是交错的。
这正是 race condition(时序竞态) 与 data race(内存竞态) 的区别:
单线程消灭了 data race(撕裂读、缓存不一致、可见性),
但完全没有消灭 race condition。
单线程只保证"同一时刻只有一个人在跑",不保证"谁先跑符合你的预期"。
工程对策
(1) 把依赖显式化——不要靠 tick 猜
const p = run(closure); // 拿到 async 方法的凭证
await p; // 明确等它全部跑完
// 这里才碰 v
(2) 共享可变量改成"返回值",而不是"副作用"
// ❌ 闭包改外部变量 —— 时序靠猜
// ✅ 让 async 方法 return,调用方消费
const result = await run();
(3) 必须共享时,只允许一方写
把"谁写谁读"变成结构约束,而不是时序约束。多个写入方 = 内在就有竞态。
(4) 需要真正的互斥就用异步锁
let lock = Promise.resolve();
const withLock = fn => (lock = lock.then(fn, fn));
六、JS 执行顺序的完整模型
只有三个"地方"能放代码
┌─────────────────────────────────────────────┐
│ 1. 调用栈 (Call Stack) │
│ 同步代码在这里跑,一次一个 │
└─────────────────────────────────────────────┘
↓ 栈空了
┌─────────────────────────────────────────────┐
│ 2. 微任务队列 (Microtask Queue) ← 必须全部清空
│ - Promise.then / catch / finally │
│ - await 后面的代码 │
│ - queueMicrotask() │
│ - MutationObserver │
└─────────────────────────────────────────────┘
↓ 微任务清空后
┌─────────────────────────────────────────────┐
│ 3. 宏任务队列 (Macrotask / Task Queue) ← 一次取一个
│ - setTimeout / setInterval │
│ - I/O、网络、用户输入事件 │
│ - MessageChannel │
└─────────────────────────────────────────────┘
事件循环的一轮:
取 1 个宏任务 → 跑完 → 清空微任务队列(期间新增的也一起清) → 渲染 → 下一个宏任务
最重要的一句话:微任务是"插队"的——一个宏任务跑完,它在执行过程中产生的所有微任务会全部跑完,才轮到下一个宏任务。
async/await 的展开式
async function f() {
A();
await X;
B();
}
约等于:
function f() {
A(); // ① 同步跑
return Promise.resolve(X).then(() => { // ② 让出,返回 pending promise
B(); // ③ 微任务里跑
});
}
由此得到 async 的三条铁律:
| # | 规则 | 后果 |
|---|---|---|
| 1 | 调用 async 函数时,函数体同步执行到第一个 await | async 不是"整个函数异步",开头那段是同步的 |
| 2 | 遇到 await 立刻让出,返回一个 pending promise 给调用方 | 调用方拿到的是占位符,不是结果 |
| 3 | await 后面的代码 = 一个 .then 回调 = 一个微任务 | 永远排到当前同步段之后 |
真实实现是状态机(不是嵌套 then),但作为心智模型,这个等价式完全够用。
关键细节:await 至少花 1 个微任务 tick,即使等的是普通值。
await 1; // 也是 1 tick
await Promise.resolve(); // 1 tick
(ES2019 前 await 一个 promise 要 3 tick,现代引擎 V8 7.2+ / Node 12+ 统一为 1 tick。旧文章里的"3 tick"说法已过时。)
例题 1:最基础
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
(async () => {
console.log('4');
await Promise.resolve();
console.log('5');
})();
console.log('6');
输出:1 4 6 3 5 2
同步段: 1 ← console
4 ← async 函数体同步执行(铁律 1)
6 ← console
[T1:setTimeout] 入宏任务队列
[M1:then] 入微任务队列
[M2:await续体] 入微任务队列
—— 栈空 ——
微任务: 3 (M1)
5 (M2)
宏任务: 2 (T1)
setTimeout(...,0) 不是"0 毫秒后",是"排到下一个宏任务",所以必然在所有微任务之后。
例题 2:await 的 tick 成本
async function f() {
console.log('a: 同步段');
await null; // ← 入队
console.log('b: 续体');
}
f();
Promise.resolve().then(() => console.log('c: then'));
console.log('d: 同步段');
输出:a: 同步段 → d: 同步段 → b: 续体 → c: then
await null 的续体在求值到那一行时入队,.then 在调用 .then() 时入队,前者先。
把 f() 挪到 .then 后面,b 和 c 就互换。
例题 3:.then 链每一节加一个 tick
Promise.resolve()
.then(() => console.log('A')) // tick 1
.then(() => console.log('B')); // tick 2 ← 中间那个 promise 要 settle
Promise.resolve()
.then(() => console.log('C')) // tick 1
.then(() => console.log('D')); // tick 2
输出:A C B D(不是 A B C D)
链式 .then 的第二节不是第一节完成就跑,而是"第一节回调返回 → 中间 promise resolve → 再入队一个微任务"。
tick1: A, C
tick2: B, D
每多一层 .then / 每多一次 await,就往后挪一格。
例题 4:为什么 for + await 看起来"乱"
async function main() {
for (let i = 0; i < 3; i++) {
await null;
console.log(i);
}
}
main();
Promise.resolve().then(() => console.log('then'));
输出:0 → then → 1 → 2
同步: i=0, await → 入队 [Q1], main 挂起; then → 入队 [Q2]
Q1: log 0; i=1; await → 入队 [Q3](追加到队尾)
Q2: log "then"
Q3: log 1; i=2; await → 入队 [Q4]
Q4: log 2; i=3; 循环结束
这就是"乱"的根源:每次 await 都把同一段控制流拆成碎片,重新排到队尾——于是它和队列里的其他碎片交错执行。 循环体不是"一口气跑完 3 次",而是"跑一次、排一次队、再跑一次"。
为什么"感觉乱"——四个真实原因
1. 书写顺序 ≠ 执行顺序。
读代码是线性从上往下,但 await / .then 的语义是"把后面的代码整体往后挪一格"。眼睛看到水平顺序,运行时是垂直队列。
2. async 函数是"半同步"的。
async function 暗示"整个函数异步",但第一个 await 之前是同步的。所以日志里经常看到 async 函数的内容出现在同步段中,与直觉冲突。
3. await ≠ 等完就继续。
真实语义是"让出,然后排到微任务队尾",不是"暂停在当前位置"。等待期间,队列里所有其他微任务都会跑。
4. tick 数会累积。
await a() 里 a() 内部又有 await → 你等的是两个 tick。链式 .then 每节都加 tick。return await x 比 return x 多一个 tick。这些都不在代码表面写着。
Node.js 的差异
| | 浏览器 | Node.js |
|---|---|---|
| 微任务队列 | Promise 续体 | Promise 续体 |
| 额外高优先级队列 | 无 | process.nextTick(比 promise 微任务还优先) |
| 宏任务类型 | setTimeout / 事件 / I/O | timers / poll / check(setImmediate) / close |
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate')); // 主模块里这两个顺序不保证
process.nextTick(() => console.log('nextTick')); // 最先
Promise.resolve().then(() => console.log('promise'));
// nextTick, promise, (timeout/immediate 顺序不定)
Node 11+ 已让微任务在每个宏任务后清空,与浏览器一致;在此之前微任务只在阶段之间清空,行为更反直觉。
一句话心智模型
把每个
await和每个.then都理解成"在这里插入一个/换行,后面的代码换到下一行(下一个 tick)去排队"。
>
一个宏任务跑完
\n,微任务队列整列跑完\n,才轮下一个宏任务。
七、速查表与实践原则
入队时刻
| 写法 | 后续代码何时跑 |
|---|---|
| await 1 / await Promise.resolve() | 1 个微任务后 |
| await p(p 未 settle) | p settle 后 + 1 微任务 |
| await fetch() | 网络返回后 + 1 微任务 |
| p.then(cb)(p 已 resolve) | 1 个微任务后 |
| p.then(a).then(b) | a 在 1 tick,b 在 2 tick |
| setTimeout(cb, 0) | 当前所有微任务跑完后 |
| queueMicrotask(cb) | 1 个微任务后(与 then 同队) |
| requestAnimationFrame(cb) | 微任务清空后、渲染前 |
API 选择
| 需求 | 用 |
|---|---|
| 等结果 | await |
| 当前 tick 结束后立刻跑(不渲染) | queueMicrotask / Promise.resolve().then |
| 下一帧、要看到渲染 | requestAnimationFrame |
| 让出主线程,让事件先处理 | setTimeout(cb, 0) |
| 别做 | 在主线程同步阻塞 |
三条实践原则
1. 需要顺序就显式 await,不要靠微任务的相对顺序来正确性。
2. 需要通信就传值 / return,不要让闭包和外部共享可变状态再靠时序对齐。
3. 需要并行就 Promise.all,不要在一个 for 里串行 await(会退化成 N 倍耗时)。
// ❌ 串行:总耗时 = 1s + 1s + 1s
for (const id of ids) { results.push(await fetchOne(id)); }
// ✅ 并行:总耗时 ≈ 1s
const results = await Promise.all(ids.map(fetchOne));
单线程 + 无抢占 = 阻塞不可恢复。
顺序确定 + 入队时刻隐性 = 可预测,但不可依赖。