Node.js 事件循环(Event Loop)

什么是事件循环

事件循环(Event Loop) 是 Node.js 的核心机制,它使得 Node.js 能够在单线程环境下高效地处理大量并发操作。尽管 JavaScript 是单线程的,但通过事件循环和非阻塞 I/O,Node.js 可以同时处理成千上万的连接。

为什么需要事件循环?

在传统的多线程服务器模型中,每个请求都会创建一个新线程,这会导致:

  • 资源消耗大:每个线程都需要内存和 CPU 资源
  • 上下文切换开销:线程间的切换会消耗 CPU 时间
  • 并发限制:受限于系统能创建的线程数量

Node.js 通过事件循环解决了这些问题:

  • 单线程执行:JavaScript 代码在单一主线程中运行
  • 非阻塞 I/O:I/O 操作在后台线程池中执行,不阻塞主线程
  • 事件驱动:通过回调函数处理异步操作的结果

事件循环的工作原理

事件循环是一个持续运行的循环,它不断地检查是否有待处理的任务,并按照特定的顺序执行它们。

基本执行流程

sql 复制代码
┌───────────────────────────┐
│   Node.js 启动            │
└───────────┬───────────────┘
            │
            ▼
┌───────────────────────────┐
│   执行同步代码            │
│   (主脚本)                │
└───────────┬───────────────┘
            │
            ▼
┌───────────────────────────┐
│   进入事件循环            │
│   ┌─────────────────────┐ │
│   │ 1. Timers           │ │
│   │ 2. Pending Callbacks│ │
│   │ 3. Idle, Prepare    │ │
│   │ 4. Poll             │ │
│   │ 5. Check            │ │
│   │ 6. Close Callbacks  │ │
│   └─────────────────────┘ │
└───────────┬───────────────┘
            │
            ▼
┌───────────────────────────┐
│   检查是否有待处理任务    │
└───────────┬───────────────┘
            │
      ┌─────┴─────┐
      │           │
     是           否
      │           │
      ▼           ▼
  继续循环    退出程序

关键概念

  1. 调用栈(Call Stack):执行同步代码的地方
  2. 任务队列(Task Queue):存储待执行的异步回调
  3. 微任务队列(Microtask Queue) :存储 Promise 回调(Promise.then/catch/finally
  4. nextTick 队列(NextTick Queue) :存储 process.nextTick 回调(独立于微任务队列,优先级更高)
  5. 事件循环:协调调用栈和任务队列的机制

事件循环的六个阶段

根据 Node.js 官方文档,事件循环包含以下六个阶段:

1. Timers(定时器阶段)

执行由 setTimeout()setInterval() 设置的回调函数。

javascript 复制代码
setTimeout(() => {
  console.log('Timer 1');
}, 0);

setInterval(() => {
  console.log('Interval');
}, 1000);

特点

  • 只执行已经到期的定时器回调
  • 定时器的延迟时间是最小延迟,不是精确延迟
  • 如果事件循环被阻塞,定时器可能会延迟执行

2. Pending Callbacks(待处理的回调阶段)

执行延迟到下一个循环迭代的 I/O 回调。这些回调通常来自系统操作,主要是某些系统级别的错误回调(如 TCP 连接错误)。

javascript 复制代码
const net = require('net');

// TCP 连接错误回调可能在这个阶段执行
const socket = net.createConnection(80, 'invalid-host');
socket.on('error', (err) => {
  // 某些系统错误回调(如 ECONNREFUSED)会在这个阶段执行
  console.error('Connection error:', err);
});

注意 :大部分 I/O 回调(包括 fs.readFile 的成功和错误回调)都在 Poll 阶段执行,而不是这个阶段。Pending Callbacks 阶段主要处理系统级别的错误回调。

3. Idle, Prepare(空闲、准备阶段)

仅供 Node.js 内部使用,通常不涉及用户代码。

4. Poll(轮询阶段)

这是事件循环的核心阶段,负责:

  • 检索新的 I/O 事件
  • 执行与 I/O 相关的回调(除了关闭回调、定时器回调和 setImmediate 回调)
javascript 复制代码
const fs = require('fs');

fs.readFile('data.txt', 'utf8', (err, data) => {
  // 这个回调在 Poll 阶段执行
  console.log('File content:', data);
});

Poll 阶段的工作机制

  1. 如果 Poll 队列不为空,同步执行队列中的回调,直到队列为空或达到系统限制
  2. 如果 Poll 队列为空:
    • 如果有 setImmediate() 回调,进入 Check 阶段
    • 如果没有,等待新的 I/O 事件到达

5. Check(检查阶段)

执行由 setImmediate() 设置的回调函数。

javascript 复制代码
setImmediate(() => {
  console.log('setImmediate callback');
});

setImmediate vs setTimeout

  • setImmediate() 在当前事件循环的 Check 阶段执行
  • setTimeout(fn, 0) 在下一个事件循环的 Timers 阶段执行
  • 在 I/O 回调中,setImmediate 总是先于 setTimeout 执行

6. Close Callbacks(关闭回调阶段)

执行关闭事件的回调,如 socket.on('close', ...)

javascript 复制代码
const server = require('http').createServer();

server.on('close', () => {
  console.log('Server closed');
});

server.close();

事件队列

事件队列是事件循环的核心数据结构,用于存储待执行的异步回调。事件循环通过管理 nextTick 队列、微任务队列和宏任务队列来协调异步操作的执行顺序。详细的队列类型和执行顺序说明请参考下面的"宏任务与微任务"章节。


宏任务与微任务

理解宏任务和微任务的区别对于掌握事件循环至关重要。

宏任务(Macrotasks)

宏任务包括:

  • setTimeout()
  • setInterval()
  • setImmediate()
  • I/O 操作回调

特点

  • 在每个事件循环阶段之间,会先清空 nextTick 队列,然后清空微任务队列
  • 执行完所有微任务后,才进入下一个事件循环阶段

微任务(Microtasks)

微任务包括:

  • Promise.then() / Promise.catch() / Promise.finally()
  • queueMicrotask()

特点

  • 优先级高于宏任务
  • 在每个事件循环阶段结束后执行
  • 会阻塞后续阶段的执行,直到微任务队列清空

注意process.nextTick() 虽然行为类似微任务,但它有自己独立的队列,优先级甚至高于 Promise 微任务。在每个阶段结束后,会先执行所有 process.nextTick 回调,然后才执行 Promise 等微任务。

执行示例

javascript 复制代码
console.log('开始');

// 宏任务
setTimeout(() => {
  console.log('setTimeout 1');
  
  // 微任务(在宏任务内部)
  Promise.resolve().then(() => {
    console.log('Promise 2');
  });
  
  process.nextTick(() => {
    console.log('nextTick 2');
  });
}, 0);

// 微任务
Promise.resolve().then(() => {
  console.log('Promise 1');
});

process.nextTick(() => {
  console.log('nextTick 1');
});

// 宏任务
setTimeout(() => {
  console.log('setTimeout 2');
}, 0);

console.log('结束');

// 输出顺序:
// 开始
// 结束
// nextTick 1
// Promise 1
// setTimeout 1
// nextTick 2
// Promise 2
// setTimeout 2

注意process.nextTick 的优先级甚至高于 Promise,过度使用可能导致事件循环阻塞。详细说明请参考下面的"process.nextTick 与 setImmediate"章节。


process.nextTick 与 setImmediate

这两个 API 经常被混淆,但它们有本质区别:

process.nextTick

  • 执行时机:在当前操作完成后、进入下一个事件循环阶段之前立即执行(不是事件循环的一部分)
  • 优先级:最高,甚至高于 Promise 微任务
  • 用途:确保回调在当前操作完成后立即执行,用于保证 API 的异步性
javascript 复制代码
console.log('开始');

setImmediate(() => {
  console.log('setImmediate');
});

process.nextTick(() => {
  console.log('nextTick');
});

console.log('结束');

// 输出:
// 开始
// 结束
// nextTick
// setImmediate

setImmediate

  • 执行时机:在当前事件循环的 Check 阶段执行
  • 优先级 :低于 process.nextTick 和 Promise
  • 用途:在当前事件循环结束后执行回调

何时使用哪个?

javascript 复制代码
// 使用 process.nextTick:需要立即执行,不阻塞 I/O
function asyncOperation(data, callback) {
  if (data) {
    process.nextTick(() => {
      callback(null, data);
    });
  } else {
    process.nextTick(() => {
      callback(new Error('No data'));
    });
  }
}

// 使用 setImmediate:在 I/O 操作后执行
const fs = require('fs');

fs.readFile('file.txt', () => {
  setImmediate(() => {
    console.log('在 I/O 回调后执行');
  });
  
  setTimeout(() => {
    console.log('在下一个事件循环执行');
  }, 0);
});

事件循环优化策略

优化事件循环是提升 Node.js 应用性能的关键。

1. 避免阻塞主线程

问题代码

javascript 复制代码
// ❌ 阻塞事件循环
// 假设已有 Express 应用:const app = require('express')();

function heavyComputation() {
  let result = 0;
  for (let i = 0; i < 10000000000; i++) {
    result += i;
  }
  return result;
}

app.get('/compute', (req, res) => {
  const result = heavyComputation(); // 阻塞所有请求
  res.json({ result });
});

优化方案

javascript 复制代码
// ✅ 使用 Worker Threads
const { Worker } = require('worker_threads');
const { join } = require('path');

function heavyComputationAsync() {
  return new Promise((resolve, reject) => {
    // 使用独立的 worker 文件(推荐方式)
    const worker = new Worker(join(__dirname, 'worker.js'));
    
    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0) {
        reject(new Error(`Worker stopped with exit code ${code}`));
      }
    });
  });
}

app.get('/compute', async (req, res) => {
  const result = await heavyComputationAsync();
  res.json({ result });
});

worker.js

javascript 复制代码
const { parentPort } = require('worker_threads');

let result = 0;
for (let i = 0; i < 10000000000; i++) {
  result += i;
}

parentPort.postMessage(result);

2. 合理使用异步 API

问题代码

javascript 复制代码
// ❌ 同步 I/O 阻塞事件循环
// 假设已有 Express 应用:const app = require('express')();
const fs = require('fs');

app.get('/file', (req, res) => {
  const data = fs.readFileSync('large-file.txt'); // 阻塞
  res.send(data);
});

优化方案

javascript 复制代码
// ✅ 使用异步 I/O
const fs = require('fs').promises;

app.get('/file', async (req, res) => {
  const data = await fs.readFile('large-file.txt');
  res.send(data);
});

3. 控制微任务数量

问题代码

javascript 复制代码
// ❌ 创建大量微任务
function recursivePromise(count) {
  if (count <= 0) return Promise.resolve();
  
  return Promise.resolve().then(() => {
    return recursivePromise(count - 1); // 可能阻塞事件循环
  });
}

优化方案

javascript 复制代码
// ✅ 使用 setImmediate 拆分任务
function recursivePromise(count) {
  if (count <= 0) return Promise.resolve();
  
  return new Promise((resolve) => {
    setImmediate(() => {
      recursivePromise(count - 1).then(resolve);
    });
  });
}

4. 避免过度使用 process.nextTick

问题代码

javascript 复制代码
// ❌ 递归调用 process.nextTick
function recursiveNextTick(count) {
  if (count <= 0) return;
  
  process.nextTick(() => {
    recursiveNextTick(count - 1); // 可能导致事件循环阻塞
  });
}

优化方案

javascript 复制代码
// ✅ 使用 setImmediate 或拆分任务
function recursiveNextTick(count) {
  if (count <= 0) return;
  
  setImmediate(() => {
    recursiveNextTick(count - 1);
  });
}

5. 使用流处理大文件

问题代码

javascript 复制代码
// ❌ 一次性读取大文件到内存
// 假设已有 Express 应用:const app = require('express')();
const fs = require('fs');

app.get('/large-file', (req, res) => {
  const data = fs.readFileSync('huge-file.txt'); // 可能耗尽内存
  res.send(data);
});

优化方案

javascript 复制代码
// ✅ 使用流处理
const fs = require('fs');

app.get('/large-file', (req, res) => {
  const stream = fs.createReadStream('huge-file.txt');
  stream.pipe(res); // 流式传输,不占用大量内存
});

6. 监控事件循环延迟

javascript 复制代码
// 监控事件循环延迟
const { performance, PerformanceObserver } = require('perf_hooks');

const obs = new PerformanceObserver((items) => {
  const entry = items.getEntries()[0];
  console.log(`Event Loop Delay: ${entry.duration}ms`);
  
  // 如果延迟超过阈值,发出警告
  if (entry.duration > 100) {
    console.warn('Event loop is blocked!');
  }
});

obs.observe({ entryTypes: ['measure'] });

// 初始化 start mark
performance.mark('start');

// 定期测量事件循环延迟
setInterval(() => {
  setImmediate(() => {
    performance.mark('end');
    performance.measure('event-loop-delay', 'start', 'end');
    performance.mark('start'); // 为下一次测量做准备
  });
}, 1000);

7. 使用集群模式处理 CPU 密集型任务

javascript 复制代码
const cluster = require('cluster');
const numCPUs = require('os').cpus().length;

// Node.js 16+ 使用 cluster.isPrimary,旧版本使用 cluster.isMaster
if (cluster.isPrimary) {
  // 主进程:创建工作进程
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
  
  cluster.on('exit', (worker) => {
    console.log(`Worker ${worker.process.pid} died`);
    cluster.fork(); // 重启工作进程
  });
} else {
  // 工作进程:运行应用
  require('./app');
}

实际案例分析

案例 1:I/O 回调中的执行顺序

javascript 复制代码
const fs = require('fs');

fs.readFile(__filename, () => {
  console.log('1. I/O 回调');
  
  setTimeout(() => {
    console.log('2. setTimeout');
  }, 0);
  
  setImmediate(() => {
    console.log('3. setImmediate');
  });
  
  Promise.resolve().then(() => {
    console.log('4. Promise');
  });
  
  process.nextTick(() => {
    console.log('5. nextTick');
  });
});

console.log('6. 同步代码');

// 输出顺序:
// 6. 同步代码
// 1. I/O 回调
// 5. nextTick
// 4. Promise
// 3. setImmediate
// 2. setTimeout

注意 :在 I/O 回调中,setImmediate 总是先于 setTimeout 执行。这是因为 I/O 回调在 Poll 阶段执行,执行完毕后会进入 Check 阶段(执行 setImmediate),然后才进入下一个循环的 Timers 阶段(执行 setTimeout)。


总结

核心要点

  1. 事件循环是 Node.js 的核心:它使得单线程能够高效处理并发操作

  2. 六个阶段的执行顺序

    • Timers → Pending Callbacks → Idle/Prepare → Poll → Check → Close Callbacks
  3. 任务优先级(从高到低):

    • nextTick 队列 (独立队列,不是事件循环的一部分):
      1. process.nextTick() - 优先级最高,在当前操作完成后、进入下一个事件循环阶段之前立即执行
    • 微任务(Microtasks) : 2. Promise.then() / Promise.catch() / Promise.finally() - Promise 回调 3. queueMicrotask() - 标准微任务 API
    • 宏任务(Macrotasks) : 4. setImmediate() - 在当前事件循环的 Check 阶段执行 5. setTimeout() / setInterval() - 在 Timers 阶段执行 6. I/O 操作回调(文件系统、网络操作等)- 在 Poll 阶段执行 7. Close Callbacks(关闭回调,如 socket.on('close'))- 在 Close Callbacks 阶段执行
  4. 优化原则

    • 永远不要阻塞事件循环
    • 优先使用异步 API
    • 合理控制微任务数量
    • 使用流处理大文件
    • 监控事件循环延迟

最佳实践

  1. 使用异步 I/O:避免同步文件操作和网络请求
  2. 拆分大任务:将 CPU 密集型任务拆分为小块
  3. 使用 Worker Threads:处理 CPU 密集型任务
  4. 使用流:处理大文件和数据流
  5. 监控性能:定期检查事件循环延迟
  6. 避免阻塞操作:不要在回调中执行耗时计算
  7. 避免过度使用 process.nextTick:可能导致事件循环阻塞
  8. 避免同步 API:除非绝对必要

参考资源

相关推荐
北慕阳2 小时前
背诵-----------------------------
java·服务器·前端
JS_GGbond2 小时前
Vue3 组件入门:像搭乐高一样玩转前端!
前端·vue.js
SakuraOnTheWay2 小时前
拆解一个由 setTimeout 引发的“页面假死”悬案
前端·javascript
渔_2 小时前
【已解决】uni-textarea 无法绑定 v-model / 数据不回显?换原生 textarea 一招搞定!
前端
小胖霞2 小时前
vite+ts+monorepo从0搭建vue3组件库(二):项目搭建
前端·vue.js·前端工程化
JS_GGbond2 小时前
Vue中级冒险:3-4周成为组件沟通大师 🚀
前端·vue.js
登山者2 小时前
npm发布报错急救手册:快速解决2FA与令牌问题
前端·npm
小小善后师2 小时前
按钮太多了?基于ResizeObserver优雅显示
前端
HIT_Weston2 小时前
57、【Ubuntu】【Gitlab】拉出内网 Web 服务:Gitlab 配置审视(一)
前端·ubuntu·gitlab