APE
文章标签归档关于

© 2026 APE.PUB

2026-10-31· 30 分钟

js-async/await

javascriptasync/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 x vs return 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));

单线程 + 无抢占 = 阻塞不可恢复。

顺序确定 + 入队时刻隐性 = 可预测,但不可依赖。

← 返回首页