最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
透彻理解 Node.js 事件循环机制
时间:2026-07-01 10:12:54 编辑:袖梨 来源:一聚教程网
深入理解 Node.js 事件循环机制
先看一段代码,你能不假思索说出它的输出顺序吗:

console.log('1')setTimeout(() => console.log('2'), 0)Promise.resolve().then(() => console.log('3'))process.nextTick(() => console.log('4'))console.log('5')
答案是 1 5 4 3 2。如果你脱口而出的是 1 5 2 3 4,那这篇就是写给你的——setTimeout 写了 0 毫秒却排在最后,nextTick 后写却先跑,这背后就是事件循环在排队
我带新人的时候发现,大部分人对事件循环的理解停在「Node 是单线程、靠事件循环处理异步」这一句话。这句话没错,但它解释不了上面的输出,也解释不了线上为什么一个同步的 JSON.parse 大对象会把整个服务卡死。这篇就把这台「调度机器」拆开看一遍
事件循环不是 V8 给的,是 libuv 给的
很多人下意识以为事件循环是 JavaScript 引擎的一部分,其实不是。V8 只负责执行 JS、管理堆和调用栈,它根本不知道「定时器」「网络 IO」是什么
真正干调度活的是 libuv——一个 C 写的跨平台异步 IO 库,Node 把它当底座。你写的 fs.readFile、setTimeout、监听一个端口,最后都落到 libuv 手上。事件循环就是 libuv 里的一个主循环,它在不停地问一句话:「现在有哪些回调到点了,该执行谁?」
所以「Node 是单线程的」这句话要补全:执行你 JS 代码的是单线程,但底下的 IO 是 libuv 用线程池和操作系统的异步能力扛的。这个区分很关键,后面讲线程池会再回来。
六个阶段,循环往复
libuv 的事件循环每一轮(官方叫 tick)会依次走过六个阶段,每个阶段维护自己的一条回调队列。Node 官方文档 The Node.js Event Loop 给的顺序是这样的:
┌───────────────────────────┐┌─>│ timers │setTimeout / setInterval 的回调│└─────────────┬─────────────┘│┌─────────────┴─────────────┐││pending callbacks│少数系统级回调,如某些 TCP 错误│└─────────────┬─────────────┘│┌─────────────┴─────────────┐││ idle, prepare│Node 内部用,业务无感│└─────────────┬─────────────┘│┌─────────────┴─────────────┐││ poll │← 真正的重头戏:取 IO 事件、执行 IO 回调│└─────────────┬─────────────┘│┌─────────────┴─────────────┐││ check│setImmediate 的回调│└─────────────┬─────────────┘│┌─────────────┴─────────────┐└──┤close callbacks │socket.on('close') 之类 └───────────────────────────┘
对业务开发真正要记住的是三个:timers、poll、check。其余三个要么是内部用的,要么只在很边缘的场景(比如 TCP 连接被拒)才碰到
timers:检查有没有setTimeout / setInterval 到期。注意「到期」不等于「精确执行」——你写 setTimeout(fn, 100),Node 只保证至少 100ms 后才可能执行,真正执行还得等循环转到这个阶段、且前面没人占着线程 poll:整个循环的核心。它干两件事:计算该阻塞等待多久、然后执行 poll 队列里的 IO 回调(文件读完了、socket 来数据了)。如果队列空了,它会在这里停下来等新的 IO 事件,这就是一个空闲的 Node 进程不烧 CPU 的原因 check:专门执行 setImmediate 的回调。设计它就是为了给你一个「在 poll 之后、下一轮 timers 之前」插队的口子 真正的插队者:nextTick 与微任务
上面六个阶段是「宏观」队列。但还有两条优先级更高的队列,它们不属于任何一个阶段,而是在每个阶段切换的间隙都会被清空:
process.nextTick 队列 微任务队列(Promise 的 .then/.catch/.finally、queueMicrotask、await 之后的代码) 执行规则是:当前这一步同步代码跑完,在进入下一个阶段之前,先把 nextTick 队列清空,再把微任务队列清空。两者都为空了,才允许事件循环往下走。而且 nextTick 优先级高于 Promise 微任务
现在回头看开头那段代码就通了:
console.log('1')// 同步,立即setTimeout(() => console.log('2'), 0) // 进 timers 阶段队列Promise.resolve().then(() => console.log('3'))// 进微任务队列process.nextTick(() => console.log('4'))// 进 nextTick 队列console.log('5')// 同步,立即
主模块同步代码先跑完 → 1、5。同步代码一结束,先清 nextTick → 4,再清微任务 → 3。这俩清空后,事件循环才正式开始转,转到 timers 阶段执行 setTimeout → 2。所以是 1 5 4 3 2
这里有个我踩过的坑要提醒:process.nextTick 听名字像是「下一轮 tick 再执行」,其实恰恰相反,它是在当前操作之后立刻执行,比任何 IO 都早。如果你写了一个会递归调用 process.nextTick 的逻辑,它会让事件循环永远进不到下一个阶段,IO 回调一个都跑不了,表现就是服务假死却不报错。我们线上真出过这种事,排查了大半天才定位到一个「看起来人畜无害」的递归 nextTick
那道经典面试题:setTimeout(fn,0) 和 setImmediate 谁先?
这是 Node 面试的钉子户,而且答案是「看情况」,这才是它有意思的地方
情况一:写在主模块里
setTimeout(() => console.log('timeout'), 0)setImmediate(() => console.log('immediate'))
这段的输出不确定,可能 timeout 先,也可能 immediate 先。原因是 setTimeout(fn, 0) 实际会被钳到最小 1ms,而进程启动、跑到 timers 阶段要花多少时间是不确定的——如果准备耗时不足 1ms,timer 还没到期,这一轮 timers 阶段就空着过去了,先轮到 check 阶段的 setImmediate;反过来就是 timeout 先。这东西跟你机器当时的负载有关,所以别在生产里依赖它们俩的相对顺序
情况二:写在一个 IO 回调里
const fs = require('node:fs')fs.readFile(__filename, () => { setTimeout(() => console.log('timeout'), 0)setImmediate(() => console.log('immediate'))})
这段就稳定输出 immediate 先、timeout 后,百分百如此。因为这段代码本身就跑在 poll 阶段(IO 回调在这里执行),poll 之后紧接着就是 check 阶段,setImmediate 当轮就被执行;而 setTimeout 得等下一轮循环绕回 timers 阶段。「我想在当前 IO 处理完后,尽快再排一个回调」,正确答案是 setImmediate 而不是 setTimeout(fn, 0),后者还要多等一整圈
别把事件循环和线程池搞混
讲到这必须澄清一个高频误解:不是所有异步都靠事件循环,有一部分靠 libuv 的线程池
libuv 维护一个默认 4 个线程的线程池(可通过环境变量 UV_THREADPOOL_SIZE 调整,上限 1024),按 libuv 官方文档 的说法,它处理那些操作系统没有好的异步接口、或者纯 CPU 密集的活,主要是:
fs.*) DNS 解析里的 dns.lookup(底层是 getaddrinfo) crypto 的一些操作(如 pbkdf2、scrypt) zlib 压缩 而网络 IO(TCP、UDP、HTTP)走的是另一条路——操作系统原生的事件通知机制(epoll / kqueue / IOCP),不占线程池
这个区分有实际后果。线程池只有 4 个线程,意味着如果你同时发起 5 个耗时的文件读取或 pbkdf2,第 5 个必须排队等前面某个线程空出来。我见过一个服务在登录高峰期变慢,profiler 一看,瓶颈居然是密码哈希——pbkdf2 把 4 个线程占满了,后来的请求全在排队。解决就一行:启动前把 UV_THREADPOOL_SIZE 调大。但要注意,这个值必须在 Node 启动之前设好,进程跑起来再改无效
落到实处:不要阻塞事件循环
理解事件循环,最大的实战价值就一句话:执行你 JS 的是单线程,一旦你用一段长时间的同步代码占住它,整个服务的所有请求都得跟着卡
典型的「凶手」:
// 同步读大文件 —— 在这行读完之前,服务处理不了任何其他请求const data = fs.readFileSync('./huge.json')// 一个 O(n²) 的循环处理大数组for (let i = 0; i < arr.length; i )for (let j = 0; j < arr.length; j ) {/* ... */ }// JSON.parse / JSON.stringify 一个几十 MB 的对象,也是同步阻塞const obj = JSON.parse(hugeString)
这些都不是「慢」,而是「在它们跑的时候,事件循环根本转不动」,新进来的 HTTP 请求连被 accept 的机会都没有。判断一段代码会不会阻塞,标准很简单:它是不是同步的、且耗时随输入增长
应对思路:能异步就用异步版本(fs.promises.readFile 而不是 readFileSync);真有 CPU 密集计算,挪到 worker_threads 或拆成小块分批处理,别让任何一段同步逻辑长时间霸占主线程。这部分官方专门有篇 Don't Block the Event Loop 值得一读
小结一张图
把脑子里的模型理顺,大概是这样:
JS 执行是单线程,事件循环由 libuv 驱动,不是 V8 一轮循环依次过 timers → pending → idle/prepare → poll → check → close,业务上盯紧 timers / poll / check 每个阶段之间会清空 nextTick 队列和微任务队列,nextTick 比 Promise 微任务更早 在 IO 回调里想尽快再排一次,用setImmediate;主模块里 setTimeout(0) 与 setImmediate 顺序不保证 文件 / DNS / crypto / zlib 走 4 线程的线程池,网络 IO 走操作系统事件机制 永远别用长同步代码堵住主线程 把这套模型装进脑子,你再看任何「异步顺序为什么是这样」的问题,基本都能自己推出来,而不用每次都跑一遍试
参考来源
The Node.js Event Loop(Node.js 官方)(六阶段、nextTick 与 setImmediate,采集于 2026-06-29) Don't Block the Event Loop(Node.js 官方)(阻塞主线程,采集于 2026-06-29) libuv — Thread pool work scheduling(线程池默认 4、上限 1024、覆盖 fs/dns/crypto/zlib,采集于 2026-06-29) Node.js — Releases(Node 24 为当前 Active LTS,采集于 2026-06-29)