Node系列 · Node基础:生命周期与事件循环
Node 进程从
node app.js到退出的每一步都受确定性流程约束。理解启动 → 微任务 → 事件循环 → 退出的完整顺序,能解释为什么setTimeout(fn, 0)不立即执行、为什么process.nextTick比Promise.then优先、为什么 K8s 发 SIGTERM 后 Node 不立刻死。
一、Node 进程生命周期全景
Node 进程从 node app.js 到退出的完整流程:启动 → 同步代码 → 微任务 → 事件循环 → 退出:
二、启动阶段详解
2.1 进程初始化
node app.js 命令执行后,Node 运行时依次:
- 解析命令行参数(
process.argv) - 加载
app.js模块(按 CJS 或 ESM 解析,详见 第 3 章模块化 与 第 4 章 ES 模块化) - 把模块代码包成函数(5 个参数:
exports / require / module / __filename / __dirname) - 执行模块顶层代码(同步部分)
2.2 同步代码执行
console.log('1. 同步开始');
process.on('exit', (code) => {
// 只剩同步操作能跑:异步回调不会再触发
console.log('4. exit 事件');
});
setTimeout(() => console.log('5. timers 回调'), 0);
Promise.resolve().then(() => console.log('3. Promise 微任务'));
console.log('2. 同步结束');$ node startup.js
1. 同步开始
2. 同步结束
3. Promise 微任务
4. exit 事件
5. timers 回调 ← 注意:exit 事件先于 timers 触发2.3 为什么 exit 在 timers 之前?
事件循环还没启动就触发了 exit 事件——因为同步代码注册了 setTimeout(fn, 0),Node 启动事件循环前已经把 timer 排进队列。但事件循环启动后会先清空微任务队列,再进入 timers 阶段。
exit 事件是 Node 同步触发的最后一个钩子,只允许同步操作(不能写文件、不能发请求)。要异步清理资源用 beforeExit。
三、事件循环阶段
事件循环是 Node 异步调度的核心。每次循环都按固定顺序执行 6 个阶段:
3.1 阶段顺序
| 阶段 | 处理内容 |
|---|---|
| timers | setTimeout / setInterval 回调 |
| pending callbacks | 延迟到下轮的系统操作(如 TCP 错误) |
| idle, prepare | Node 内部使用,不暴露给用户 |
| poll | I/O 回调;唯一会阻塞等待新 I/O 的阶段 |
| check | setImmediate 回调 |
| close callbacks | socket.on('close', ...) 等 |
阶段切换时必须先清空微任务队列(process.nextTick + Promise)。
3.2 poll 阶段的细节
poll 阶段是事件循环唯一会主动阻塞等 I/O 的阶段。阻塞条件:
- timers 队列为空
- check 队列为空
- poll 队列为空
满足这三个条件时,poll 阻塞等待新 I/O。任何条件失效就退出阻塞:
- timers 有到期回调 → 进入下一轮 timers
setImmediate注册了新任务 → 进入 check- poll 队列有新回调 → 立即执行
3.3 各阶段回调的来源
| 来源 | 阶段 |
|---|---|
setTimeout(fn, 0) | timers |
fs.readFile 完成 | poll |
net.Socket 接收到 data | poll |
setImmediate(fn) | check |
socket.on('close') | close callbacks |
process.on('exit') | 不经过事件循环,同步触发 |
四、微任务:nextTick vs Promise
Node 的微任务队列有两层:
| 微任务 | 来源 | 优先级 |
|---|---|---|
process.nextTick | Node 私有 | 最高 |
Promise.then / queueMicrotask | V8 标准 | 次高 |
process.nextTick(() => console.log('nextTick 1'));
process.nextTick(() => console.log('nextTick 2'));
Promise.resolve().then(() => console.log('promise 1'));
Promise.resolve().then(() => console.log('promise 2'));
queueMicrotask(() => console.log('microtask'));
console.log('sync');$ node microtask-priority.js
sync
nextTick 1
nextTick 2
promise 1
promise 2
microtask执行规则:
- 同步代码跑完
- 全部 nextTick 回调执行完
- 然后执行 Promise / queueMicrotask 回调
- 进入事件循环阶段
WARNING
不要在 process.nextTick 里递归调用 nextTick——会无限循环,事件循环永远进不去。这是常见 bug。
// ❌ 危险:事件循环饿死
function recursive() {
process.nextTick(recursive);
}
recursive();五、setImmediate vs setTimeout(fn, 0)
| 维度 | setImmediate(fn) | setTimeout(fn, 0) |
|---|---|---|
| 触发阶段 | check | timers |
| 最小延迟 | 0 | 1ms(HTML 规范) |
| 在 I/O 回调内 | check 阶段紧跟 poll,总是先触发 | 要等下一轮 timers,最少 1ms 延迟 |
// 场景 1:主模块顶层调用
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 顺序:不确定!取决于本轮 timers 阶段时 setTimeout 是否已到期
// Node 性能波动时,顺序可能颠倒const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 顺序确定:immediate 先输出
// 因为当前在 poll 阶段,poll 后紧跟 check
});TIP
在 I/O 回调内,setImmediate 总是比 setTimeout(fn, 0) 先触发——这是 Node 的硬性保证。利用这一点可以把"批量更新"放在 setImmediate 里立即执行。
六、退出阶段
Node 进程有四种退出方式:
6.1 正常退出:事件循环无任务
// 同步代码执行完,没有定时器、没有未完成的 I/O、没有 setImmediate
// 事件循环检测到 timers / check / poll 都为空 → 退出
console.log('done');6.2 显式退出:process.exit()
process.exit(0); // 正常退出,code = 0
process.exit(1); // 异常退出,code = 1process.exit() 立即终止,异步任务被丢弃。生产 HTTP 服务不应直接调,应通过 server.close() 让现有请求跑完再退。
6.3 信号退出:SIGTERM / SIGINT
process.on('SIGTERM', () => {
console.log('收到 SIGTERM');
server.close(() => {
console.log('所有连接已关闭');
process.exit(0);
});
});K8s pod 终止流程:
- K8s 发
SIGTERM - pod 进入
terminationGracePeriodSeconds(默认 30s)倒计时 - 应用收到信号后清理(关闭连接、刷写日志)
- 应用调
process.exit(0)或调用server.close()自然退出 - 超时未退出 → K8s 发
SIGKILL强杀
6.4 异常退出:未捕获异常
// 未捕获的同步异常
throw new Error('boom');
// 未处理的 Promise rejection
Promise.reject(new Error('boom'));Node 默认对未处理 rejection 直接 crash(Node 15+)。可以全局捕获:
process.on('uncaughtException', (err) => {
console.error('未捕获异常:', err);
process.exit(1); // 推荐:捕获后退出,让 K8s 重启
});
process.on('unhandledRejection', (reason, promise) => {
console.error('未处理 rejection:', reason);
});DANGER
uncaughtException 处理器只能做紧急记录 + 退出。事件循环可能已处于不一致状态,继续运行会引发更隐蔽的 bug。
七、beforeExit vs exit
| 钩子 | 触发时机 | 是否可异步 |
|---|---|---|
beforeExit | 事件循环为空,准备退出 | ✅ 可以做异步操作 |
exit | 进程即将退出 | ❌ 仅同步操作 |
process.on('beforeExit', async (code) => {
// 异步清理:如刷写日志、关闭数据库连接
await flushLogs();
console.log('beforeExit 完成');
});
process.on('exit', (code) => {
// 同步清理:如输出最终统计
console.log('exit code:', code);
});八、process.exit() 与事件循环
process.exit() 绕过事件循环直接终止进程:
setTimeout(() => console.log('timeout'), 0);
process.on('exit', () => console.log('exit'));
process.on('beforeExit', () => console.log('beforeExit'));
process.exit(0);
// 输出:exit
// 不输出 beforeExit(事件循环被绕过)
// 不输出 timeout(异步回调被丢弃)九、worker_threads 中的事件循环
Worker 线程有自己的事件循环,与主进程独立。但二者通过 MessagePort 通信,消息传递是异步的。
const { Worker, isMainThread, parentPort } = require('node:worker_threads');
if (isMainThread) {
const worker = new Worker(__filename);
worker.on('message', (msg) => console.log('主线程收到:', msg));
worker.postMessage('ping');
} else {
parentPort.on('message', (msg) => {
console.log('worker 收到:', msg);
parentPort.postMessage('pong');
});
}十、常见诊断
| 现象 | 原因 | 排查方向 |
|---|---|---|
| 进程不退出,端口占用 | 有未关闭的定时器 / socket / server | 检查 setInterval、createServer 是否 close |
setTimeout(fn, 0) 不立即执行 | 在 I/O 回调外调用,受 timers 1ms 最小延迟影响 | 改用 setImmediate |
| 微任务队列堆积 | 递归 process.nextTick | 改用 queueMicrotask 或 setImmediate |
unhandledRejection 警告 | Promise 没有 .catch | 加上 try/catch 或 .catch |
MaxListenersExceededWarning | EventEmitter 监听器超过 10 个 | 检查是否重复注册监听器 |
十一、最佳实践
| 场景 | 推荐 |
|---|---|
| 进程退出码 | 成功 0;失败 1;自定义错误码 |
| 优雅退出 | 监听 SIGTERM → server.close() → process.exit(0) |
| 紧急记录 | uncaughtException + 立即 process.exit(1) |
| 微任务顺序敏感时 | 全部用 queueMicrotask(避免 nextTick 抢占) |
| 想"尽快执行"且不阻塞 | setImmediate(不阻塞 poll 阶段) |
| 资源清理 | beforeExit 做异步 + exit 做同步兜底 |
十二、小结
- Node 进程生命周期:启动 → 同步代码 → 微任务 → 事件循环 → 退出
- 事件循环 6 阶段:timers / pending / idle / poll / check / close
poll阶段唯一会阻塞等 I/O,其他阶段都是非阻塞- 微任务优先级:
process.nextTick>Promise.then/queueMicrotask setImmediate在 I/O 回调内总是比setTimeout(fn, 0)先触发- 优雅退出 = 监听 SIGTERM → 关闭 server → 自然退出
uncaughtException只能紧急记录后退出,不能继续运行
