Skip to content

Node系列 · Node基础:生命周期与事件循环

Node 进程从 node app.js 到退出的每一步都受确定性流程约束。理解启动 → 微任务 → 事件循环 → 退出的完整顺序,能解释为什么 setTimeout(fn, 0) 不立即执行、为什么 process.nextTickPromise.then 优先、为什么 K8s 发 SIGTERM 后 Node 不立刻死。

一、Node 进程生命周期全景

Node 进程从 node app.js 到退出的完整流程:启动 → 同步代码 → 微任务 → 事件循环 → 退出:

二、启动阶段详解

2.1 进程初始化

node app.js 命令执行后,Node 运行时依次:

  1. 解析命令行参数(process.argv
  2. 加载 app.js 模块(按 CJS 或 ESM 解析,详见 第 3 章模块化第 4 章 ES 模块化
  3. 把模块代码包成函数(5 个参数:exports / require / module / __filename / __dirname
  4. 执行模块顶层代码(同步部分

2.2 同步代码执行

javascript
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. 同步结束');
bash
$ 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 阶段顺序

阶段处理内容
timerssetTimeout / setInterval 回调
pending callbacks延迟到下轮的系统操作(如 TCP 错误)
idle, prepareNode 内部使用,不暴露给用户
pollI/O 回调;唯一会阻塞等待新 I/O 的阶段
checksetImmediate 回调
close callbackssocket.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 接收到 datapoll
setImmediate(fn)check
socket.on('close')close callbacks
process.on('exit')不经过事件循环,同步触发

四、微任务:nextTick vs Promise

Node 的微任务队列有两层

微任务来源优先级
process.nextTickNode 私有最高
Promise.then / queueMicrotaskV8 标准次高
javascript
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');
bash
$ node microtask-priority.js
sync
nextTick 1
nextTick 2
promise 1
promise 2
microtask

执行规则:

  1. 同步代码跑完
  2. 全部 nextTick 回调执行完
  3. 然后执行 Promise / queueMicrotask 回调
  4. 进入事件循环阶段

WARNING

不要在 process.nextTick 里递归调用 nextTick——会无限循环,事件循环永远进不去。这是常见 bug。

javascript
// ❌ 危险:事件循环饿死
function recursive() {
  process.nextTick(recursive);
}
recursive();

五、setImmediate vs setTimeout(fn, 0)

维度setImmediate(fn)setTimeout(fn, 0)
触发阶段checktimers
最小延迟01ms(HTML 规范)
在 I/O 回调内check 阶段紧跟 poll,总是先触发要等下一轮 timers,最少 1ms 延迟
javascript
// 场景 1:主模块顶层调用
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

// 顺序:不确定!取决于本轮 timers 阶段时 setTimeout 是否已到期
// Node 性能波动时,顺序可能颠倒
javascript
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 正常退出:事件循环无任务

javascript
// 同步代码执行完,没有定时器、没有未完成的 I/O、没有 setImmediate
// 事件循环检测到 timers / check / poll 都为空 → 退出
console.log('done');

6.2 显式退出:process.exit()

javascript
process.exit(0);  // 正常退出,code = 0
process.exit(1);  // 异常退出,code = 1

process.exit() 立即终止,异步任务被丢弃。生产 HTTP 服务不应直接调,应通过 server.close() 让现有请求跑完再退。

6.3 信号退出:SIGTERM / SIGINT

javascript
process.on('SIGTERM', () => {
  console.log('收到 SIGTERM');
  server.close(() => {
    console.log('所有连接已关闭');
    process.exit(0);
  });
});

K8s pod 终止流程:

  1. K8s 发 SIGTERM
  2. pod 进入 terminationGracePeriodSeconds(默认 30s)倒计时
  3. 应用收到信号后清理(关闭连接、刷写日志)
  4. 应用调 process.exit(0) 或调用 server.close() 自然退出
  5. 超时未退出 → K8s 发 SIGKILL 强杀

6.4 异常退出:未捕获异常

javascript
// 未捕获的同步异常
throw new Error('boom');

// 未处理的 Promise rejection
Promise.reject(new Error('boom'));

Node 默认对未处理 rejection 直接 crash(Node 15+)。可以全局捕获:

javascript
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进程即将退出❌ 仅同步操作
javascript
process.on('beforeExit', async (code) => {
  // 异步清理:如刷写日志、关闭数据库连接
  await flushLogs();
  console.log('beforeExit 完成');
});

process.on('exit', (code) => {
  // 同步清理:如输出最终统计
  console.log('exit code:', code);
});

八、process.exit() 与事件循环

process.exit() 绕过事件循环直接终止进程:

javascript
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 通信,消息传递是异步的。

javascript
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检查 setIntervalcreateServer 是否 close
setTimeout(fn, 0) 不立即执行在 I/O 回调外调用,受 timers 1ms 最小延迟影响改用 setImmediate
微任务队列堆积递归 process.nextTick改用 queueMicrotasksetImmediate
unhandledRejection 警告Promise 没有 .catch加上 try/catch.catch
MaxListenersExceededWarningEventEmitter 监听器超过 10 个检查是否重复注册监听器

十一、最佳实践

场景推荐
进程退出码成功 0;失败 1;自定义错误码
优雅退出监听 SIGTERMserver.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 只能紧急记录后退出,不能继续运行