
有不少刚接触 Node.js 的人把“事件驱动”理解成“写回调函数”把 Event Loop 理解成“异步循环执行”。这两个说法不准确但又接近真相。真正的问题在于很多人写完 Express.js 接口后根本不知道自己写的代码在什么阶段执行、哪些操作会把整个进程卡死、为什么 QPS 一高响应就变慢。这次我们直接拆开 Node.js 的运行时把事件驱动和 Event Loop 讲清楚再看它们在 Express.js 接口里是如何工作的。这不是一篇“从入门到放弃”的科普而是一篇能跟着敲代码验证的实战文章。你会看到事件驱动模型怎么让单线程处理高并发、Event Loop 的六个阶段到底是什么、为什么process.nextTick和Promise.then的执行顺序会被搞混、为什么一个for死循环能拖垮整个 Express 服务以及怎么用检测工具观察事件循环是否阻塞。本文会给出完整可运行的示例代码建议在 Node.js 20 或更高版本上验证。1. 核心知识速览能力项说明核心模型基于 libuv 的事件循环单线程 异步 I/O适用框架Express.js、Koa、NestJS 等 Node.js 服务端框架关键概念Event Loop、微任务、宏任务、非阻塞 I/O、回调队列优势高并发 I/O 密集场景下内存占用低、上下文切换少劣势CPU 密集任务会阻塞主线程需要 worker 或子进程学习门槛需要理解异步编程、回调、Promise、async/await验证方式通过定时器、文件读取、HTTP 请求观察执行顺序常见问题EADDRINUSE端口冲突、回调地狱、未捕获异常、事件循环阻塞Node.js 版本建议使用 18、20 或 22 LTS 版本。操作系统不限Windows、macOS、Linux 都支持。下面是这篇文章最需要先记住的一句话Node.js 是单线程的但单线程不代表同时只能干一件事而是同一时间只会处理一件事。2. 事件驱动不是“回调套回调”而是“状态通知 任务调度”很多人在学 Node.js 时把事件驱动理解成“我传一个回调函数进去异步执行后调用它”。这没错但只是表面形式。事件驱动的本质是程序不是在主动等待某个操作完成而是注册一个监听器当操作系统或者底层库告诉你“结果已经就绪”时调度器把对应的回调放进执行队列。用文件读取来举个例子。传统的同步写法是const fs require(fs); try { const data fs.readFileSync(./file.txt, utf8); console.log(data); } catch (err) { console.error(err); }这段代码在执行readFileSync时整个进程会阻塞CPU 在那干等磁盘数据返回。如果这个操作发生在 Express 的请求处理流程里其他所有用户请求都会排在你后面服务就像卡死了一样。但 Node.js 中更常见的是非阻塞写法const fs require(fs); fs.readFile(./file.txt, utf8, (err, data) { if (err) { console.error(err); return; } console.log(data); }); console.log(readFile 调用结束继续执行后续代码);这里的执行顺序是先调用fs.readFile然后把回调注册到事件系统里主线程立即继续执行console.log。等到磁盘数据真正读完了线程池或内核通知 libuvlibuv 再将回调放到待执行队列最终由 JavaScript 主线程执行。在 Express.js 中事件驱动的表现方式更直接每次 HTTP 请求进来Node 都会触发一个事件路由匹配到之后你的处理器函数被当作回调执行。const express require(express); const app express(); app.get(/, (req, res) { res.send(Hello, Event Driven!); }); app.listen(3000, () { console.log(server running at http://localhost:3000); });如果把这段代码拆开看app.get(/, callback)实际上是在注册一个事件监听器“当 GET / 请求到达时调用这个回调函数”。你不需要写while (true)去轮询有没有请求事件驱动模型会自动处理这一切。事件驱动最大的价值是把等待时间从代码路径里剥离出来。同步 IO 下程序的时间线是“发起读取 - 等待 - 处理”事件驱动下时间线变成了“发起读取 - 继续做别的事 - 有结果了再回来处理”。这在高并发网络服务中非常关键因为网络请求和数据库查询的大部分时间都花在等待上而不是计算上。3. 环境准备Node.js 安装与版本管理在验证 Event Loop 之前先把 Node.js 环境准备好。很多初学者卡在安装这一步尤其是 Windows 用户会遇到版本切换、PATH 配置、编译工具缺失等问题。3.1 安装 Node.js 的两种方式最简单的方案是直接到 Node.js 官网下载 LTS 版本安装包。官方网址以 nodejs.org 为准下载 Windows 或 macOS 安装包后一路下一步即可。安装完成后打开终端验证node -v npm -v能看到类似v20.11.0和10.2.4的输出就说明安装成功。如果提示node 不是内部或外部命令通常是安装时没有勾选“Add to PATH”或者安装完成后没有重新打开终端。可以手动把 Node.js 安装目录例如C:\Program Files\nodejs\添加到系统环境变量的 Path 中。3.2 使用 NVM 管理多版本如果你需要频繁切换 Node.js 版本比如有的老项目只能在 16 上运行有的新库要求 20建议使用 NVM。Windows 用户用 nvm-windowsmacOS/Linux 用户可以搜索 nvm 官方安装脚本。常用命令# 查看已安装版本 nvm list # 安装指定版本 nvm install 20.11.0 # 切换到指定版本 nvm use 20.11.0 # 设置默认版本 nvm alias default 20.11.0切换后务必再次执行node -v确认当前使用版本。实际开发中如果发现npm install报错、某个原生模块编译失败优先检查 Node 版本是否和项目要求一致。3.3 安装 Express.js新建一个项目目录mkdir event-loop-demo cd event-loop-demo npm init -y npm install express如果npm install速度慢可以使用国内镜像源npm config set registry https://registry.npmmirror.com安装完成后项目里会出现node_modules目录和package-lock.json文件。建议把node_modules加入.gitignore不要提交到版本库。4. 什么是 Event Loop六阶段模型与微任务队列现在进入核心内容。Event Loop 是 Node.js 运行时内部的调度机制它负责把 JavaScript 代码、事件回调、系统 I/O 结果按顺序组织起来执行。Node.js 基于 libuv 库实现了事件循环整个循环分为多个阶段了解每个阶段做什么才能真正理解执行顺序。4.1 六个阶段一次完整的 Event Loop 迭代大致会依次经过以下阶段阶段作用timers执行setTimeout、setInterval的回调pending callbacks执行被延迟到下一轮循环的 I/O 回调idle, prepare内部使用可忽略poll获取新的 I/O 事件执行 I/O 相关回调check执行setImmediate回调close callbacks执行close事件回调例如 socket 关闭每个阶段都有一个先进先出的回调队列。当进入某个阶段时Node.js 会执行该阶段队列中的所有回调直到队列清空或达到回调数上限时才进入下一阶段。需要注意浏览器也有 Event Loop但和 Node.js 的规则不完全一样。浏览器没有setImmediate也没有 libuv 的 poll 阶段。所以不要用浏览器的并发模型直接套 Node.js。4.2 宏任务与微任务在阶段切换之间Node.js 会清空微任务队列。微任务包括process.nextTick的回调和Promise的回调。其中process.nextTick的优先级比Promise更高这意味着它会在当前操作完成后、事件循环继续之前立即执行。经典的验证代码如下console.log(1. 同步代码 start); setTimeout(() { console.log(2. setTimeout 回调); }, 0); setImmediate(() { console.log(3. setImmediate 回调); }); Promise.resolve().then(() { console.log(4. Promise 回调); }); process.nextTick(() { console.log(5. process.nextTick 回调); }); console.log(6. 同步代码 end);在大多数情况下你会看到这样的顺序1. 同步代码 start 6. 同步代码 end 5. process.nextTick 回调 4. Promise 回调 2. setTimeout 回调 3. setImmediate 回调process.nextTick和 Promise 回调都排在定时器之前这并不难理解它们属于微任务在同步代码执行完、进入 timers 阶段之前就被处理了。但setTimeout和setImmediate的顺序有一个坑在模块作用域中执行时顺序不稳定。如果运行多次有时setTimeout先有时setImmediate先。原因是setTimeout(0)的定时器阈值受系统性能和启动时间影响当 poll 阶段已经处理完任务后setImmediate可能比定时器更早进入 check 阶段。这个不确定性在学习的时候很让人困惑但实际工程中很少有人在模块顶层同时比较这两个 API 的顺序。更有意义的对比发生在 I/O 回调内部。const fs require(fs); fs.readFile(__filename, () { setTimeout(() { console.log(setTimeout); }, 0); setImmediate(() { console.log(setImmediate); }); });在 I/O 回调里setImmediate几乎总是先于setTimeout执行。原因很简单当前正处于 poll 阶段文件读取的回调已经处理完接下来事件循环先去 check 阶段执行setImmediate之后才在下一轮迭代的 timers 阶段处理定时器。这个顺序是稳定的、可预测的。4.3 事件循环阻塞的后果理解了 Event Loop 后就能解释为什么某些代码会导致服务卡顿。看这个 Express 示例const express require(express); const app express(); app.get(/hello, (req, res) { res.send(hello); }); app.get(/block, (req, res) { // 同步阻塞 5 秒 const end Date.now() 5000; while (Date.now() end) { // 空转 } res.send(done); }); app.listen(3000);当你请求/block时Express 的处理器函数会同步阻塞主线程 5 秒。此时你再请求/hello请求会进入 pending 状态直到/block处理完才能得到响应。因为JavaScript 是单线程的事件循环一次只能执行一个任务。/block的同步循环占住了线程其他所有请求、定时器、I/O 回调全部排队。这也是 Event Loop 最需要关注的工程问题不要在请求处理路径里执行 CPU 密集的同步计算。如果必须做应该使用worker_threads或child_process把这些任务移出主线程。4.4 异步 I/O 的优势在哪里既然单线程容易阻塞为什么还要用它因为实际的高并发服务瓶颈往往不在 CPU 计算而在等待 I/O读数据库、调远程接口、读写文件、操作缓存。如果为每个请求都创建一个线程线程切换成本会很高而用单线程 事件驱动等待期间 CPU 可以立刻去处理下一个请求单位时间内能容纳的并发连接数量非常大。所以 Event Loop 模型的适用场景非常明确I/O 密集型服务例如 Web API、网关、聊天服务、实时推送。如果是 CPU 密集任务例如图像处理、大规模矩阵计算Node.js 并不是最优选择即使强行使用也需要配合 worker 线程。5. 在 Express.js 中验证 Event Loop理论讲完了现在通过 Express 服务来一步步验证。以下代码会模拟几种典型场景观察不同异步操作对请求响应时间的影响。5.1 基础 Express 服务创建一个server.jsconst express require(express); const app express(); app.get(/fast, (req, res) { res.json({ message: fast response, timestamp: Date.now() }); }); app.get(/slow, (req, res) { setTimeout(() { res.json({ message: slow response after 2000ms, timestamp: Date.now() }); }, 2000); }); app.get(/file, (req, res) { const fs require(fs); fs.readFile(__filename, utf8, (err, data) { if (err) { res.status(500).json({ error: err.message }); return; } res.json({ fileSize: data.length, message: file read done }); }); }); const PORT 3000; app.listen(PORT, () { console.log(server listening on ${PORT}); });启动服务node server.js打开浏览器访问http://localhost:3000/fast会立即返回 JSON访问/slow2 秒后返回访问/file读取当前文件后返回文件长度。这里最重要的观察点是访问/slow时并发请求/fast也不会卡住。因为setTimeout是非阻塞的/slow的回调被放到 timers 阶段主线程不会干等 2 秒。5.2 使用 curl 或浏览器验证如果安装了 curl可以分别请求curl http://localhost:3000/fast curl http://localhost:3000/slow curl http://localhost:3000/file或者写一个简单的并发测试脚本同时发起 10 个/fast请求for i in {1..10}; do curl -s http://localhost:3000/fast done wait观察结果所有请求几乎同时返回而不是一个个排队。这说明事件驱动模型在同一时间段内接收了多个请求并通过事件循环快速处理。5.3 观察同步阻塞对请求的影响修改/slow把setTimeout替换成同步阻塞app.get(/slow-block, (req, res) { const end Date.now() 2000; while (Date.now() end) { // 阻塞主线程 } res.json({ message: blocked for 2000ms }); });重启服务后先请求/slow-block然后立刻请求/fast。你会发现/fast也必须等待大约 2 秒才能返回。这就是事件循环阻塞的典型现象。在生产环境中这种阻塞可能来自大 JSON 解析、正则回溯、复杂计算、同步文件读取、数据库同步驱动等。排查时需要重点检查请求处理链路上的同步操作。5.4 通过日志观察阶段执行顺序在 Express 中添加一个日志中间件配合事件循环阶段标记app.use((req, res, next) { console.log([${new Date().toISOString()}] request: ${req.method} ${req.url}); next(); });再在路由里添加各种异步操作观察打印日志的先后顺序。你会发现请求处理本身是同步进入的中间等待 I/O 的时间被让出去事件循环在这个过程中会继续处理其他事件。6. 微任务、宏任务与 Express 的关联很多人以为 Express 中只要用了async/await就一定是异步的其实不对。async/await只是语法糖它真正做的是把 Promise 链展开。如果函数内部是同步计算await一个同步值并不会把任务交给事件循环。看这个例子app.get(/async-sync, async (req, res) { // 这里没有真正的异步 I/O依然是同步计算 let sum 0; for (let i 0; i 1e8; i) { sum i; } res.json({ sum }); });虽然标记了async但这段代码仍然会阻塞事件循环。因为for循环的计算发生在当前调用栈里事件循环不会插进来微任务也不会被执行。真正会发生阻塞的不是有没有用 async而是代码里有没有真正让出线程的异步 I/O 操作。6.1 Promise 与 nextTick 在请求中的顺序在 Express 的中间件中微任务执行顺序也会影响业务逻辑。app.get(/microtask, (req, res) { Promise.resolve().then(() { console.log(promise microtask); }); process.nextTick(() { console.log(nextTick microtask); }); console.log(sync log); res.send(done); });请求后控制台输出顺序一定是sync log nextTick microtask promise microtask这说明process.nextTick在当前操作完成后立即执行而 Promise 的回调在下一个微任务检查点执行。二者的优先级差异虽然在这种简单场景下不明显但如果在nextTick中递归调用process.nextTick会导致微任务队列无限延长事件循环永远进入不了下一个阶段最终卡死进程。6.2 不要在 nextTick 里做递归下面这种写法是经典的坑function badLoop() { process.nextTick(() { badLoop(); }); }这段代码会在微任务阶段一直递归下去事件循环永远无法进入定时器或 I/O 阶段其他请求全部无法处理。如果要执行大量独立任务应该用队列加setImmediate或setTimeout分批处理把控制权交还给事件循环。6.3 async/await 对事件循环的影响使用await时如果被等待的 Promise 已经 resolve后续代码会被放入微任务队列。如果被等待的 Promise 内部是真正的异步 I/O事件循环会先处理其他任务直到 I/O 完成后再回到这个 Promise 的 then 回调。const fs require(fs); const { promisify } require(util); const readFile promisify(fs.readFile); app.get(/async-file, async (req, res) { const data await readFile(__filename, utf8); res.json({ size: data.length }); });这个接口的等待期间不会阻塞事件循环。也就是说当它正在读文件时其他请求依然可以正常处理。这是非阻塞 I/O 与事件驱动模型配合的典型写法。7. 性能观察如何确认事件循环没有被阻塞工作中不能凭感觉判断服务卡不卡要用工具和数据说话。下面几种方法可以观察到事件循环的阻塞情况。7.1 使用定时器延迟检测原理很简单在服务里启动一个定时器每隔 100ms 记录一次当前时间和实际执行时间之间的差值。如果事件循环被阻塞定时器的回调会被推迟执行延迟值会明显变大。setInterval(() { const now Date.now(); const expected now - (now % 100); const delay now - expected; if (delay 50) { console.warn(Event loop delayed by ${delay}ms); } }, 100);把这个代码加入 Express 服务。正常情况不会输出当某个路由触发同步阻塞时控制台会打印警告。这个方法足够简单适合作为基础监控。7.2 使用 Node.js 内置诊断工具Node.js 20 及以上版本可以启用诊断报告和跟踪。当进程卡住时可以通过--trace-event-categories查看事件循环各阶段耗时。启动方式node --trace-event-categories node.perf,node.fs,node.net server.js这将输出大量事件跟踪信息适合在开发环境定位问题不建议直接在生产环境长期开启会产生额外性能开销。7.3 使用 clinic.js 做负载分析clinic.js是一个第三方诊断工具可以生成火焰图和事件循环延迟报告。安装与使用npm install -g clinic clinic doctor -- node server.js然后对服务发起压力请求例如使用autocannonnpm install -g autocannon autocannon -c 50 -d 10 http://localhost:3000/fast测试结束后clinic doctor会将生成的报告保存为 HTML 文件打开后可以看到瓶颈在哪里。如果事件循环延迟曲线很高说明存在阻塞如果 CPU 占用高但事件循环延迟低可能问题在计算密集而非异步结构。7.4 负载测试的基本指标使用 autocannon 观察几个关键数字Req/Bytes每秒处理的请求数和字节数Latency平均延迟、P99 延迟Throughput吞吐量Non 2xx错误响应数在优化前后分别压测数据对比才有意义。不要只看平均延迟更要关注 P99因为队列尾部的请求往往最能反映服务稳定性。7.5 如何降低事件循环压力如果确实检测到事件循环负载过高可以从几个方向优化把 CPU 密集计算移到worker_threads。把耗时任务交给外部队列例如 BullMQ Redis异步消费。用setImmediate把大任务拆分多个小任务让事件循环有空闲处理其他事件。控制单个请求的并发度例如限制数据库查询条数、分页返回。使用 Redis 缓存热点数据减少重复计算和重复 I/O。8. 接口 API 与异步编程实践事件循环不只是概念它直接影响接口设计。在 Express.js 中最常见的异步 API 开发模式有三种回调、Promise、async/await。它们的执行细节不同但都依赖事件循环。8.1 回调模式回调模式是 Node.js 最早的异步写法注意错误优先回调约定第一个参数是 err第二个参数是结果。const fs require(fs); function readConfig(callback) { fs.readFile(./config.json, utf8, (err, data) { if (err) { callback(err); return; } try { const config JSON.parse(data); callback(null, config); } catch (parseErr) { callback(parseErr); } }); } readConfig((err, config) { if (err) { console.error(err); return; } console.log(config); });8.2 Promise 模式用util.promisify或fs.promises将回调封装成 Promise。const fs require(fs).promises; async function readConfig() { const data await fs.readFile(./config.json, utf8); return JSON.parse(data); } readConfig() .then(config { console.log(config); }) .catch(err { console.error(err); });在 Express 路由中建议直接用 async/await 加 try/catch避免回调嵌套。如果想让错误统一处理可以给 async 路由包一层 wrapper。8.3 Express 异步路由的错误捕获Express 4 版本中async 路由的 rejected Promise 不会自动传递给错误中间件。需要手动捕获或者使用 wrapper。const wrapper fn (req, res, next) { fn(req, res, next).catch(next); }; app.get(/async-route, wrapper(async (req, res) { const data await getDataFromDb(); res.json(data); })); // 统一错误中间件 app.use((err, req, res, next) { console.error(err); res.status(500).json({ error: err.message }); });Express 5 已经改进了这一点可以直接捕捉 async 路由的错误。如果项目里还在用 Express 4建议使用上面的 wrapper 方案防止 UnhandledPromiseRejection 导致进程崩溃。8.4 未捕获异常处理在生产环境如果出现未捕获的异常不应该让进程直接挂掉但也不能盲目吞掉错误。可以监听uncaughtException记录日志然后根据业务决定是否退出重启。process.on(uncaughtException, (err) { console.error(uncaughtException:, err); }); process.on(unhandledRejection, (reason) { console.error(unhandledRejection:, reason); });注意uncaughtException发生后进程状态可能不稳定需要尽早重启。在容器或 PM2 环境下配合自动重启策略会更安全。9. 常见问题与排查方法问题现象可能原因排查方式解决方案启动服务报EADDRINUSE端口被占用检查端口占用进程换端口或杀掉占用进程访问接口响应慢CPU 不高事件循环被同步计算阻塞用定时器延迟检测、clinic.js 分析移出 CPU 密集任务到 worker高并发下 P99 延迟很高事件循环排队、GC 频繁压测并观察延迟分布减少同步计算、增加缓存、优化查询定时器回调延迟执行主线程被长任务占用在回调里打印延迟值拆分任务使用 setImmediate进程内存持续增长内存泄漏、句柄未释放使用 -heap 分析排查未清理的定时器、监听器node命令找不到PATH 未配置检查echo $PATH添加 Node 安装目录到 PATHnvm 切换版本无效shell 缓存重新打开终端执行hash -r或重启终端npm install 报错网络问题或镜像源查看 npm 日志更换国内镜像源Express 异步路由错误未被捕获Express 4 不自动捕获观察控制台 warning使用 wrapper 包装 async 路由request 回调中调用同步文件读取主线程阻塞审查代码同步 API改为fs.promises.readFile9.1 端口冲突处理Windows 下查看端口占用netstat -ano | findstr :3000Linux/macOS 下lsof -i :3000找到占用进程后可以换端口启动 ExpressPORT3001 node server.js代码中也可以从环境变量读取端口const PORT process.env.PORT || 3000;这样在部署时就可以灵活调整不会因为端口冲突直接退出。9.2 定时器不执行定时器不执行通常有两个原因一是事件循环被阻塞回调没机会进入执行队列二是定时器设置了非常大的延迟或者进程在定时器触发前就已经退出。检查事件循环是否阻塞最直接的办法就是第 7.1 节的延迟检测方法。9.3 回调地狱的根治回调地狱不是事件循环的问题是代码组织问题。解决思路很简单用Promise替代回调回调。用async/await替代 Promise 链。把复杂流程拆成独立函数。使用Promise.allSettled处理多个并行任务。const [user, posts] await Promise.all([ getUserById(id), getPostsByUserId(id) ]);10. 最佳实践与使用建议事件驱动模型本身不难理解难的是在工程里保持自律。下面这些建议是我认为使用 Node.js 和 Express.js 时最值得长期遵守的请求路径上不要放同步阻塞代码。哪怕是一段看起来很快的 JSON 解析在高峰期也可能成为瓶颈。显式管理异步流程。优先使用async/await配合Promise.all合并并行任务减少无必要的串行等待。给所有异步路由加错误处理。不要让一个 rejected Promise 打崩整个进程。正确选择定时器。需要尽快执行且非 I/O 场景可以参考setImmediate需要延迟任务用setTimeout需要轮询用setInterval但必须处理未捕获错误。用监控代替猜测。定时器延迟检测可以作为最基础的探针最好配合 APM、clinic.js、日志系统一起观察服务健康度。CPU 密集任务交给独立线程。Node.js 提供worker_threads不要在事件循环主线程里做长计算。控制并发与队列长度。任何队列都不是无限增长的要设置超时、重试上限和积压告警。保持 Node.js 版本更新。至少使用 LTS 版本不要停留在已停止维护的版本上。代码评审时专门检查同步 API。搜索readFileSync、writeFileSync、execSync等同步用法确认是否真的有必要。理解并善用事件的顺序。熟练区分nextTick、Promise、setTimeout、setImmediate的执行时机很多隐蔽 bug 都出在这里。11. 总结与下一步这期内容围绕 Node.js 最核心的事件驱动机制展开。Event Loop 不是什么玄学它就是 libuv 驱动的一套固定执行流程先处理定时器再处理 I/O 回调中间穿插微任务最后处理 close 事件。理解了阶段顺序你就能解释为什么process.nextTick比Promise先执行为什么 I/O 回调里setImmediate先于setTimeout为什么同步阻塞会让整个服务卡住。文章给出的所有代码都可以直接复制运行。建议先把4.2的阶段验证代码跑一遍把输出顺序记录一下再跑5.1的 Express 示例对比/fast、/slow、/slow-block的响应情况。这样对事件循环的理解会比只看概念深很多。接下来可以继续探索几个方向用worker_threads处理 CPU 密集任务、用child_process做进程级隔离、用 BullMQ 这类队列工具处理延迟任务、用clinic.js给现有项目做一次性能体检。事件驱动是 Node.js 的根很多高级框架的设计都建立在这套机制之上。这个根扎稳了后面写高性能接口会顺手得多。