CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Node.js 后端入门/课时/事件循环与异步
RawBlame
课时目录12 节

Node.js 后端入门

  1. 01Node.js 是什么
  2. 02运行时与 process
  3. 03模块与 npm
  4. 04文件系统与 path
  5. 05http 模块
  6. 06Express 入门
  7. 07事件循环与异步
  8. 08环境变量与配置
  9. 09Streams 基础
  10. 10错误处理
  11. 11测试 node:test
  12. 12综合练习与选型

事件循环与异步

Event Loop、process.nextTick、Node 中 Promise/async。

35 分钟

事件循环与异步

这一课你将能做到

  1. 画出事件循环的执行模型,说清同步代码、微任务、宏任务、nextTick 的优先级
  2. 用 async/await 把回调式和 Promise 式代码改写成顺序风格,并用 Promise.all 做并发
  3. 用一段实测代码解释 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 微任务队列      ← 次之

三条优先级,从高到低:

  1. 同步代码(最先跑完)
  2. nextTick 队列 → 微任务(Promise.then / await 后面的代码)
  3. 宏任务(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 三代同堂

同一个任务(读文件并解析 JSON)的三种写法:

// 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 后面的代码」排进微任务。

讲清楚要点

  1. 事件循环 = 阶段化的任务调度器:timers → pending → poll → check → close,每阶段之间清空 nextTick 和微任务队列。I/O 的「等待」发生在系统层,主线程从不空等。
  2. 优先级口诀:同步 → nextTick → 微任务(Promise)→ 宏任务(setTimeout/setImmediate/I/O 回调)。
  3. async/await 是 Promise 的语法糖:await p 等价于 p.then(后续代码),try/catch 等价于 .catch。没有 Promise 落定,await 之后的代码不会执行。
  4. 串行用 await 链,并发用 Promise.all:Promise.all 全成功才成功;要「谁先完成用谁」用 Promise.race;要「全跑完看结果成败」用 Promise.allSettled。
  5. 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 回调 APIawait 无效,拿到原始 undefined/值用 util.promisify 包一层或换 promises API
try/catch 里 catch 后不处理继续跑错误被吞,下游拿脏数据记录 + 重新抛出或返回明确错误
忘了 await 一个返回 Promise 的调用错误变成 unhandledRejection,时序乱每个Promise 要么 await,要么显式 .catch
用 setTimeout 实现精确顺序的流程宏任务时序不可靠(受 I/O 影响)用 Promise 链 / await 表达依赖关系

卡点急救

  1. 输出顺序和预期不符:先分类——同步?微任务(then/await 后)?宏任务(setTimeout/IO)?按「同步 → nextTick → 微任务 → 宏任务」重排。
  2. await is only valid in async functions:await 只能出现在 async 函数内(或 ESM 顶层)。包一层 async main() 再调用。
  3. 接口偶发超时、整体变慢:找同步大计算(大 JSON 解析、同步加密、复杂循环)——用 --prof 或简单打时间戳定位,把计算移出请求路径。
  4. UnhandledPromiseRejection 警告:某个 Promise 没 await 也没 catch。全项目搜索返回 Promise 的调用点补齐处理;进程级兜底第 10 课讲。
  5. 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 多线程,或直接交给更适合计算的语言/服务。

自测三问

  1. 写出 console.log → nextTick → Promise.then → setTimeout(0) 四者的输出顺序,并说明依据。
  2. fs.readFile 的回调在哪个阶段执行?回调里再写 setImmediate 和 setTimeout,谁先?
  3. 三个互不依赖的接口请求,怎么写最快?有依赖关系的呢?

小结一句话

事件循环用「一个主线程 + 阶段化队列」实现并发:同步先跑、nextTick 插队、微任务随后、宏任务按阶段领取;await 挂起的是函数不是线程,CPU 计算才是真正的阻塞源。

延伸

  • Node.js 官方文档 Event Loop
  • MDN 使用 Promise
  • 下一课:环境变量与配置——把密码、端口、开关从代码里挪出去,用 .env 和 NODE_ENV 管理多环境。
加载本节练习…
← 上一课Express 入门下一课 →环境变量与配置