最早在 iOS 开发中接触到 Runloop (也是一种 Event loop),后来发现 Android 也有自己的 Event loop,
再到浏览器 Node.js,都有自己的类 Event loop 实现:
✅ 各平台类 Event loop 对比 (来自 chatGPT)
平台 | 名称/机制 | 本质 |
iOS/macOS | RunLoop | 基于 CFRunLoop |
Node.js / 浏览器 | Event Loop | libuv / Web APIs 管理 |
Android | Looper + Handler | 事件循环 + 消息机制 |
Java SE (GUI) | Event Dispatch Thread (EDT) | 单线程事件模型 |
Linux 系统底层 | epoll/kqueue/select | 系统级事件通知机制 |
Qt 框架 | QEventLoop / QCoreApplication | 消息驱动模型 |
最近再次深入阅读了 Node.js 官方关于 Event loop 的文档:
让我对这个事件循环有了新的认识,Event loop 很值得深入研究和学习。
处理异步编程的模型 : 对比 Golang Goroutine
之前写过一段时间 Go (没有深入研究过),当时大概知道 Golang 通过协程来处理异步执行,总的来说,就是直接将异步任务打包到独立的线程中处理,当然具体就是 Go 的协程 (一种比线程轻量的多线程,解决传统线程上下文开销大的问题)。
go handleConn(conn) { ... } // 哪怕 handleConn 很重,它阻塞的是当前 Goroutine,不会影响其他 Goroutine 的调度运行。
所以 Go 在并发上有天然的优势,开发者只需要开个协程就可以处理异步,其他的 Go 内部帮你处理好了。之所以说天然的优势,需要对比 Node.js 基于 Event loop 的异步处理,这样就能体会到各种异步模型的优缺点。
相比 Go 使用独立的协程来处理异步,Node.js 则是直接使用事件驱动的单线程模型来处理异步。总来的说是这样:
- 异步执行的代码都使用 calback 打包,注册到队列中,等待稍后的 Event loop 处理执行。
- Event loop 将对应的异步分发给不同的底层模块:例如网络 / File 读取 (统称 IO)
- callback 则在队列中排队 (FIFO)
- 当底层 IO 处理完毕,通知到 Event loop 的 poll 阶段 (被 libuv 管理实现),然后在排队执行队列中的各个 callback
- 注意 callback 都是在主线程上执行的 (Event loop 中的各种类型的 callback 都在主线程执行),所以如果有 1000 个并发请求,那么当响应回来后,理论上就会有 1000个 callback 需要排队处理,这个处理过程是单线程的,吞吐效率自然低。所以能体会到为什么说 Go 对与处理并发编程有天然优势了吧,也许 Go 发明的初衷就是解决基于事件驱动模型并发吞吐低的问题 (猜测)。
注意:不是说 Event loop 要同步处理 1000 个网络请求,而是网络请求的 callback 回调。真正的网络请求会由 Network Service 模块处理(内部维护着线程池),由 Event loop 来负责调度。
前端浏览器和 Node.js 的 Event loop 调度模型基本一致,只是阶段上有些区别,例如:
- Node.js 有 process.nextTick,这个比 microTask 微任务执行还要早的队列,在 Web 浏览器中没有这个 Tick,但是有 queueMicroTask。
- Promise 微任务对两者都是一样的,因为 Promise 被 V8 引擎维护和实现,是语言层面的。
- setImmediate 也是 Node.js 专有的。
- 另外 Node.js 更侧重 IO (网络请求和File读取),所以它更看重优先处理 IO,这个在 poll 阶段处理,甚至 Timer (setTimeout/setInterval) 的执行也由 poll 阶段的处理结果来决策。
所以,接下来一步一步深入 Node.js Event loop 的内部来解开 Event loop 的工作模式,和异步处理的设计。
阻塞式和非阻塞式 (blocking & non-blocking)
- 在 Node.js 中,一般认为 CPU 密集型的为非阻塞式操作,可以立即被 CPU 执行。non-blocking
- I/O (网络+File访问)为阻塞式 blocking operation
- 遇到 I/O,事件循环需要暂停 (阻塞等待机制)
- 阻塞式 IO,一般使用 async 调用处理
Event Loop 一次执行总览:
先来结合一段简单的代码分析,对 Event loop 的执行规则有个基本认识:
console.log('script start'); // 进调用栈 setTimeout(() => { // 异步:进 Timer 队列 console.log('setTimeout'); }, 0); Promise.resolve().then(() => { // 异步: 进入 promise 微任务队列 console.log('promise1'); }).then(() => { console.log('promise2'); }); process.nextTick(() => { // 异步: 进入 tick 微任务队列 console.log('nextTick'); }); console.log('script end'); // 进主调用栈 // IMPORTANT: 我们依次分析上边的代码: /*** 第一阶段:调用栈阶段 (同步) ***/ // 1: console.log('script start'); 压栈 -> 执行 -> pop 出栈 // 2: 中间的异步执行都进入各自的队列 ... // 3: console.log('script end'); 压栈 -> 执行 -> pop 出栈 // 到这里 ('script end' 输出后), 主调用栈为空,开始依次检查: /*** 第二阶段:微任务队列 ***/ // 1: Tick 微任务队列 // 2: promise 微任务duilie /*** 第三阶段: 执行 Event loop 下一个阶段 ***/ // 1: 执行 Event loop 下一阶段 // 2: Event loop 某个宏任务阶段开始执行 // 每个阶段的执行,使用和第一、第二阶段相同的步骤: // 主栈执行 --> 主栈清空,开始检查微任务队列执行 --> Event loop 下一阶段 // 输出: script start script end nextTick promise1 promise2 setTimeout
这也是经常说的,事件循环先执行微任务,然后执行宏任务。可内部的具体细节其实非常丰富。
Event loop 是什么
Event loop 是一种阻塞等待的事件模型,并不是死循环,当没有事件的时候,它会自动休眠,等待下次的异步事件唤醒它。
总的来看,Event loop 的设计目的,是将所有的异步操作 (async operations)从主流程卸载,分发给操作系统内核。操作系统内核都是多线程的,处理完对应的耗时任务,处理完,通知 Event loop 注册对应的 callback,准备执行,注意是准备执行,不是立即执行。
下图宏观的展示了 Event loop 的循环阶段:

Event loop 的工作时机 — 栈空,微任务执行情况后
结合上边的图,来看看,具体的执行时机:
- 当当前的调用栈清空(同步代码执行完毕),
- 开始执行微任务队列
- 然后 Eventloop 工作,Event loop 进入下一个工作阶段
- 回到 1 继续循环,没有事件则休眠…
所以,微任务会再每个 Event loop 的阶段末尾都执行一遍,因为每个阶段的 Event loop 都会再次执行函数压栈,执行,出栈,最后栈空,一旦栈空,立即触发执行微任务队列,这也是为什么上图,每个阶段都指向中间的微任务队列的原因。
一个阶段完成后,Event loop 进入下一阶段。如此循环往复。。。
下图来自 Node.js 的官方:

结合上图,依次深入 Event loop 内部的每个阶段:
Timers 阶段
为了整体清晰一些,每个阶段末尾的微任务队列执行忽略,不在这里赘述了,前文已经解释
Timers 这个阶段,主要用来注册检查两类 Timer 事件:
- setTimeout
- setInterval
这里就用 setTimeout 来举例,setTimeout 的延迟执行是不精确的,而是在最小门限值到来的时候执行,这个稍后可以在 setImmediate 中一起解释。
Pending 阶段
- 处理各种异常,错误回调
- 处理上一次 Event loop poll 阶段来不及执行的 callback
idle prepare
- libuv 内部维护和私有调用
- 空闲和准备阶段,开发一般无法调用到
Poll 阶段
这一阶段是 Node.js Event loop 的重点阶段,它主要处理 IO 的回调:
- Network 请求
- File 读取
注意:处理 IO callback 的过程,还有可能有新的 callback (新的网络请求到来)注册到队尾,所以,这一阶段的执行是有一个最大处理队列长度的限制。不然,很容易引起 Event loop 的其他阶段阶段饥饿。
例如,如果没有最大执行队列长度限制,不停的处理网络请求,那么前一个阶段的 Timer 事件很可能早就超时了,后续阶段的其他异步也得不到执行,从而产生其他阶段的饥饿。
这里可以类比,操作系统, 过渡等待 IO 密集型任务,会让 CPU 密集型的任务得不到执行,CPU 长时间空闲,产生 CPU 饥饿。
这一阶段一个很重的执行细节:
- 如果 poll 阶段,需要执行的队列为空,则立即回头检查 Timer 阶段的callback,处理时间到达的 callback。
- 如果此时没有后续阶段的 callback (check阶段),Event loop 就此进入休眠,阻塞等待下次 IO 的到来,被唤醒。
- 如果 poll 阶段,需要执行的队列不为空,则执行最大长度的队列任务后:
- 把剩余的callback加入到之前的 Pending 阶段,下次 Event loop 再执行
- 立即调用 check 阶段 (主要为了执行 setImmediate)
- 检查 Timers 时间到了的 callback,执行 Timers
所以,如果在一次 IO 中,一次调用
setTimeout (fn, 0)
和 setImmediate()
; setImmediate()
总是会先于 setTimeout (fn, 0)
被执行:const fs = require('fs'); fs.readFile(__filename, () => { // IO setTimeout(() => console.log('timeout'), 0); setImmediate(() => console.log('immediate')); }); // 总是输出: immediate timeout
但如果是在主栈中调用:
console.log('start') setTimeout(() => console.log('timeout'), 0); setImmediate(() => console.log('immediate')); console.log('end') // 输出: start end timeout / immediate (先后不确定,要看 CPU 调度)
Check 阶段 (Poll 想进入阻塞前的一个检查)
这一阶段主要处理的就是
setImmediate()
,并且这一阶段,基本上是和 Poll 阶段捆绑的,目的就是让开发者在繁重的 Poll 阶段后,能立即注册一些自己的回调做处理。这么设计的目的:- Poll 阶段结束,检查 check 阶段来收取,是否有额外的 setImmediate 要执行
- 如果没有,Poll 则可以进入阻塞休眠,继续在 Poll 阶段等待 (例如等待网络请求回来后,再醒来)
- 如果开发者注册了执行,那么 Poll 阶段不会进入阻塞等待,而是继续循环执行后续阶段 (check —> close → Timers → …Poll → … )
- 这一定程度会节省性能,避免 Event loop 永远无效的循环等待下去。
Close 阶段
这一阶段,主要处理一些来自系统的异常,关闭对应的服务,例如TCP套接字被系统关闭 socket.destroy(). 这一阶段用来做出通知。如果没有,则直接进入微任务队列检查执行,然后进入到下一次 Event loop:Timers → Pending → idle → Poll → Check …
到这里,整个 Event loop 的各个阶段,循环完成。
理解 Process.nextTick()
这个 tick 也是个异步 API,tick 这个词,很像一次 CPU 的时钟周期,想象钟摆的依次摆动。
这里的 tick 用来形象的比喻一次调用栈的清空,然后紧接着执行一次 tick。然后开始下次调用栈的执行。这个异步有个很有用的场景,保证异步执行的代码能捕获到当前调用栈上下文的变量,例如下边的代码:
const EventEmitter = require('node:events'); class MyEmitter extends EventEmitter { constructor() { super(); this.emit('event'); } } const myEmitter = new MyEmitter(); // on 的时候, this.emit('event') 执行完成,所以无法监听到第一次的 emit myEmitter.on('event', () => { console.log('an event occurred!'); });
我们定义了一个事件发射器类,正常的代码执行,on 无法监听到第一次的 emit:
constructor() { super(); // 这是同步执行,先于 on 监听 this.emit('event'); }
但是如果,我们使用 nextTick() 然发射器在当前调用栈清空的时候执行,那么 on 的监听,就一定能捕获到:
const EventEmitter = require('node:events'); class MyEmitter extends EventEmitter { constructor() { super(); // 在整个函数调用栈执行完,情况后,在emit,执行一定晚于任何地方的 on 的注册; process.nextTick(() => { this.emit('event'); }); } } const myEmitter = new MyEmitter(); myEmitter.on('event', () => { console.log('an event occurred!'); });
Node.js 和 浏览器不同的架构模型对 Event loop 的区别实现
在 Node.js 中, Event loop 被 libuv 这个 c++ 库实现,在浏览器中 Event loop 被浏览器的渲染进程的主线程管理和实现,除了处理定时器,IO,还需要处理一类浏览器界面特有的事件:
- 用户事件:点击 滑动 鼠标等…
- Dom 事件等
另外,Node js 和 浏览器本身的架构也是不同的:
- Node.js 单进程,内部维护多线程
- Event loop 处理 IO 的时候,直接交够 IO 线程池
- 浏览器是多进程架构
- Network Service 服务被浏览器进程单独管理
- File manager 也是一个单独进程
- Event loop 被每个 tab 的渲染进程的主线程管理
- 所以 Event loop 进行 IO 的时候,会和 Network Service 发生 IPC (进程间通信),这一点有别于 Node.js
(本文完,后续计划更新浏览器多进程架构详解系列博客)