文章

Node.js事件循环深度解析

Node.js事件循环深度解析

一句话概括

Node.js 事件循环是基于 libuv 实现的异步 I/O 调度机制,它将 JavaScript 的单线程与底层 C++ 线程池结合,使得非阻塞 I/O 操作成为可能,本文详细剖析事件循环的六大阶段、各阶段的执行顺序以及与浏览器事件循环的核心差异。

背景与意义

「JavaScript 是单线程的」这句话每个前端开发者都耳熟能详。但 Node.js 却用它构建出了高性能的后端服务——每年处理数十亿请求的 npm 下载服务、每秒处理数万连接的实时聊天应用……单线程如何做到高并发?

答案就是事件循环(Event Loop)。它就像一个高度自律的调度官,严格按时间片和优先级把各种任务安排到单线程上执行,让 I/O 操作「等待不阻塞」。理解事件循环,是 Node.js 后端开发从「会用」到「精通」的分水岭。

为什么需要事件循环?

传统的多线程 I/O 模型(如 Apache 的每个请求一个线程)在面对大量并发连接时,线程的创建、切换和销毁会带来巨大的开销。Node.js 采用事件驱动 + 异步 I/O 的模型,用单线程配合事件循环来处理成千上万的并发连接,内存开销极低。

概念与定义

事件循环(Event Loop):一个持续运行的循环,负责从事件队列中取出回调函数并执行。它是 Node.js 异步行为的底层调度器。

libuv:一个专注于异步 I/O 的 C 语言库。Node.js 将其作为底层抽象层,提供事件循环、文件系统操作、网络 I/O、线程池等功能。

阶段(Phase):事件循环中的每个周期由多个阶段组成,每个阶段负责处理特定类型的回调。

微任务(Microtask/Microtask):在 ECMAScript 规范中定义,包括 Promise.then/catch/finally、queueMicrotask、process.nextTick(Node.js 特有)。微任务在当前阶段结束后立即执行。

核心知识点拆解

1. 事件循环的六大阶段

Node.js 的事件循环在 libuv 的实现中分为六个阶段,每个阶段维护各自的回调队列。一个完整的循环周期如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
   ┌───────────────────────────┐
┌─>│         timers            │  ← setTimeout/setInterval 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │    pending callbacks      │  ← 上一轮延迟的 I/O 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     idle, prepare         │  ← 内部使用
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │          poll             │  ← 轮询 I/O 事件,执行 I/O 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │        check              │  ← setImmediate 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     close callbacks       │  ← socket.on('close') 等
│  └───────────────────────────┘
│             └────────────────────────────────┘

timers 阶段:执行 setTimeoutsetInterval 的回调。Timer 的精度受轮询阶段影响,可能存在延迟。

pending callbacks 阶段:执行某些系统操作的回调,如 TCP 连接错误回调。大部分 I/O 回调在 poll 阶段处理。

idle, prepare 阶段:libuv 内部使用,开发者无需关注。

poll 阶段最重要的阶段。负责轮询新的 I/O 事件,执行 I/O 相关回调(文件读写、网络请求等)。如果 poll 队列为空,会检查是否有 setImmediate 回调或定时器到期。

check 阶段setImmediate() 回调在此执行。

close callbacks 阶段:执行关闭回调,如 socket.destroy()close 事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 演示事件循环各阶段的执行顺序
const fs = require('fs');

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

// poll 阶段(I/O 回调)
fs.readFile(__filename, () => {
  console.log('2. fs.readFile (poll 阶段)');
});

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

// 微任务 - process.nextTick 在各个阶段之间执行
process.nextTick(() => {
  console.log('4. process.nextTick (微任务)');
});

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

console.log('0. 同步代码 (主线程)');

// 预期输出顺序:
// 0. 同步代码 (主线程)
// 4. process.nextTick (微任务)
// 5. Promise.then (微任务)
// 1. setTimeout (timers 阶段)    ← 与 setImmediate 的顺序可能因启动时间而异
// 3. setImmediate (check 阶段)
// 2. fs.readFile (poll 阶段)

2. nextTick 与 Promise 的微任务机制

process.nextTick 是 Node.js 独有的机制,不属于事件循环的任何一个阶段,而是在每个阶段切换时执行。它的优先级甚至高于 Promise.then。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
 * 演示 process.nextTick 与 Promise.then 的优先级关系
 * 结论:nextTick 先于 Promise.then 执行
 */
function demonstrateMicrotasks() {
  // 第 1 轮
  process.nextTick(() => {
    console.log('nextTick 1');
    // 在第 1 个 nextTick 内又注册了第 3 个
    process.nextTick(() => console.log('nextTick 3'));
  });

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

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

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

  // 下一轮 - 这些会在所有微任务完成后执行
  setTimeout(() => console.log('setTimeout'), 0);
  setImmediate(() => console.log('setImmediate'));
}

demonstrateMicrotasks();
// 输出顺序:
// nextTick 1
// nextTick 2
// nextTick 3
// Promise 1
// Promise 2
// Promise 3
// setTimeout (可能先)
// setImmediate (或这个先)

记住process.nextTick 回调会形成一个队列,同一次阶段切换中,所有 nextTick 回调(包括递归注册的新 nextTick)会持续执行直到队列清空。因此递归 nextTick 可能导致 I/O 回调「饿死」。

3. setTimeout(fn, 0) 与 setImmediate 的执行顺序迷思

这是 Node.js 面试中高频出现的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
 * 场景一:在全局作用域中
 * 输出不确定!setTimeout(fn, 0) 和 setImmediate 的顺序取决于系统性能
 */
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));

/**
 * 场景二:在 I/O 回调中
 * setImmediate 一定先于 setTimeout 执行!
 */
const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('setTimeout in I/O'), 0);
  setImmediate(() => console.log('setImmediate in I/O'));
  // 输出:setImmediate in I/O → setTimeout in I/O
});

原因分析

  • 在 I/O 回调中执行时,当前阶段是 poll 阶段
  • 接下来事件循环进入 check 阶段,执行所有 setImmediate 回调
  • 然后才进入下一轮的 timers 阶段,执行 setTimeout 回调
  • 因此 I/O 回调内的 setImmediate 总是先于 setTimeout

在全局作用域中:

  • 如果是主模块启动后立即执行,事件循环刚启动进入 timers 阶段
  • setTimeout(fn, 0) 的延迟是 1ms(浏览器和 Node.js 的最小延迟)
  • 如果 timer 已经到期,setTimeout 先;如果 timer 还没到期,setImmediate 先
  • 结果取决于启动性能,所以不确定

4. poll 阶段的阻塞机制

poll 阶段的行为是事件循环最微妙的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const fs = require('fs');

console.time('timer');
// 启动一个异步 I/O,耗时 50ms
fs.readFile('/large-file.dat', () => {
  console.timeEnd('timer'); // 约 50ms
  console.log('I/O 回调执行');
});

// 在 poll 阶段,事件循环发现 poll 队列为空
// 它会检查是否有到期的定时器:
//   有 → 跳到 timers 阶段
//   没有 → 等待 I/O 事件,但受以下因素限制:
//     1. 最近的定时器截止时间
//     2. 如果有 setImmediate,立即跳转到 check 阶段
//
// 这里 setTimeout 设置了 20ms
setTimeout(() => {
  console.log('setTimeout 到期');
  // 读取大文件需要 50ms,但 setTimeout 20ms 后就到期了
  // 所以即使 poll 还在等 I/O,事件循环也会中断等待先去执行 timer
}, 20);

实战案例

实现一个简单的非阻塞调度器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
/**
 * 基于事件循环实现的任务调度器
 * 演示事件循环各阶段的协作
 */
class EventLoopScheduler {
  constructor() {
    this.microTaskQueue = [];
    this.timerQueue = [];
    this.ioQueue = [];
    this.checkQueue = [];
    this.running = false;
  }

  // 模拟微任务(process.nextTick / Promise)
  scheduleMicrotask(callback) {
    this.microTaskQueue.push(callback);
    this.run();
  }

  // 模拟定时器
  scheduleTimer(callback, delayMs) {
    this.timerQueue.push({
      callback,
      deadline: Date.now() + delayMs,
    });
    this.run();
  }

  // 模拟 I/O 操作
  scheduleIO(callback, delayMs) {
    setTimeout(() => {
      this.ioQueue.push(callback);
      this.run();
    }, delayMs);
  }

  // 模拟 setImmediate
  scheduleImmediate(callback) {
    this.checkQueue.push(callback);
    this.run();
  }

  run() {
    if (this.running) return;
    this.running = true;

    // 模拟事件循环主循环
    const maxLoops = 3;
    for (let loop = 1; loop <= maxLoops; loop++) {
      console.log(`\n=== Event Loop 第 ${loop} 轮 ===`);
      
      // 1. timers 阶段
      const now = Date.now();
      const expiredTimers = this.timerQueue.filter(t => t.deadline <= now);
      this.timerQueue = this.timerQueue.filter(t => t.deadline > now);
      for (const timer of expiredTimers) {
        timer.callback();
        this.flushMicrotasks();
      }

      // 2. I/O callbacks (简化为 poll)
      while (this.ioQueue.length > 0) {
        const callback = this.ioQueue.shift();
        callback();
        this.flushMicrotasks();
      }

      // 3. check 阶段
      while (this.checkQueue.length > 0) {
        const callback = this.checkQueue.shift();
        callback();
        this.flushMicrotasks();
      }
    }

    this.running = false;
  }

  flushMicrotasks() {
    while (this.microTaskQueue.length > 0) {
      const callback = this.microTaskQueue.shift();
      callback();
    }
  }
}

// 使用示例
const scheduler = new EventLoopScheduler();
scheduler.scheduleTimer(() => console.log('Timer 1'), 100);
scheduler.scheduleImmediate(() => console.log('Immediate 1'));
scheduler.scheduleMicrotask(() => console.log('Microtask 1'));

实战:用事件循环分析性能问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
 * 阻塞事件循环的典型陷阱
 */
function eventLoopBlocker() {
  // 陷阱 1:同步 CPU 密集型操作阻塞事件循环
  function heavyComputation() {
    let sum = 0;
    for (let i = 0; i < 1e9; i++) {
      sum += Math.sqrt(i);
    }
    return sum;
  }

  console.log('开始耗时计算...');
  setTimeout(() => console.log('这个 setTimeout 会被严重延迟!'), 0);
  
  const result = heavyComputation(); // 阻塞事件循环 ~500ms
  console.log('计算完成:', result);
  // setTimeout 回调在 heavyComputation 完成后才执行

  // 陷阱 2:递归 nextTick 饿死 I/O
  function recursiveNextTick() {
    process.nextTick(() => {
      console.log('不断排队的 nextTick');
      recursiveNextTick(); // 递归注册,永不停止
    });
  }
  // recursiveNextTick(); // ⚠️ 别取消注释,会饿死事件循环

  // 正确做法:将 CPU 密集型任务拆分或使用 Worker Threads
  function nonBlockingComputation() {
    const chunkSize = 1000000;
    let sum = 0;
    let i = 0;

    function processChunk() {
      const end = Math.min(i + chunkSize, 1e9);
      for (; i < end; i++) {
        sum += Math.sqrt(i);
      }
      if (i < 1e9) {
        setImmediate(processChunk); // 让出事件循环
      } else {
        console.log('非阻塞计算完成:', sum);
      }
    }

    setImmediate(processChunk);
  }
}

底层原理

libuv 的事件循环源码简析

libuv 的事件循环核心实现在 src/unix/core.cuv_run() 函数中。简化后的伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// libuv 事件循环核心(伪代码)
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  while (UV_RUN_ONCE || UV_RUN_DEFAULT) {
    // 1. 更新当前时间(缓存时间,减少 gettimeofday 调用)
    uv_update_time(loop);
    
    // 2. timers 阶段:遍历 timer 堆,执行到期回调
    uv__run_timers(loop);
    
    // 3. pending callbacks:执行上轮延迟的回调
    uv__run_pending(loop);
    
    // 4. idle/prepare:内部回调
    uv__run_idle(loop);
    uv__run_prepare(loop);
    
    // 5. poll 阶段:核心 I/O 事件轮询
    //    timeout 计算规则:
    //    - 有 pending 回调 → timeout = 0(不等待)
    //    - 有即将到期的 timer → timeout = 剩余时间
    //    - setImmediate 等待中 → timeout = 0
    //    - 无任何待办 → timeout = -1(无限等待)
    timeout = uv__next_timeout(loop);
    uv__io_poll(loop, timeout);
    
    // 6. check 阶段:执行 setImmediate
    uv__run_check(loop);
    
    // 7. close callbacks
    uv__run_closing_handles(loop);
  }
}

关键要点:

  • 单线程中的并发:事件循环本身运行在主线程上,但 I/O 操作由 libuv 的线程池(默认 4 个线程)执行
  • 非阻塞的本质:I/O 调用立即返回,内核或线程池在后台处理,完成后通过事件通知事件循环
  • timeout 计算策略:poll 阶段的阻塞时间由最近的定时器决定,这是确保 setTimeout 精度的关键

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
 * 关键区别演示
 */

// 区别 1:Node.js 有 process.nextTick,浏览器没有
// 区别 2:Node.js 有 setImmediate,浏览器没有对应的 API
// 区别 3:Node.js 的微任务在每个阶段之间执行,浏览器在整个宏任务之后执行

// Node.js 中的行为:
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));
setTimeout(() => console.log('setTimeout'), 0);
// Node.js 输出:nextTick → Promise → setTimeout

// 浏览器中的行为:
Promise.resolve().then(() => console.log('Promise'));
setTimeout(() => console.log('setTimeout'), 0);
// 浏览器输出:Promise → setTimeout
// 注意:nextTick 不存在

核心差异表

特性Node.js浏览器
微任务执行时机每个阶段切换时每个宏任务结束后
process.nextTick✅ 有,优先级 > Promise❌ 无
setImmediate✅ 有❌ 无(类似的有 postMessage)
微任务队列数至少 2 个(nextTick + 其他)1 个
主线程JavaScript + libuv 线程池JavaScript + Web API
渲染时机无渲染阶段有渲染阶段(requestAnimationFrame)

高频面试题解析

面试题1:这段代码的输出顺序是什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
async function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise(resolve => {
  console.log('promise1');
  resolve();
}).then(() => console.log('promise2'));
process.nextTick(() => console.log('nextTick'));
console.log('script end');

答案

1
2
3
4
5
6
7
8
9
script start
async1 start
async2
promise1
script end
nextTick
async1 end
promise2
setTimeout

解析await async2() 后面的代码相当于 .then() 微任务。process.nextTick 优先级高于 Promise.then。所以 nextTick 先于 async1 end 和 promise2 执行。

面试题2:如何实现一个 sleep 函数且不阻塞事件循环?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ❌ 错误:同步阻塞
function badSleep(ms) {
  const start = Date.now();
  while (Date.now() - start < ms) {} // 阻塞事件循环!
}

// ✅ 正确:返回 Promise
function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// 使用
async function demo() {
  console.log('开始');
  await sleep(2000); // 不阻塞事件循环
  console.log('2秒后');
}

面试题3:为什么 process.nextTick 比 Promise.then 先执行?

:这是 Node.js 的设计选择。在事件循环的 C++ 层实现中,process.nextTick 队列在 libuv 的 uv__run_timers 之后检查,而 Promise 回调由 V8 的 MicrotaskQueue 管理。Node.js 在处理完每个 C++ 回调后先清空 nextTick 队列,再清空微任务队列,因此 nextTick 的优先级最高。

面试题4:Node.js 中什么样的操作会进入线程池?

:DNS 的 dns.lookup()、文件系统 I/O(如 fs.readFile)、某些加密操作(crypto.pbkdf2crypto.randomBytes)等。网络 I/O(TCP/UDP/HTTP)则使用操作系统原生的异步 I/O(如 epoll/kqueue),不占用线程池。

面试题5:如何让 CPU 密集型计算不阻塞事件循环?

:三种策略:

  1. 拆分任务:用 setImmediatenextTick 将大量计算拆分成小块轮番执行
  2. 使用 child_process:fork 子进程来处理
  3. 使用 worker_threads:创建工作线程,这是 Node.js 推荐的方案(自 v10.5.0 起)

总结与扩展

理解事件循环是掌握 Node.js 的基石。核心要点:

  1. 六大阶段:timers → pending → poll → check → close,每轮循环清空各阶段队列
  2. 微任务挂载点:nextTick 和 Promise 在每个阶段切换时执行,优先级依次为 nextTick > Promise
  3. poll 阶段最重要:它决定了 I/O 回调的执行时机和事件循环的阻塞时间
  4. 与浏览器的区别:多了 nextTick 和 setImmediate,微任务执行时机更细粒度

扩展方向:

  • process.nextTick 的递归陷阱:递归 nextTick 会导致 I/O 回调永远得不到执行,使用 setImmediate 递归更安全
  • timers 精度:Node.js 的定时器精度受事件循环负载影响,高精度定时任务应使用 perf_hooks 或专门方案
  • 事件循环的诊断:使用 NODE_DEBUG=libuv 环境变量可以查看事件循环内部日志
  • libuv 线程池大小调整process.env.UV_THREADPOOL_SIZE 可调整线程池大小(最大 1024),但过大可能适得其反
本文由作者按照 CC BY 4.0 进行授权