skip to content
月与羽

事件循环(Event Loop)

/ 13 min read

JavaScript 语言本身是单线程的。这意味着在任何给定时刻,JavaScript 引擎(比如 Chrome 的 V8)只能执行一件任务。如果一个任务耗时很长(比如一个复杂的计算或网络请求),那么整个程序就会被阻塞,用户界面会卡住,无法响应任何操作。 那么,它如何实现“不阻塞”的异步效果呢? 答案是: JavaScript 引擎本身是单线程的,但它运行的宿主环境(Host Environment)——比如浏览器或 Node.js——并不是单线程的。 异步操作的核心思想就是把耗时的任务交给宿主环境去处理,JavaScript 主线程则继续执行后面的代码。当耗时任务完成后,宿主环境再通知 JavaScript 主线程,并将需要执行的回调函数放回任务队列中等待执行。 这个协调工作的机制,就是大名鼎鼎的 事件循环(Event Loop)

理解这个机制的关键组件

为了实现异步,整个 JavaScript 运行时环境包含了以下几个关键部分:

  1. 调用栈(Call Stack)
    • 这是一个后进先出(LIFO)的数据结构。
    • 当一个函数被调用时,它会被推入栈中。当函数执行完毕返回时,它会被弹出栈。
    • JavaScript 主线程只做一件事:执行调用栈顶部的函数。
  2. Web APIs / Node.js APIs(宿主环境提供的 API)
    • 这些是由浏览器或 Node.js 提供的 API,它们不是 JavaScript 引擎的一部分。
    • 例如:setTimeout, setInterval, DOM 事件(如 click, scroll), AJAX 请求 (XMLHttpRequest, fetch)。
    • 当你调用这些 API 时,你实际上是把任务交给了宿主环境的其他线程去处理。例如,setTimeout 会启动一个计时器,fetch 会发起一个网络请求。这些操作都不会阻塞 JavaScript 主线程。
  3. 任务队列(Task Queue / Callback Queue)
    • 这是一个先进先出(FIFO)的数据结构。
    • 当 Web API 完成了它的任务(比如计时器时间到了,或者网络数据回来了),它不会直接把结果交给 JS 主线程,而是将对应的回调函数(Callback Function)放入这个队列中排队。
  4. 事件循环(Event Loop)
    • 这是一个持续运行的进程,它的工作就是不停地“监视”调用栈和任务队列。
    • 它的规则非常简单:如果调用栈是空的,就从任务队列中取出一个任务(回调函数),并将其推入调用栈中执行。

一个简单的例子:setTimeout

让我们通过一个具体的例子来走一遍流程:

console.log('Start'); // 1. 开始
setTimeout(function() { // 2. 设置一个定时器
console.log('Timeout callback!');
}, 2000);
console.log('End'); // 3. 结束

执行步骤:

  1. console.log('Start') 被推入调用栈,立即执行并打印 “Start”,然后从调用栈中弹出。
  2. setTimeout(...) 被推入调用栈
  3. JavaScript 引擎识别出这是一个 Web API,于是将任务交给浏览器。浏览器启动一个计时器线程,倒计时 2 秒。setTimeout 函数本身执行完毕,从调用栈中弹出。注意:JS 主线程现在是自由的,它不会在这里等待 2 秒!
  4. console.log('End') 被推入调用栈,立即执行并打印 “End”,然后从调用栈中弹出。
  5. 此时,主线程的所有同步代码都已执行完毕,调用栈变空了
  6. (在后台,2 秒钟过去了…)浏览器的计时器完成倒计时。
  7. 浏览器将 setTimeout回调函数 function() { console.log('Timeout callback!'); } 放入任务队列中排队。
  8. 事件循环检测到调用栈是空的,并且任务队列里有任务
  9. 事件循环从任务队列中取出那个回调函数,并将其推入调用栈
  10. 调用栈执行这个回调函数,console.log('Timeout callback!') 被执行,打印 “Timeout callback!”。
  11. 回调函数执行完毕,从调用栈中弹出。程序结束。 所以,最终的输出顺序是:
Start
End
Timeout callback!

这个流程完美地解释了为什么 JavaScript 是单线程的,但又能处理异步任务而不会被阻塞。

总结

组件 (Component)负责执行 (Executed By)主要职责 (Primary Responsibility)
JavaScript 代码JavaScript 引擎 (V8)执行调用栈顶部的函数,进行计算、操作数据等。
Web APIs (setTimeout 等)宿主环境 (浏览器)在后台线程中处理耗时任务(计时、网络请求、DOM 事件等)。
任务队列 (Task Queue)宿主环境 (浏览器)维护一个“待办事项”列表,存放已完成的异步任务的回调函数。
事件循环 (Event Loop)宿主环境 (浏览器)作为调度员,在调用栈为空时,从任务队列中取出任务,交给 JS 引擎执行。

拓展 1:微任务 (Microtask) vs. 宏任务 (Macrotask) 的深度剖析

随着 JavaScript 的发展,为了解决回调函数嵌套过深(“回调地狱”)的问题,引入了 Promiseasync/await。 它们的工作原理也是基于事件循环,但引入了一个新的、更高优先级的队列:微任务队列(Microtask Queue)

  • 宏任务 (Macrotask / Task):
    • 来源: script (整个脚本文件)、setTimeout, setInterval, setImmediate (Node.js), I/O, UI 渲染。
    • 特点: 每次事件循环的迭代中,只会从宏任务队列中取出一个任务来执行。执行完毕后,会去检查微任务队列。
  • 微任务 (Microtask):
    • 来源: Promise.then(), Promise.catch(), Promise.finally(), MutationObserver, queueMicrotask().
    • 特点: 在当前宏任务执行结束后,会立即执行微任务队列中的所有任务,直到微任务队列被清空为止。如果在执行微任务的过程中又产生了新的微任务,那么这些新的微任务也会被加入队列并在当前轮次中执行完毕。 事件循环的完整流程更新为:
  1. 执行一个宏任务(比如加载的 <script> 标签内的代码)。
  2. 执行完该宏任务后,检查微任务队列。
  3. 循环执行微任务队列中的所有任务,直到队列为空。
  4. (可选)浏览器进行 UI 渲染(判断是否需要重新绘制)。
  5. 从宏任务队列中取出下一个宏任务,返回第 1 步,开始新的循环。 一个经典的面试题,来巩固这个概念:
console.log('script start'); // 1. 宏任务
setTimeout(function() {
console.log('setTimeout'); // 4. 宏任务
}, 0);
Promise.resolve().then(function() {
console.log('promise1'); // 3. 微任务
}).then(function() {
console.log('promise2'); // 3. 微任务
});
console.log('script end'); // 2. 宏任务

执行分析:

  1. 宏任务开始:
    • 执行 console.log('script start'),打印 “script start”。
    • 遇到 setTimeout,将其回调函数注册为一个新的宏任务,放入宏任务队列。
    • 遇到 Promise.resolve().then(...)then 里的回调函数被注册为一个微任务,放入微任务队列。
    • 执行 console.log('script end'),打印 “script end”。
  2. 当前宏任务结束。现在,事件循环会检查微任务队列
    • 发现微任务队列中有 promise1 的回调。执行它,打印 “promise1”。
    • 这个回调返回 undefined,但它后面链式调用了 .then(),所以又产生了一个新的微任务(promise2 的回调),并将其放入微任务队列。
    • 事件循环继续检查微任务队列,发现还不为空,里面有 promise2 的回调。执行它,打印 “promise2”。
  3. 微任务队列清空
  4. (浏览器可能会进行一次渲染)。
  5. 事件循环去宏任务队列中取下一个任务。
    • 取到了之前 setTimeout 的回调。执行它,打印 “setTimeout”。 最终输出:
script start
script end
promise1
promise2
setTimeout

拓展 2:与浏览器渲染的关系

事件循环、宏任务和微任务与页面是否卡顿(Jank)息息相关。

  • UI 渲染是宏任务:浏览器的渲染更新(Repaint/Reflow)通常是在处理完微任务队列后,下一个宏任务执行前,由浏览器自行决定是否执行。
  • 为什么微任务会阻塞渲染? 因为微任务队列会在当前宏任务结束后立即全部执行。如果你的代码(比如在一个循环中)不断地产生微任务,那么微任务队列将永远不会清空。这就意味着,事件循环永远没有机会进入“UI 渲染”或“执行下一个宏任务”的阶段,导致页面完全卡死,无法响应用户操作。
// 危险!不要在生产环境这样用
function endlessMicrotasks() {
Promise.resolve().then(() => {
console.log('Microtask running...');
endlessMicrotasks(); // 无限递归产生微任务
});
}
endlessMicrotasks();
setTimeout(() => console.log('This will never run!'), 0); // 这个宏任务永远得不到机会

拓展 3:Node.js 中的事件循环

虽然核心概念相似,但 Node.js 的事件循环(基于 libuv 库)比浏览器的更复杂,它有明确的阶段(Phases)。 一个循环周期大致包含以下阶段:

  1. timers: 执行 setTimeoutsetInterval 的回调。
  2. pending callbacks: 执行延迟到下一个循环迭代的 I/O 回调。
  3. idle, prepare: 仅内部使用。
  4. poll: 检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有回调都在这里执行,除了 close 回调、timers 和 setImmediate)。Node.js 会在这里适当阻塞等待。
  5. check: 执行 setImmediate 的回调。
  6. close callbacks: 执行如 socket.on('close', ...) 的回调。 关键区别setImmediate 的回调在 poll 阶段完成后立即执行,而 setTimeout 的回调在 timers 阶段执行。这导致在某些情况下,setTimeout(fn, 0)setImmediate(fn) 的执行顺序是不确定的,这取决于进入事件循环时所花费的时间。

总结与启示

理解 JavaScript 异步的本质,就是理解宿主环境、调用栈、任务队列(宏/微)和事件循环这几个组件如何协同工作。

  • 性能优化:避免编写长时间运行的同步代码或无限循环的微任务,因为它们会阻塞事件循环,导致页面卡顿或服务器无响应。
  • 代码预测:能够准确预测异步代码的执行顺序,是调试和编写复杂逻辑的基础。
  • 架构设计:在设计应用时,合理地将耗时操作(如文件读写、数据库查询、网络请求)异步化,是构建高性能、高并发应用的关键。