返回首页

Node.js 事件循环机制详解

1. 问题解析

事件循环(Event Loop)是 Node.js 处理非阻塞 I/O 操作的核心机制。与浏览器的事件循环基于 HTML5 规范不同,Node.js 的事件循环基于 libuv 库实现,具有独特的阶段划分和执行顺序。深入理解事件循环对于编写高性能、避免竞态条件的 Node.js 代码至关重要。

2. 核心概念

2.1 libuv 简介

libuv 是一个跨平台的异步 I/O 库,为 Node.js 提供:

  • 事件循环
  • 异步文件 I/O
  • 异步 TCP/UDP socket
  • 子进程管理
  • 线程池(用于文件系统和 DNS 操作)

2.2 事件循环的 6 个阶段

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     I/O callbacks         │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     idle, prepare         │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘
阶段 说明
timers 执行 setTimeoutsetInterval 的回调
I/O callbacks 执行延迟到下一个循环迭代的 I/O 回调
idle, prepare 内部使用,开发者很少直接使用
poll 获取新的 I/O 事件,执行 I/O 回调
check 执行 setImmediate 的回调
close callbacks 执行 socket.on('close', ...) 等关闭回调

2.3 宏任务与微任务

  • 宏任务(Macrotasks):timers、I/O callbacks、check、close callbacks
  • 微任务(Microtasks)
    • process.nextTick(严格来说不属于事件循环,优先级最高)
    • Promise.then/catch/finally
    • queueMicrotask

3. 详细解答

3.1 各阶段详解

// timers 阶段
setTimeout(() => {
  console.log('setTimeout');
}, 0);

// I/O callbacks 阶段
const fs = require('fs');
fs.readFile(__filename, () => {
  console.log('I/O callback');
});

// check 阶段
setImmediate(() => {
  console.log('setImmediate');
});

// 同步代码
console.log('sync');

// 输出顺序:
// sync
// setTimeout(timers 阶段)
// setImmediate(check 阶段)
// I/O callback(poll 阶段后如果有 I/O)

3.2 process.nextTick

// process.nextTick 不属于任何阶段,在当前操作完成后立即执行
console.log('1');

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

Promise.resolve().then(() => {
  console.log('3');
});

setTimeout(() => {
  console.log('4');
}, 0);

console.log('5');

// 输出:1, 5, 2, 3, 4
// 解释:
// 1. 同步代码:1, 5
// 2. nextTick 队列:2
// 3. 微任务队列:3
// 4. 事件循环 timers 阶段:4

3.3 nextTick 的递归风险

// 危险:递归 nextTick 会阻塞事件循环
function recursiveNextTick() {
  process.nextTick(() => {
    console.log('tick');
    recursiveNextTick(); // 递归调用
  });
}

// 这会完全阻塞事件循环,timers 和 I/O 永远不会执行
// recursiveNextTick();

// 安全的做法:使用 setImmediate
function safeRecursive() {
  setImmediate(() => {
    console.log('immediate');
    safeRecursive();
  });
}
// safeRecursive(); // 允许事件循环继续

3.4 setTimeout vs setImmediate

// 在主模块中,执行顺序不确定
setTimeout(() => {
  console.log('timeout');
}, 0);

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

// 可能输出 timeout -> immediate,也可能相反
// 原因:取决于进程启动到进入事件循环的时间
// 在 I/O 回调中,setImmediate 总是先于 setTimeout
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);

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

// 总是输出:immediate -> timeout
// 原因:I/O 回调在 poll 阶段执行,
// check 阶段(setImmediate)在 timers 阶段之前

3.5 Promise 微任务执行时机

// Promise 微任务在 nextTick 之后,每个阶段之前执行
console.log('script start');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(() => {
  console.log('promise1');
}).then(() => {
  console.log('promise2');
});

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

console.log('script end');

// 输出:
// script start
// script end
// nextTick
// promise1
// promise2
// setTimeout

4. 深入理解

4.1 事件循环的完整执行流程

// 综合示例展示完整的事件循环流程
const fs = require('fs');

console.log('1. 同步代码开始');

// Timers 阶段
setTimeout(() => {
  console.log('2. setTimeout 0ms');
}, 0);

setTimeout(() => {
  console.log('3. setTimeout 100ms');
}, 100);

// Check 阶段
setImmediate(() => {
  console.log('4. setImmediate');
});

// I/O 操作
fs.readFile(__filename, () => {
  console.log('5. I/O callback (poll 阶段)');

  // I/O 回调中的定时器
  setTimeout(() => {
    console.log('6. I/O 中的 setTimeout 0ms');
  }, 0);

  setImmediate(() => {
    console.log('7. I/O 中的 setImmediate');
  });

  process.nextTick(() => {
    console.log('8. I/O 中的 nextTick');
  });
});

// nextTick
process.nextTick(() => {
  console.log('9. nextTick 1');
  process.nextTick(() => {
    console.log('10. nextTick 2');
  });
});

// Promise
Promise.resolve().then(() => {
  console.log('11. Promise 1');
  Promise.resolve().then(() => {
    console.log('12. Promise 2');
  });
});

console.log('13. 同步代码结束');

// 典型输出顺序:
// 1. 同步代码开始
// 13. 同步代码结束
// 9. nextTick 1
// 10. nextTick 2
// 11. Promise 1
// 12. Promise 2
// 2. setTimeout 0ms
// 4. setImmediate
// 5. I/O callback (poll 阶段)
// 8. I/O 中的 nextTick
// 7. I/O 中的 setImmediate
// 6. I/O 中的 setTimeout 0ms
// 3. setTimeout 100ms

4.2 线程池与事件循环

// 某些操作使用 libuv 线程池,完成后回调进入事件循环
const crypto = require('crypto');

console.time('pbkdf2');

// pbkdf2 使用线程池
for (let i = 0; i < 4; i++) {
  crypto.pbkdf2('password', 'salt', 100000, 64, 'sha512', () => {
    console.log(`pbkdf2 ${i} done`);
  });
}

console.timeEnd('pbkdf2'); // 几乎立即打印

// 4 个 pbkdf2 操作并行在 libuv 线程池执行
// 完成后回调进入 poll 阶段

4.3 事件循环的 uv_run 模式

// Node.js 使用 UV_RUN_ONCE 模式运行事件循环
// 这意味着每次事件循环迭代后,Node.js 会检查是否还有工作要做

const http = require('http');

const server = http.createServer((req, res) => {
  res.end('Hello');
});

server.listen(3000);

// 只要还有活跃的 handles(如服务器监听)或 requests,
// 事件循环就会继续运行

5. 最佳实践

5.1 合理使用 nextTick

// 场景 1:确保在事件触发前执行
const EventEmitter = require('events');

class MyEmitter extends EventEmitter {
  constructor() {
    super();

    // 在构造函数中触发事件
    // 但此时子类还未完成初始化
    // 使用 nextTick 延迟到当前操作完成后
    process.nextTick(() => {
      this.emit('ready');
    });
  }
}

const emitter = new MyEmitter();
emitter.on('ready', () => {
  console.log('Ready!');
});
// 场景 2:错误处理时清理资源
function processData(data, callback) {
  let resource;

  try {
    resource = acquireResource();
    const result = doSomething(data);

    // 使用 nextTick 确保回调异步执行
    process.nextTick(() => {
      callback(null, result);
    });
  } catch (err) {
    // 同样异步执行错误回调
    process.nextTick(() => {
      if (resource) releaseResource(resource);
      callback(err);
    });
  }
}

5.2 避免阻塞事件循环

// 不好的做法:同步计算阻塞事件循环
app.get('/slow', (req, res) => {
  let sum = 0;
  for (let i = 0; i < 1e10; i++) {
    sum += i;
  }
  res.json({ sum });
});

// 好的做法 1:使用 setImmediate 分片执行
function calculateSum(n, callback) {
  let sum = 0;
  let i = 0;

  function chunk() {
    const end = Math.min(i + 1e6, n);
    for (; i < end; i++) {
      sum += i;
    }

    if (i < n) {
      setImmediate(chunk); // 让出事件循环
    } else {
      callback(sum);
    }
  }

  chunk();
}

// 好的做法 2:使用 worker_threads
const { Worker } = require('worker_threads');

function calculateInWorker(n) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./sum-worker.js', {
      workerData: n
    });
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

5.3 正确处理异步初始化

// 数据库连接等异步初始化
class Database {
  constructor() {
    this.connected = false;
    this.queue = [];
  }

  async connect() {
    // 模拟连接
    await new Promise(resolve => setTimeout(resolve, 100));
    this.connected = true;

    // 处理队列中的请求
    this.queue.forEach(({ sql, resolve }) => {
      this.execute(sql).then(resolve);
    });
    this.queue = [];
  }

  query(sql) {
    if (!this.connected) {
      // 未连接时加入队列
      return new Promise(resolve => {
        this.queue.push({ sql, resolve });
      });
    }
    return this.execute(sql);
  }

  async execute(sql) {
    // 执行查询
    return { result: 'data' };
  }
}

// 使用
const db = new Database();
db.connect();

// 立即查询会被排队,连接成功后执行
db.query('SELECT * FROM users').then(console.log);

6. 面试要点

  1. Node.js 与浏览器事件循环的区别

    • Node.js 基于 libuv,有 6 个明确的阶段
    • 浏览器基于 HTML5 规范,主要有 task 和 microtask
    • Node.js 有 process.nextTicksetImmediate,浏览器没有
    • Node.js 的宏任务分阶段执行,浏览器按任务队列顺序
  2. setTimeout vs setImmediate 的执行顺序

    • 在主模块中:不确定,取决于系统性能
    • 在 I/O 回调中:setImmediate 先于 setTimeout(0)
    • 原因:I/O 回调在 poll 阶段执行,check 阶段在 timers 阶段之前
  3. process.nextTick 的特殊性

    • 不属于事件循环的任何阶段
    • 优先级高于 Promise 微任务
    • 在当前操作完成后立即执行
    • 递归使用会阻塞事件循环
  4. 微任务的执行时机

    • process.nextTick:当前操作后,进入事件循环前
    • Promise.then:每个事件循环阶段结束后
    • 执行顺序:nextTick 队列 -> Promise 微任务队列
  5. poll 阶段的特性

    • 如果 poll 队列不为空,遍历执行回调
    • 如果 poll 队列为空:
      • 有 setImmediate:进入 check 阶段
      • 无 setImmediate:等待新的 I/O 事件
    • timers 的阈值检查也在 poll 阶段进行
  6. 实际应用注意事项

    • 避免在 nextTick 中递归调用
    • CPU 密集型任务使用 worker_threads 或分片执行
    • I/O 回调中优先使用 setImmediate 而非 setTimeout(0)
    • 理解事件循环有助于调试异步代码的执行顺序问题