事件循环(Event Loop)

从为什么需要事件循环讲起,系统梳理调用栈、任务队列、微任务队列的区别,深入 Node.js libuv 事件循环各阶段,解析 setTimeout/setImmediate/process.nextTick 的差异,以及浏览器与 Node.js 事件循环的异同。

为什么 JavaScript 需要事件循环

JavaScript 从诞生起就是单线程语言。这个设计选择不是偶然,而是有其深刻的历史背景和工程考量。

在浏览器环境中,JavaScript 的主要用途是操作 DOM。如果 JavaScript 支持多线程,当两个线程同时修改同一个 DOM 元素时,浏览器的渲染结果将变得不可预测。浏览器开发者们选择了最简单的解决方案:让 JavaScript 单线程执行,任何时刻只有一个任务在运行。

但单线程意味着所有 I/O 操作都必须异步完成。如果 JavaScript 同步等待网络请求完成,整个浏览器界面就会冻结,用户体验将无法接受。所以 JavaScript 必须有一套机制来处理异步操作——这就是事件循环的核心驱动力。

事件循环的本质是:当主线程(Call Stack)执行完当前所有同步代码后,它会去检查任务队列(Task Queue)中是否有等待执行的任务;如果有,就取出第一个任务执行;如果没有,就继续等待。这个循环永不停止,直到程序结束。


一、调用栈(Call Stack)

1.1 什么是调用栈

调用栈是 JavaScript 引擎维护的一个数据结构,用于跟踪函数调用。当一个函数被调用时,一个**栈帧(stack frame)**被推入栈顶;当函数返回时,这个栈帧被弹出。

function greet(name) {
  return `Hello, ${name}`;
}

function sayHello() {
  const message = greet('World');
  console.log(message);
}

sayHello();

上述代码执行时,调用栈的变化过程是:

// 1. sayHello() 被调用,栈帧 push
[sayHello]

// 2. greet() 被调用,栈帧 push
[sayHello, greet]

// 3. greet() 返回,栈帧 pop
[sayHello]

// 4. sayHello() 返回,栈帧 pop
[]

1.2 栈溢出(Stack Overflow)

调用栈的容量是有限的。如果递归调用没有正确的终止条件,栈会不断增长,最终耗尽内存,这就是栈溢出

// 这段代码会导致栈溢出
function recursive() {
  recursive();
}

recursive(); // RangeError: Maximum call stack size exceeded

浏览器和 Node.js 都会对调用栈深度做出限制,通常在几千到几万层之间。

1.3 同步代码的执行

调用栈的一个关键特性是:它会执行完当前栈中的所有同步代码后,才会处理其他任务。这意味着如果有一段耗时很长的同步计算,它会阻塞整个主线程。

console.log('1');

function blockingCode() {
  // 假设这是一个耗时 10 秒的计算
  const start = Date.now();
  while (Date.now() - start < 1000) {
    // 阻塞 1 秒
  }
}

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

blockingCode(); // 这会阻塞,导致 setTimeout 的回调无法在预期时间执行

console.log('3');

// 输出顺序:1, [阻塞 1 秒], 3, 2

在这个例子中,即使 setTimeout 的延迟是 0 毫秒,它的回调也必须等到调用栈清空后才能执行。


二、任务队列(Task Queue)与事件循环

2.1 基本概念

任务队列(也称为宏任务队列或 macrotask queue)是 JavaScript 引擎用于管理异步回调的队列结构。当异步操作完成时(如定时器到期、网络请求响应到达),对应的回调函数会被放入任务队列等待执行。

事件循环的工作流程可以描述为:

while (true) {
  // 1. 执行完当前调用栈中的所有同步代码
  while (callStack.isEmpty()) {
    // 2. 从微任务队列取出所有微任务执行
    while (microtaskQueue.isNotEmpty()) {
      executeMicrotask(microtaskQueue.dequeue());
    }

    // 3. 从任务队列取出一个任务执行
    if (taskQueue.isNotEmpty()) {
      executeTask(taskQueue.dequeue());
      // 4. 循环回到步骤 2(检查微任务)
    }
  }
}

这个流程图展示了事件循环的核心逻辑:

graph TD
    A[事件循环开始] --> B[执行调用栈中的同步代码]
    B --> C{调用栈为空?}
    C -->|是| D[处理所有微任务]
    D --> E{任务队列有任务?}
    E -->|是| F[取出并执行一个任务]
    F --> B
    E -->|否| G[等待任务到来]
    G --> A
    C -->|否| B

2.2 任务队列的执行时机

事件循环的每次迭代中,最多只会执行一个宏任务。这是非常重要的特性,意味着如果一个宏任务执行时间过长,会延迟后续宏任务的执行。

setTimeout(() => console.log('timeout 1'), 0);
setTimeout(() => console.log('timeout 2'), 0);
Promise.resolve().then(() => console.log('microtask'));

console.log('sync');

// 输出顺序:
// sync
// microtask
// timeout 1
// timeout 2

解释:同步代码先执行,然后检查微任务队列(发现 Promise.then),执行后检查任务队列(发现 setTimeout)。


三、微任务(Microtask)与宏任务(Macrotask)

3.1 核心区别

这是 JavaScript 异步编程中最容易混淆的概念之一。

特征 微任务(Microtask) 宏任务(Macrotask)
代表 Promise.then/catch/finally, async/await, queueMicrotask setTimeout, setInterval, I/O, UI 渲染
执行时机 当前任务执行完毕后、下一个宏任务之前 事件循环的每一轮迭代
执行数量 所有微任务都会在本轮执行完 一个
创建的新微任务 会在同一微任务阶段执行 会进入下一个宏任务阶段

3.2 为什么需要微任务

微任务的存在是为了解决异步操作之间的依赖关系

考虑这个场景:Promise A 完成后,需要立即执行 Promise B 的处理逻辑。如果 Promise A 的回调在任务队列中,Promise B 的回调在同一个队列里,那么 B 必须等 A 执行完后才能被取出。但如果有更高优先级的任务插入,A 和 B 的相对顺序可能被打破。

微任务队列提供了一种确定性:在当前宏任务结束后,所有已完成的微任务都会被处理,不管有多少个。这确保了 Promise 链式调用的顺序性。

3.3 实际执行顺序示例

console.log('1. sync start');

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

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

console.log('6. sync end');

// 完整输出顺序:
// 1. sync start
// 6. sync end
// 3. Promise.then 1
// 4. nested Promise.then
// 5. Promise.then 2
// 2. setTimeout

执行过程详解:

  1. console.log('1') 同步执行
  2. setTimeout 注册回调到任务队列
  3. Promise.resolve().then() 注册第一个微任务
  4. console.log('6') 同步执行
  5. 调用栈空,检查微任务队列
  6. 执行第一个微任务,输出 "3"
  7. 第一个微任务中注册了新的 Promise.then,进入微任务队列
  8. 执行新的微任务,输出 "4"
  9. 第一个微任务的 .then() 链后面还有一个 .then(),进入微任务队列
  10. 执行第二个微任务,输出 "5"
  11. 微任务队列空,检查任务队列
  12. 执行 setTimeout 回调,输出 "2"

3.4 async/await 与微任务

async 函数返回 Promiseawait 后面的代码会作为微任务执行。

async function asyncFunc() {
  console.log('a1. in async');
  await Promise.resolve();
  console.log('a2. after await');
}

console.log('s1. before async');
asyncFunc();
console.log('s2. after async call');

// 输出:
// s1. before async
// a1. in async
// s2. after async call
// a2. after await

await Promise.resolve() 暂停了 async 函数的执行,让出调用栈。当 Promise resolve 后,await 后面的代码作为微任务继续执行。

3.5 queueMicrotask API

除了 Promise,JavaScript 还提供了 queueMicrotask() 函数,允许你将任意函数排队为微任务:

queueMicrotask(() => {
  console.log('这是微任务');
});

这在你需要确保某段代码在当前同步代码之后、但在下一个宏任务之前执行时很有用。


四、浏览器中的事件循环

4.1 浏览器事件循环的特点

浏览器环境中的事件循环需要协调多种任务来源:

  • 渲染任务:浏览器需要在合适的时机执行样式计算和布局更新
  • 用户交互任务:点击、输入等事件
  • 定时器任务:setTimeout、setInterval
  • 网络任务:XMLHttpRequest、fetch
  • 微任务:Promise 回调
sequenceDiagram
    participant JS as JavaScript 引擎
    participant Micro as 微任务队列
    participant Macro as 宏任务队列
    participant Render as 渲染引擎

    JS->>JS: 执行同步代码
    loop 事件循环
        JS->>Micro: 执行所有微任务
        JS->>Macro: 执行一个宏任务
        Note over JS,Render: 检查是否需要渲染
        Render-->>JS: 渲染时机检查
        JS->>Macro: 取出下一个宏任务
    end

4.2 渲染时机

浏览器并不会在每个宏任务后都渲染。渲染通常发生在宏任务执行完毕后调用栈清空时的特定时机。这个时机由浏览器的调度策略决定,通常与显示器的刷新率同步(60fps 即每 16.67ms 渲染一次)。

// 示例:动画卡顿的原因
function badAnimation() {
  for (let i = 0; i < 100000; i++) {
    element.style.transform = `translateX(${i}px)`;
  }
}

// 更好的做法:使用 requestAnimationFrame
function goodAnimation() {
  requestAnimationFrame(() => {
    element.style.transform = `translateX(${i}px)`;
  });
}

4.3 用户代码与渲染任务的关系

当用户代码执行时间过长(超过 16ms),会挤压渲染可用时间,导致动画卡顿。这就是为什么长任务(Long Task)监控是性能优化的重要指标。


五、Node.js 的事件循环

5.1 libuv 的角色

Node.js 使用 libuv 作为底层事件循环库。libuv 是一个用 C 语言编写的多平台支持库,封装了不同操作系统上的 I/O 多路复用机制(如 Linux 的 epoll、macOS 的 kqueue、Windows 的 IOCP)。

Node.js 的事件循环比浏览器复杂,因为它需要处理:

  • 文件系统操作
  • DNS 查询
  • 子进程管理
  • 信号处理
  • 定时器(timers)
  • TCP/UDP socket

5.2 Node.js 事件循环阶段

Node.js 的事件循环分为多个阶段(phase),每个阶段都有自己独立的队列:

graph TD
    A[事件循环开始] --> B[timers 阶段]
    B --> C[pending callbacks 阶段]
    C --> D[idle, prepare 阶段]
    D --> E[poll 阶段]
    E --> F{有 setImmediate?}
    F -->|是| G[check 阶段]
    F -->|否| H{有没有定时器?}
    H -->|是| B
    H -->|否| E
    G --> I[close callbacks 阶段]
    I --> B

各阶段详解:

  1. timers 阶段:执行 setTimeout()setInterval() 的回调。所有在 timer 阈值到期后注册的回调都会在这里执行。

  2. pending callbacks 阶段:执行某些系统操作的回调(如 TCP 错误)。通常不需要关心。

  3. idle, prepare 阶段:仅供 libuv 内部使用。

  4. poll 阶段这是最重要的阶段。获取新的 I/O 事件;执行与 I/O 相关的回调(除了 close callbacks、setImmediate 和 timers 的回调);如果 poll 队列为空,会在这里阻塞等待。

  5. check 阶段:执行 setImmediate() 的回调。

  6. close callbacks 阶段:执行一些关闭的回调,如 socket.on('close', ...).

5.3 setTimeout vs setImmediate

这是 Node.js 中最容易混淆的一对 API。

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

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

运行多次,你会发现输出顺序不确定——有时 setTimeout 先输出,有时 setImmediate 先输出。

原因setTimeout 的回调在 timers 阶段执行,而 setImmediate 的回调在 check 阶段执行。如果 timers 阶段有多个 setTimeout,它们会按过期顺序执行。但如果主模块(main script)没有其他 I/O 操作,事件循环可能直接从 timers 跳到 check 阶段,或者中间经过其他阶段,导致顺序不确定。

但在 I/O 操作的回调中,setImmediate 总是先于 setTimeout

const fs = require('fs');

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

// 几乎总是输出:
// setImmediate
// setTimeout

因为 I/O 回调在 poll 阶段执行,而 poll 阶段结束后会进入 check 阶段(如果存在 setImmediate)。

5.4 process.nextTick

process.nextTick() 是一个 Node.js 特有的 API,它不是事件循环的一部分

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

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

console.log('sync');

// 输出:
// sync
// nextTick
// setTimeout

process.nextTick 的回调会被添加到一个特殊的队列,这个队列会在当前操作完成后、事件循环继续之前立即清空。它优先级高于所有微任务和宏任务。

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

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

// 输出:
// nextTick
// Promise.then

5.5 微任务在 Node.js 中的差异

在 Node.js 中,Promise.then/catch/finally、async/await 都会创建微任务。但 process.nextTick 的优先级高于微任务。

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

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

// 输出:
// 1. nextTick first
// 2. nested nextTick
// 3. Promise microtask

所有 nextTick 回调会在任何微任务之前执行。

5.6 Node.js 与浏览器事件循环的异同

方面 浏览器 Node.js
底层库 无统一底层库,各浏览器自己实现 libuv(统一实现)
渲染任务 有(requestAnimationFrame 等) 无(服务端无 UI)
事件来源 用户交互、网络、定时器 文件 I/O、网络 I/O、定时器、子进程
microtask 队列 Promise 回调、async/await、queueMicrotask Promise 回调、async/await、queueMicrotask
nextTick 有,优先级高于微任务
阶段划分 简单(任务队列 + 微任务队列) 复杂(多个 phase)

六、实际应用与常见陷阱

6.1 错误处理导致的事件循环阻塞

// 错误示范:同步错误不会进入事件循环
try {
  setTimeout(() => {
    throw new Error('async error');
  }, 0);
} catch (e) {
  console.log('这不会捕获异步错误');
}

异步代码中的错误需要通过回调或 Promise 来处理。

6.2 微任务队列饥饿

如果不断向微任务队列添加任务,可能导致宏任务永远得不到执行:

let count = 0;

function recursiveMicrotask() {
  Promise.resolve().then(recursiveMicrotask);
  count++;
}

recursiveMicrotask();

setTimeout(() => {
  console.log('永远不会执行');
}, 1000);

这实际上是一个无限循环,但它在微任务队列中执行,不会阻塞主线程,也不会触发浏览器的"脚本长时间运行"警告。

6.3 Node.js 中 nextTick 与微任务的执行顺序

const promise = Promise.resolve();

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

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

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

// 输出顺序:
// 1. nextTick
// 3. nextTick second
// 2. Promise.then

所有 nextTick 回调会在微任务之前执行,而且是在 nextTick 队列清空后再执行微任务。

6.4 为什么 setImmediate 在 readFile 回调中有时更快

const fs = require('fs');

console.log('=== 第一次执行 ===');
fs.readFile(__filename, () => {
  console.log('in readFile callback');
  setTimeout(() => console.log('setTimeout'), 0);
  setImmediate(() => console.log('setImmediate'));
  process.nextTick(() => console.log('nextTick in callback'));
});

console.log('=== 主模块代码 ===');

// 可能的输出顺序:
// === 主模块代码 ===
// === 第一次执行 ===
// in readFile callback
// nextTick in callback
// setImmediate
// setTimeout

在 I/O 回调中,process.nextTick 优先级最高,然后是 setImmediate,最后是 setTimeout


七、生产环境中的注意事项

7.1 避免长任务阻塞事件循环

生产代码中应避免耗时过长的同步操作。如果必须执行耗时计算,应考虑:

  • 使用 requestAnimationFrame 分解任务
  • 使用 Web Worker(在浏览器)或 Worker Threads(在 Node.js)
  • 使用 setImmediatesetTimeout 分批执行

7.2 Node.js 性能调优

Node.js 的事件循环是单线程的,但 libuv 维护了一个线程池来处理文件 I/O 和 DNS 查询等操作。如果应用大量使用这些操作,可能需要调整线程池大小:

UV_THREADPOOL_SIZE=16 node app.js

7.3 监控工具

  • 浏览器:Chrome DevTools 的 Performance 面板可以可视化事件循环和 Long Task
  • Node.js:可以使用 async_hooks 模块或第三方工具(如 clinic.js)分析事件循环延迟

八、与其他主题的关联

关联主题 关系说明
async-programming Promise 和 async/await 的回调通过微任务队列执行,理解事件循环才能准确预测执行顺序
node-streams stream 的 .pipe() 和事件处理都依赖事件循环机制
nodejs-internals libuv 是 Node.js 事件循环的底层实现
web-workers Worker 线程有独立的事件循环,不会阻塞主线程
performance Long Task 是性能监控的重要指标

延展阅读