前言
这应该是一个系列文章,因为我觉得Event Loop(事件循环)是一件很抽象也很重要的一个机制。eventloop这个知识点处于非常杂糅的位置,和很多其他知识,如运行时、浏览器、渲染流程、数据结构、线程等等,也是正确理解JS异步编程的核心。所以很值得展开来娓娓道来。
⚠️强烈建议学完JS异步语法知识后再来学习EventLoop。
JS是单线程的编程语言
JS是单线程的编程语言,重要的事情说三遍,单线程,单线程,单线程。接下来请读者思考一下,单线程是什么意思?
单线程意味着JavaScript在同一时刻只能执行一个任务或操作。也就是说,它一次只能处理一个事件或指令,不会同时处理多个任务。
那我就不禁要问,单线程如何实现异步编程,这不是开玩笑吗,how,轮询?回调?这不麻烦死了。能实现基本操作还好,但有稍微复杂点的该如何呢。举一个例子,setTimeout
的时候为什么还可以执行其他代码,谁在那边倒数时间?执行其他任务和进行记时任务,这俩件事情是如何在所谓单线程的JS中同时执行的。蒙了吗,我就不禁要怀疑JS是不是单线程了。
直到我去了解了EventLoop,EventLoop就是在保证JS是单线程的特点的同时,让其拥有高效实现复杂异步的能力。
Eventloop避免了阻塞和性能瓶颈。如果没有事件循环,虽然理论上可以通过轮询、回调等方式实现异步编程,但这些方式都存在效率低、开发复杂度高等问题,且无法像事件循环那样高效地协调和管理多个异步操作。
简单介绍完了Event Loop的重要性,那么我们要开始正式认识它了。
Runtime运行时
我先说结论,JavaScript 本身是单线程的,但它的 运行环境(runtime)(如浏览器或 Node.js)提供了 事件循环(Event Loop),并通过调用 底层的多线程 API(如浏览器的 Web APIs 或 Node.js 的 libuv 线程池)来实现异步操作。
那为什么浏览器能运行JS代码,就是因为浏览器内置了解析JS代码的工具,以Chromium浏览器内核为例,就是使用了大名鼎鼎的V8引擎。而eventloop实际上和runtime关系更密切的,本文就先以浏览器的Event Loop为中心,理解完浏览器后,下一章再介绍nodejs、deno、bun这些其他的“Runtime”
浏览器提供的runtime
看这幅图,大概就明白JS引擎和运行时的关系了吧,它们之间是包含关系,而EventLoop并不是在JS引擎里实现的。
我们先不关注EventLoop的具体细节,乘胜追击简单了解一下浏览器的架构。
浏览器是多进程+多线程架构的
现代Chorme浏览器是多进程架构,一个页面就是一个进程,而一个进程可以有多个线程。通过多个线程协作来完成不同的任务,包括 JavaScript 执行、页面渲染、I/O 操作等。通常,以下是常见的几个关键线程:
-
主线程:负责执行 JavaScript 代码、渲染页面、响应用户输入等。它与事件循环机制紧密配合。
-
渲染线程(Rendering Thread):专门负责页面渲染。它从 DOM 和 CSSOM 构建渲染树(Render Tree),然后进行页面布局、绘制和合成。渲染线程会独立于主线程运行,但也需要与主线程进行一定的交互(例如,当 DOM 更新时,渲染线程需要重新绘制)。
-
网络线程(Network Thread):负责处理网络请求,例如发起 HTTP 请求(如
fetch
或XMLHttpRequest
)以及接收服务器响应。它通常与主线程和渲染线程协作,但它运行在独立的线程中,不会阻塞 JavaScript 的执行。 -
I/O 线程(I/O Thread):处理与操作系统相关的 I/O 操作(如文件操作、网络操作等)。它的作用是将 I/O 操作的阻塞部分从主线程中分离出来,避免长时间的阻塞导致浏览器卡顿。
-
Web Worker:Web Worker 是一个额外的线程,通常用于执行耗时的计算任务,以防止阻塞主线程。Web Worker 线程之间通过消息传递与主线程或其他 Worker 进行通信。
有了宏观的视角,读者应该能猜出来了吧,JS就是通过调用WebAPIs,将I/O、Network、Render等极有可能消耗时间的任务甩锅给了浏览器的其他线程去执行,真坏啊,摸鱼技能+1。
理解为啥会堵塞
咱们先理解单核CPU,单核CPU其实就类似于在一个在单一轨道上行驶的超跑,在闲置的时间(EventQueue和RenderTask没任务,这俩都是我自己乱取名的嗷),就一直在黑色的轨道上一圈圈慢慢的跑。
当任务来了,需要CPU去进行处理的时候,这辆跑车就会进入蓝色或黄色轨道,去进行计算或读写操作。可能会对为什么CPU要去处理渲染任务有疑惑,这点和浏览器的渲染流程有关系,我直接简短告诉结论:渲染需要CPU先进行运算操作,后交由GPU去进行后续的处理,即需要二者配合,没有CPU的参与就会出现渲染堵塞的问题。
接下来我们在把注意力集中在EventQueue上,EventQueue,我自己这么叫它的,顾名思义,就是以类似于排队的感觉。各种事件和任务一个一个的进入队列中,然后等待CPU处理,处理完就滚蛋了,处理不完就会一直拖着CPU,不让它离开,像个事佬,其他任务处理不了,只能等着,这就是所谓的堵塞。
任务从哪来
让我们一窥全貌,call me灵魂画师🫡👍
接下来的重点就是左上角的那部分
两种数据结构
JS引擎维护一个CallStack回调栈,顾名思义,就一个栈结构,遵循先进后出(FILO)。而Runtime运行时维护一类TaskQueue,顾名思义,就是一个队列结构,遵循先进先出(FIFO)。
动画模拟感兴趣的可以看看这网站
CallStack
先关注CallStack,又是顾名思义啊,为什么叫Call,其实就是执行回调函数(CallBack)。这点其实不是我们这片文章的重点,简单的讲一下,就是应该是所有编程语言吧,同步代码都是以回调栈这种形式去执行代码。
其实我感觉,可以比较粗暴的讲一切操作理解为“同步”,因为以回调栈的视角去看,无非是有任务来了就按照Stack的形式(FILO)去执行操作回调操作。
WebAPIs
https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Client-side_APIs/Introduction#what_can_apis_do
https://developer.mozilla.org/zh-CN/docs/Web/API
WebAPIs有很多,感兴趣的可以自己去详细了解,我摘记了一段MDN的介绍
客户端 JavaScript 中有很多可用的 API。它们本身并不属于 JavaScript 语言,却建立在核心 JavaScript 语言之上,为使用 JavaScript 代码提供额外的超强能力。它们通常分为两类:
- 浏览器 API 内置于 Web 浏览器中,能从浏览器和电脑周边环境中提取数据,并用来做有用的复杂的事情。例如,Web 音频 API 为在浏览器中处理音频提供了 JavaScript 结构——获取音轨、改变音量、应用特效等。在后台,浏览器实际上使用了一些复杂的低级代码(如 C++ 或 Rust)来进行实际的音频处理。但同样,这种复杂性已被应用程序接口抽象化。
- 第三方 API 缺省情况下不会内置于浏览器中,通常必须在 Web 中的某个地方获取代码和信息。例如,Google Maps API 使你能够执行诸如在网站上显示办公室的交互式地图之类的操作。它提供一系列特殊的结构,可以用来查询 Google 地图服务并返回特定信息。
看看就好,这也提醒了我们,像是我们常用的DOM操作、fetch、setTimeout、setInterval都是通过WebAPIs来实现的,也就是说这些功能在其他Runtime中就不一定有,比如在NodeJS中就没有DOM操作。
TaskQueue
接下来就到了任务队列了
任务队列在浏览器环境中又区分微任务队列和宏任务队列,之所以区分,我猜测是为了对事件有更加精细的操作,就如NodeJS则甚至会有6个不同的阶段,当然NodeJS还不是本文的重点。
宏任务(Macro-task)
宏任务指的是宿主环境(如浏览器或 Node.js)提供的任务,通常与用户交互、I/O、定时器等相关。
通常是一些消耗时间比较多,计算量比较大的任务。
常见的宏任务
setTimeout
setInterval
setImmediate
(Node.js 专有)- I/O 操作(如文件读取、数据库查询)
比较特殊的宏任务
requestAnimationFrame
(浏览器专有)- UI 渲染任务(浏览器)
之所以说特殊,因为UI渲染的频率是和屏幕的刷新频率有直接关系的,也就是说屏幕如果说60hz的,在主线程不堵塞的情况下,就会每1/60秒去执行一次渲染任务,而如果个任务堵塞时间过长,就会将渲染任务延迟,甚至直接丢失(也就是会发生所谓的掉帧、丢帧)
特点
每次事件循环(Event Loop)都会从任务队列(Task Queue)中取出一个宏任务执行。
微任务(Micro-task)
常见的微任务
Promise.then()
、catch()
、finally()
MutationObserver
(浏览器专有)queueMicrotask()
(现代浏览器和 Node.js)
特点
微任务的优先级高于宏任务,JS 引擎会立即清空所有的微任务队列(Micro-task Queue)中的任务。
执行顺序
已经了解了CallStack 和 TaskQueue,那么了解EventLoop就是易如反掌,接下来死记硬背一下规则就行了,没有为什么,就是一个人为规定的固定顺序
- 执行同步代码(包括变量声明、函数调用等)
- 执行所有微任务
- 取出并执行一个宏任务
- 重复步骤 2 和 3
这就是浏览器里的EventLoop
重新理解堵塞
微微的callback一下,从执行循序可以知道,
1.如果同步代码中执行的任务计算量太大,就会难以清空CallStack,堵塞TaskQueue里异步任务的执行。
2.如果微任务过多,就会堵塞宏任务的执行
3.如果单个任务执行时间超过两倍屏幕刷新率
就会出现丢帧
后记
下次分享我应该会分享一下NodeJS的EventLoop,如果我时间的话,会顺手讲解一下Deno和Bun的EventLoop,当然这俩个目前没啥必要学,只是为了加深一下读者们“EventLoop由Runtime构建的而不是JS引擎”的这个重要观点。