事件循环与异步
这一课你将能做到
- 画出事件循环的执行模型,说清同步代码、微任务、宏任务、nextTick 的优先级
- 用
async/await 把回调式和 Promise 式代码改写成顺序风格,并用 Promise.all 做并发
- 用一段实测代码解释 setTimeout 与 setImmediate 的执行顺序,说清为什么 CPU 密集会卡死服务
为什么要学
前面课程里你一直在「无意识地异步」:fs 读文件返回 Promise、HTTP 回调里处理请求。这课把窗帘拉开:Node 到底怎么用一个 JS 主线程同时伺候几千个连接?
答案就是事件循环(Event Loop)。它是 Node 面试的第一高频题,也是实际开发中「接口偶尔卡顿」「请求互相拖累」等诡异现象的总根源。人话:事件循环就是一个永不停歇的调度员——主线程跑完手里的活,就去各个队列里领下一件;I/O 交给系统干,干完了把回调排队等领。理解它,异步代码的执行顺序从「背结论」变成「推出来」。
先建立图像
┌────────────────────────────────────┐
│ 一次事件循环 tick │
同步代码 ──►│ ① timers setTimeout/setInterval 回调
│ ② pending callbacks 系统 I/O 回调
│ ③ poll 绝大多数 I/O 回调(fs、网络)
│ ④ check setImmediate 回调
│ ⑤ close close 事件回调
└────────────────────────────────────┘
每切换一个阶段之间,都先清空两条「插队通道」:
process.nextTick 队列 ← 最高优先级
Promise 微任务队列 ← 次之
三条优先级,从高到低:
- 同步代码(最先跑完)
- nextTick 队列 → 微任务(Promise.then / await 后面的代码)
- 宏任务(timers、poll、check 里的 I/O 回调)
跟着做
步骤 1:一段代码看清执行顺序
新建 node-async 目录,npm init -y,新建 order.js:
// order.js
console.log('1: 同步代码');
setTimeout(() => console.log('4: setTimeout 宏任务'), 0);
setImmediate(() => console.log('5: setImmediate (check 阶段)'));
Promise.resolve().then(() => console.log('3: Promise 微任务'));
process.nextTick(() => console.log('2: nextTick'));
console.log('6: 同步代码结束');
node order.js
# 1: 同步代码
# 6: 同步代码结束
# 2: nextTick
# 3: Promise 微任务
# 4: setTimeout 宏任务
# 5: setImmediate (check 阶段)
解读:同步代码跑完 → 清空 nextTick → 清空微任务 → 进入事件循环各阶段领宏任务。「同步先于一切,nextTick 插队,微任务紧随,宏任务最后」——这四行输出背下来,面试顺序题基本通杀。
步骤 2:I/O 回调里的 setTimeout vs setImmediate
主模块里 setTimeout(0) 和 setImmediate 的先后不保证(受机器性能影响,通常 timeout 先);但在 I/O 回调里顺序是确定的:
// io-order.js
const fs = require('node:fs');
fs.readFile(__filename, () => {
console.log('readFile 回调(poll 阶段)');
setTimeout(() => console.log(' setTimeout'), 0);
setImmediate(() => console.log(' setImmediate'));
});
node io-order.js
# readFile 回调(poll 阶段)
# setImmediate ← 一定先
# setTimeout
原因:readFile 回调在 poll 阶段执行,下一个要进入的是 check 阶段(setImmediate),之后才轮到下一轮循环的 timers。「I/O 回调里 setImmediate 先于 setTimeout」是面试必考的确定结论,主模块里则只能说「通常 setTimeout 先,官方不保证」。
步骤 3:回调 → Promise → async/await 三代同堂
// three-generations.js
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const path = require('node:path');
async function makeSample() {
const file = path.join(__dirname, 'user.json');
await fsp.writeFile(file, JSON.stringify({ name: 'Java', age: 20 }));
return file;
}
// 第一代:回调(error-first 约定)
function readByCallback(file) {
fs.readFile(file, 'utf8', (err, data) => {
if (err) return console.error('回调版失败:', err.message);
console.log('回调版:', JSON.parse(data).name);
});
}
// 第二代:Promise
function readByPromise(file) {
fsp.readFile(file, 'utf8')
.then((data) => console.log('Promise 版:', JSON.parse(data).name))
.catch((err) => console.error('Promise 版失败:', err.message));
}
// 第三代:async/await(Promise 的语法糖)
async function readByAsync(file) {
try {
const data = await fsp.readFile(file, 'utf8');
console.log('await 版:', JSON.parse(data).name);
} catch (err) {
console.error('await 版失败:', err.message);
}
}
async function main() {
const file = await makeSample();
readByCallback(file);
readByPromise(file);
await readByAsync(file);
}
main();
node three-generations.js
# 回调版: Java
# Promise 版: Java
# await 版: Java
新代码统一 async/await:结构顺序、异常用 try/catch、异常传播符合直觉。但老代码和事件 API 仍是回调,error-first 约定(第一个参数是 err)必须认识。
步骤 4:串行 vs 并发——Promise.all 的价值
// concurrency.js
const fsp = require('node:fs/promises');
const path = require('node:path');
async function makeFiles() {
const names = ['a', 'b', 'c'];
for (const n of names) {
await fsp.writeFile(path.join(__dirname, `${n}.txt`), `内容${n}`, 'utf8');
}
return names.map((n) => path.join(__dirname, `${n}.txt`));
}
async function serial(files) { // 串行:一个等一个
const start = Date.now();
const result = [];
for (const f of files) {
result.push(await fsp.readFile(f, 'utf8'));
}
console.log(`串行耗时: ${Date.now() - start}ms`);
return result;
}
async function parallel(files) { // 并发:同时发起
const start = Date.now();
const result = await Promise.all(files.map((f) => fsp.readFile(f, 'utf8')));
console.log(`并发耗时: ${Date.now() - start}ms`);
return result;
}
async function main() {
const files = await makeFiles();
await serial(files);
await parallel(files);
}
main();
// 串行耗时: 6ms
// 并发耗时: 1ms (文件越大、越多,差距越大)
规则:互相独立的异步任务用 Promise.all 打包并发;有依赖关系的才 await 串行。await 写在 for 循环里逐个等待,是把并行能力手动降级成串行——最常见的性能反模式。
步骤 5:亲眼看看「阻塞」长什么样
// block.js
const http = require('node:http');
const server = http.createServer((req, res) => {
if (req.url === '/heavy') {
const start = Date.now();
while (Date.now() - start < 3000) {} // 模拟 3 秒 CPU 密集计算
res.end('heavy done');
return;
}
res.end('quick');
});
server.listen(3000, () => console.log('http://localhost:3000'));
curl http://localhost:3000/quick # 先发这个,紧跟着发下面的
curl http://localhost:3000/heavy
现象:/quick 本应立刻返回,却要陪着 /heavy 等满 3 秒。这就是单线程的代价:一个 CPU 密集任务阻塞事件循环,所有连接一起遭殃。 解决思路(进阶):拆分计算、子进程 child_process、worker_threads,或这类任务直接交给 Java/Go 服务。
步骤 6:await 的正确心智
// await-mind.js
function delay(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
async function main() {
console.log('A');
const v = await delay(100, 'B'); // 挂起的是 main 这条"逻辑",不是整个线程
console.log(v); // 100ms 后微任务里恢复执行
console.log('C');
}
main();
console.log('D');
// 输出: A D B C
// await 之后的代码 = 注册进微任务,主线程继续跑外面的同步代码(D)
关键心智:await 暂停的是当前这个 async 函数,不是事件循环。线程立刻回去干别的活,Promise 落定后把「await 后面的代码」排进微任务。
讲清楚要点
- 事件循环 = 阶段化的任务调度器:timers → pending → poll → check → close,每阶段之间清空 nextTick 和微任务队列。I/O 的「等待」发生在系统层,主线程从不空等。
- 优先级口诀:同步 → nextTick → 微任务(Promise)→ 宏任务(setTimeout/setImmediate/I/O 回调)。
- async/await 是 Promise 的语法糖:
await p 等价于 p.then(后续代码),try/catch 等价于 .catch。没有 Promise 落定,await 之后的代码不会执行。
- 串行用 await 链,并发用 Promise.all:
Promise.all 全成功才成功;要「谁先完成用谁」用 Promise.race;要「全跑完看结果成败」用 Promise.allSettled。
- CPU 密集是单线程的死穴:I/O 等待可以并发,计算不能。长计算会拖住所有请求,必须拆分或外移(子进程/Worker/其他服务)。
你可能会懵的地方
-
Node 单线程,怎么同时处理几千个连接?
「单线程」指执行 JS 的主线程只有一个。网络 I/O、磁盘 I/O 的等待由操作系统和 libuv(部分用线程池)完成,主线程只负责跑回调。等待不占主线程时间,所以能并发。
-
nextTick 和 setImmediate 名字好像搞反了?
历史命名遗留。nextTick 其实是「当前操作结束立刻执行」(比微任务还靠前);setImmediate 是「下一轮 check 阶段执行」(要等到当前循环阶段走完)。记住事实优先级:nextTick > Promise 微任务 > setImmediate。
-
await 会不会阻塞事件循环?
不会阻塞「循环」,只挂起当前 async 函数的后续逻辑。真正阻塞循环的是同步 CPU 计算(while 死循环、大 JSON.parse)。await 的「等」是让线程去干别的,回来再说。
-
for 循环里 await 和 Promise.all 怎么选?
循环 await = 串行,适合「后一步依赖前一步结果」(如分页抓取)。独立任务一律 await Promise.all(tasks) 并发。还要控制并发量(比如同时最多 5 个请求)时,用循环分批或 p-limit 这类库。
-
setTimeout(fn, 0) 是立刻执行吗?
不是。Node 会把 0 归一化为 1ms,且必须等当前同步代码和微任务全部清空、轮到 timers 阶段才执行。它只是「尽快排进宏任务」。
错法对照
| 错法 | 问题 | 正解 |
|---|
for 循环里 await 发独立请求 | 手动串行,耗时累加 | 收集 Promise,Promise.all 并发 |
| async 函数调用后直接用返回值 | 拿到的是 Promise,不是结果 | const v = await asyncFn() |
在 async 函数里 await 一个非 Promise 回调 API | await 无效,拿到原始 undefined/值 | 用 util.promisify 包一层或换 promises API |
| try/catch 里 catch 后不处理继续跑 | 错误被吞,下游拿脏数据 | 记录 + 重新抛出或返回明确错误 |
| 忘了 await 一个返回 Promise 的调用 | 错误变成 unhandledRejection,时序乱 | 每个Promise 要么 await,要么显式 .catch |
| 用 setTimeout 实现精确顺序的流程 | 宏任务时序不可靠(受 I/O 影响) | 用 Promise 链 / await 表达依赖关系 |
卡点急救
- 输出顺序和预期不符:先分类——同步?微任务(then/await 后)?宏任务(setTimeout/IO)?按「同步 → nextTick → 微任务 → 宏任务」重排。
await is only valid in async functions:await 只能出现在 async 函数内(或 ESM 顶层)。包一层 async main() 再调用。
- 接口偶发超时、整体变慢:找同步大计算(大 JSON 解析、同步加密、复杂循环)——用
--prof 或简单打时间戳定位,把计算移出请求路径。
UnhandledPromiseRejection 警告:某个 Promise 没 await 也没 catch。全项目搜索返回 Promise 的调用点补齐处理;进程级兜底第 10 课讲。
- Promise.all 一个失败全军覆没:需要「成败都留痕」就换
Promise.allSettled,按 status: 'fulfilled' | 'rejected' 分别处理。
面试常问
-
描述 Node.js 的事件循环。
libuv 实现的阶段化循环:timers(setTimeout/setInterval)→ pending callbacks → poll(I/O 回调)→ check(setImmediate)→ close。各阶段切换时清空 nextTick 队列和微任务(Promise)队列。主线程跑 JS,I/O 由系统与线程池异步完成,回调按阶段排队执行。
-
process.nextTick、Promise.then、setTimeout(fn,0) 的执行顺序?
同步代码后:nextTick 最先,然后微任务(Promise.then),然后宏任务(timers)。在 I/O 回调内,setImmediate 先于 setTimeout(poll 之后紧接 check)。
-
async/await 的原理?相比 Promise 的优势?
await 把后续逻辑注册为 Promise 的微任务(语法糖,配合生成器语义)。优势:代码呈同步顺序、异常用 try/catch、调试堆栈更直观;本质仍是异步非阻塞,不阻塞事件循环。
-
Node 如何处理高并发?CPU 密集任务怎么办?
靠事件循环 + 非阻塞 I/O:等待不占主线程,少量线程服务大量连接。CPU 密集会阻塞循环,方案:任务拆分、child_process 派发、worker_threads 多线程,或直接交给更适合计算的语言/服务。
自测三问
- 写出
console.log → nextTick → Promise.then → setTimeout(0) 四者的输出顺序,并说明依据。
fs.readFile 的回调在哪个阶段执行?回调里再写 setImmediate 和 setTimeout,谁先?
- 三个互不依赖的接口请求,怎么写最快?有依赖关系的呢?
小结一句话
事件循环用「一个主线程 + 阶段化队列」实现并发:同步先跑、nextTick 插队、微任务随后、宏任务按阶段领取;await 挂起的是函数不是线程,CPU 计算才是真正的阻塞源。
延伸