一次性讲清楚 Node.js 事件循环(Event Loop)

一次性讲清楚 Node.js 事件循环(Event Loop) 之前在仔细的说过事件循环但是那个事件循环是基于浏览器背景下实现的。除此之外javascript还有一个很重要的执行环境——Node.js。在Node中事件循环有了些许的变化接下来就仔仔细细的看到底有什么变化。资料基本来源于Node 官方文档的 “The Node.js Event Loop” https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick一、明确概念为什么 Node 需要自己的事件循环JavaScript 是单线程的但 Node 要用它来写服务器——服务器要同时处理成千上万的网络请求、读写文件、查数据库这些都是耗时的 I/O 操作。如果继续单线程服务器根本没法用。浏览器用事件循环解决了这个问题Node 也需要同样的机制。但 Node 的运行环境和浏览器不同没有 DOM、没有渲染、却有大量文件和网络 I/O所以它没有直接照搬浏览器而是基于一个专门的 C 语言库libuv来实现事件循环。先看看libuv做了什么libuv 是一个用 C 语言编写的跨平台异步 I/O 库是 Node 单线程却不阻塞的底层支柱。它主要做三件事第一提供事件循环本身。整个循环机制timers → pending → poll → check → close后面会详细说明就是 libuv 实现的Node 的 JS 层只是调用它。第二封装跨平台的异步 I/O。不同操作系统的高效 I/O 机制不一样——Linux 用 epoll、macOS 用 kqueue、Windows 用 IOCP。libuv 把这些差异抹平对上层提供统一接口这样 Node 代码才能在三大平台行为一致。第三管理一个线程池。这点最关键、也最容易被误解。很多人以为Node 完全是单线程的其实不准确——JavaScript 的执行是单线程的但 libuv 背后有一个线程池默认 4 个线程。为什么需要线程池因为不是所有操作都有操作系统级的异步接口。网络 I/O 大多有原生异步支持靠 epoll/kqueue/IOCP不占线程池但文件系统操作、DNS 解析、一些 CPU 密集的加密操作如crypto.pbkdf2没有可靠的跨平台异步方案libuv 就把这些丢进线程池去跑跑完再通过事件循环把回调交回主线程。所以关于Node 是不是单线程准确的表述是Node 执行 JavaScript 的主线程是单线程的但 libuv 用线程池 操作系统的异步机制在背后并发处理耗时 I/O完成后把回调塞回主线程的事件循环。这就是单线程却非阻塞的真相。Node事件循环的不同阶段浏览器的事件循环只有一个笼统的宏任务队列而Node 把宏任务细分成了几个有固定执行顺序的阶段phase。每一轮事件循环都会按固定顺序走过这些阶段┌───────────────────────────┐ ┌─│ timers │ 执行到期的 setTimeout / setInterval 回调 │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ pending callbacks │ 执行上一轮延迟的系统级 I/O 回调 │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ idle, prepare │ 仅 libuv 内部使用JS 层碰不到 │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ poll │ 最核心获取并执行 I/O 回调必要时阻塞等待 │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ check │ 执行 setImmediate 回调 │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ close callbacks │ 执行 close 事件回调如 socket.on(close) └───────────────────────────┘idle/prepare 是内部阶段JS 层无法访问日常可忽略下面不展开timers定时器阶段执行已到期的setTimeout和setInterval回调。注意是到期才执行——定时器设定的是至少等这么久不是精确时间实际由 poll 阶段控制何时回到这里。pending callbacks待定回调阶段执行一些被推迟到本轮的系统级 I/O 回调比如某些类型的 TCP 错误如收到ECONNREFUSED。这个阶段和业务代码关系不大。poll轮询阶段整个事件循环最核心的阶段做两件事——获取新的 I/O 事件并执行对应回调几乎所有 I/O 回调如fs.readFile、网络数据到达都在这里执行以及在没有其他任务时决定是否阻塞在这里等待新的 I/O。它是事件循环停下来等活干的地方。check检查阶段专门执行setImmediate的回调。它紧跟在 poll 阶段之后。close callbacks关闭回调阶段执行各种关闭事件的回调比如socket.on(close, ...)。每个阶段都有自己的先进先出FIFO回调队列。事件循环进入一个阶段后会执行完该阶段队列里的回调或达到系统上限才进入下一阶段。走完 close 后绕回 timers 开始新一轮。Node中的微任务上面讲的是宏任务分阶段。但 Node 里还有优先级更高的微任务它们不属于任何阶段而是在阶段之间被清空。Node 里有两类微任务优先级还不一样process.nextTick队列优先级最高自成一队。Promise 微任务队列.then/.catch/.finally、await之后的代码、queueMicrotask优先级次之。记住他们的优先级整个 Node 事件循环差不多都能记住了每当事件循环执行完一个宏任务阶段里的一个回调后会先清空整个nextTick队列再清空整个 Promise 微任务队列然后才继续下一个宏任务或进入下一阶段。一句话优先级排序同步代码 process.nextTick Promise 微任务 任何阶段的宏任务。二、关键 APINode 提供了三个安排稍后执行的核心 API它们落在事件循环的不同位置理解它们的区别是理解整个模型的关键。setTimeout / setInterval —— timers 阶段setTimeout(fn, delay)安排一个回调在至少 delay 毫秒后执行回调进入timers 阶段。setInterval(fn, delay)类似但每隔 delay 毫秒重复执行。要点delay是最小延迟而非精确时间setTimeout(fn, 0)的0会被 Node 设置成最小 1ms。它们返回的是一个Timeout 对象不是数字可以传给clearTimeout/clearInterval取消。setImmediate —— check 阶段setImmediate(fn)安排回调在check 阶段执行也就是当前这一轮 poll 阶段结束后立即执行。它是 Node 独有的浏览器没有。它和setTimeout(fn, 0)看起来都像尽快执行但落点不同一个在 check 阶段一个在 timers 阶段。这个差别导致了它俩顺序的微妙问题见后面的题。process.nextTick —— 不属于任何阶段优先级最高process.nextTick(fn)安排的回调不属于事件循环的任何阶段而是在当前操作结束后、事件循环继续之前立刻执行优先级比 Promise 微任务还高。它强大也危险如果你递归调用process.nextTick会不断往 nextTick 队列里塞任务导致事件循环永远无法进入下一阶段比如永远到不了 poll这叫饿死 I/O。官方因此建议——大多数情况优先用setImmediate它更容易推理。一个常被误解的点EventEmitter 的 emit 是同步的顺带澄清一个和事件循环相关的高频误区。EventEmitterNode 的发布-订阅基础类server、stream等都继承自它的emit()默认是同步执行的——触发事件时所有监听器会被立刻依次调用不进任何队列constEventEmitterrequire(node:events);constenewEventEmitter();e.on(event,()console.log(2));console.log(1);e.emit(event);// 同步调用监听器console.log(3);// 输出1 2 3不是 1 3 2这也解释了一个经典的坑如果在构造函数里emit一个事件此时使用者还没来得及on注册监听器事件就丢了。解决办法是用process.nextTick把 emit 推迟到构造函数执行完、监听器绑定之后。三、Node 与浏览器的对比Node 和浏览器的事件循环大方向一致但细节差异不少。详细对比如下事件循环机制对比维度浏览器Node底层实现各浏览器引擎自己实现基于 libuv宏任务组织一个笼统的宏任务队列细分成 timers/pending/poll/check/close 多个阶段微任务Promise 微任务、MutationObserverPromise 微任务 额外的 process.nextTick优先级更高微任务清空时机每个宏任务后清空每个宏任务后先清 nextTick、再清 Promise 微任务渲染步骤有每轮可能重绘无服务端无渲染核心差异一句话浏览器是宏任务队列 微任务队列两层Node 是多阶段宏任务 nextTick 队列 Promise 微任务队列三层且 nextTick 优先级最高。定时器 API 对比setTimeout和setInterval两个环境都有、用法一致但有几处区别此外各自有独占的 APIAPI浏览器Node说明setTimeout/setInterval有有用法一致定时器返回值数字 IDTimeout 对象带unref()等方法都可传给 clear 函数取消回调去向宏任务队列libuv 的 timers 阶段—嵌套 5 层强制 4ms 最小延迟有HTML 规范无浏览器特有的防滥用规则setImmediate无有check 阶段Node 独有process.nextTick无有最高优先级Node 独有requestAnimationFrame有与渲染同步无浏览器独有用于动画要点提炼setTimeout/setInterval通用但返回值类型和底层调度不同setImmediate和process.nextTick是 Node 独有requestAnimationFrame是浏览器独有。四、答题时间到下面几道题覆盖 Node 事件循环的高频考点每题先自己推一遍再看答案和解析。题目 1三类任务的优先级console.log(1);// 同步setTimeout(()console.log(2),0);// 宏任务(timers)setImmediate(()console.log(3));// 宏任务(check)Promise.resolve().then(()console.log(4));// Promise 微任务process.nextTick(()console.log(5));// nextTick(最高优先级)console.log(6);// 同步先想想输出顺序是什么哪些是确定的哪些不一定答案1 6 5 4是确定的然后2和3的顺序不确定。逐步推演先跑同步代码console.log(1)和console.log(6)→ 打印1、6。同步代码跑完、栈清空进入微任务清算。按优先级先清nextTick 队列执行5→ 打印5。再清Promise 微任务队列执行4→ 打印4。微任务都清完事件循环正式开始第一轮进入 timers 阶段。此时看那个setTimeout(0)到期没有——0被钳到最小 1ms而从进程启动到这一刻的耗时是飘忽不定的如果已 ≥ 1mstimers 阶段执行22在前如果 1ms定时器没到期跳过 timers走到 check 阶段先执行33在前。关键认知1 6 5 4由铁律保证同步 nextTick Promise 微任务完全确定但在主模块顶层setTimeout(0)和setImmediate谁先是一场受启动耗时影响的赛跑顺序不确定。很多人会把这道题的答案背成固定的1 6 5 4 2 3这是不严谨的——2 3和3 2都可能出现。题目 2setTimeout(0) vs setImmediate 在 I/O 回调里constfsrequire(node:fs);fs.readFile(__filename,(){setTimeout(()console.log(timeout),0);setImmediate(()console.log(immediate));});先想想这次 timeout 和 immediate 谁先还是不确定吗答案immediate永远先执行这次是确定的。解析区别就在于这两个定时器是在fs.readFile的回调里安排的而这个回调本身是在poll 阶段执行的I/O 回调都在 poll 阶段。执行完这个回调后看阶段顺序poll当前在这→ checksetImmediate 在这→ …下一轮… → timerssetTimeout 在这poll 阶段结束后紧接着就是 check 阶段immediate立刻执行而timeout属于 timers 阶段得等事件循环绕完一整圈、到下一轮才轮到。所以immediate必然先于timeout。关键认知同样两行代码在主模块里顺序不确定题1在 I/O 回调里 immediate 必先本题——差别来自代码在哪个阶段执行。poll 紧邻 check是 immediate 在 I/O 回调里稳赢的根本原因。这也是判断这类题的通用方法先问这段代码运行在哪个阶段。题目 3nextTick 高于 PromisePromise.resolve().then(()console.log(promise));process.nextTick(()console.log(nextTick));console.log(sync);先想想三行的输出顺序答案sync → nextTick → promise解析console.log(sync)是同步代码最先执行 →sync。同步代码跑完进入微任务清算。Node 里nextTick 队列的优先级高于 Promise 微任务队列所以先执行nextTick→nextTick。再清 Promise 微任务 →promise。关键认知在 Node 里process.nextTick比Promise.then更急。尽管两者都在同步代码之后、下一个宏任务之前执行但 nextTick 自成一个更高优先级的队列永远排在 Promise 微任务前面。这是 Node 特有的浏览器里没有 nextTick 这一层。题目 4递归 nextTick 会饿死事件循环constfsrequire(node:fs);fs.readFile(__filename,()console.log(I/O 回调执行了));functionloop(){process.nextTick(loop);// 递归安排 nextTick}loop();先想想那句I/O 回调执行了会被打印吗为什么答案永远不会打印。解析loop每次执行都用process.nextTick安排下一个loop。回想那条规则——事件循环在进入下一阶段之前必须先把整个 nextTick 队列清空。但这个队列在清空的过程中每执行一个loop又塞进一个新的loop队列永远清不完。结果事件循环被死死卡在清 nextTick 队列这一步永远无法推进到 poll 阶段于是fs.readFile的回调在 poll 阶段执行永远得不到机会。这就是饿死 I/O。关键认知process.nextTick的高优先级是把双刃剑——递归调用会让它霸占事件循环阻止任何阶段推进。这也是官方建议优先用setImmediate的原因setImmediate是宏任务check 阶段每轮只执行一次已排队的不会阻止循环推进用它做递归/切片是安全的。题目 5EventEmitter 的 emit 是同步的constEventEmitterrequire(node:events);constenewEventEmitter();e.on(event,()console.log(监听器));console.log(开始);e.emit(event);console.log(结束);先想想输出顺序是什么监听器会不会被异步推迟答案开始 → 监听器 → 结束emit 是同步的。解析EventEmitter的emit()默认同步调用所有监听器——它不进任何队列触发的那一刻就直接、立即依次执行监听器。所以顺序就是老实的从上到下开始→ emit 立即调用监听器打印监听器→结束。关键认知emit是同步的别把它当异步。这解释了那个经典坑在构造函数里emit此时监听器还没on上去事件就丢了正确做法是用process.nextTick把 emit 推迟到构造完成之后。