Skip to content

Eventloop

YinPeng edited this page Sep 19, 2023 · 2 revisions

前言

我们先了解浏览器端的 Eventloop 的运行机制,然后去探索Node.js 的 Eventloop 的底层逻辑。在过程中会发现无论是浏览器端还是服务端,都在使用 Eventloop,虽然两者机制不同,但都利用了 JavaScript 语言的单线程和非阻塞的特点。

浏览器的 Eventloop

Eventloop 是 JavaScript 引擎异步编程背后需要特别关注的知识点。JS 在单线程上执行所有操作,虽然是单线程,但是能够高效地解决问题,并能给我们带来一种“多线程”的错觉,这其实是通过使用一些比较合理的数据结构来达到此效果的。我们来看下 JavaScript 引擎背后都有哪些东西在同时运转。

**1.调用堆栈(call stack)负责跟踪所有要执行的代码。**每当一个函数执行完成时,就会从堆栈中弹出(pop)该执行完成函数;如果有代码需要进去执行的话,就进行 push 操作,如下图所示:

堆栈

**2.事件队列(event queue)负责将新的 function 发送到队列中进行处理。**它遵循 queue 的数据结构特性,先进先出,在该顺序下发送所有操作以进行执行。如下图所示:

queue

**3.每当调用事件队列(event queue)中的异步函数时,都会将其发送到浏览器 API。**根据从调用堆栈收到的命令,API 开始自己的单线程操作。其中 setTimeout 方法就是一个比较典型的例子,在堆栈中处理 setTimeout 操作时,会将其发送到相应的 API,该 API 一直等到指定的时间将此操作送回进行处理。它将操作发送到哪里去呢?答案是事件队列(event queue)。这样,就有了一个循环系统,用于在 JavaScript 中运行异步操作。

**4.JavaScript 语言本身是单线程的,而浏览器 API 充当单独的线程。**事件循环(Eventloop)促进了这一过程,它会不断检查调用堆栈是否为空。如果为空,则从事件队列中添加新的函数进入调用栈(call stack);如果不为空,则处理当前函数的调用。我们把整个过程串起来就是这样的一个循环执行流程,如下图所示:

eventloop

通过上面这张图就能很清晰地看出调用栈、事件队列以及 Eventloop 和它们之间相互配合的关系。

看完了 JS 引擎的全局流程图,那么 Eventloop 的内部都有哪些东西呢?

简单来说 Eventloop 通过内部两个队列来实现 Event Queue 放进来的异步任务。以 setTimeout 为代表的任务被称为宏任务,放到宏任务队列(macrotask queue)中;而以 Promise 为代表的任务被称为微任务,放到微任务队列(microtask queue)中。

来看一下日常工作中经常遇到的哪些是宏任务,哪些是微任务。

macrotasks(宏任务): 
script(整体代码),setTimeout,setInterval,setImmediate,I/O,UI rendering,event listner
microtasks(微任务): 
process.nextTick, Promises, Object.observe, MutationObserver

这里列举了主要的宏任务和微任务。 Eventloop 在处理宏任务和微任务的逻辑是有些不一样的,执行的情况大致如下:

  1. JavaScript 引擎首先从宏任务队列(macrotask queue)中取出第一个任务;
  2. 执行完毕后,再将微任务(microtask queue)中的所有任务取出,按照顺序分别全部执行(这里包括不仅指开始执行时队列里的微任务),如果在这一步过程中产生新的微任务,也需要执行;
  3. 然后再从宏任务队列中取下一个,执行完毕后,再次将 microtask queue 中的全部取出,循环往复,直到两个 queue 中的任务都取完。

📚 总结:一次 Eventloop 循环会处理一个宏任务和所有这次循环中产生的微任务

Node.js 的 Eventloop

关于在 Node.js 服务端 Eventloop,Node.js 官网是这么描述的:

When Node.js starts, it initializes the event loop, processes the provided input script (or drops into the REPL, which is not covered in this document) which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.

简单翻译过来就是:当 Node.js 开始启动时,会初始化一个 Eventloop,处理输入的代码脚本,这些脚本会进行 API 异步调用,process.nextTick() 方法会开始处理事件循环。下面就是 Node.js 官网提供的 Eventloop 事件循环参考流程。

nodejs_eventloop

整个流程分为六个阶段,当这六个阶段执行完一次之后,才可以算得上执行了一次 Eventloop 的循环过程。我们来分别看下这六个阶段都做了哪些事情。

  • Timers 阶段:这个阶段执行 setTimeout 和 setInterval。
  • I/O callbacks 阶段:这个阶段主要执行系统级别的回调函数,比如 TCP 连接失败的回调。
  • idle,prepare 阶段:只是 Node.js 内部闲置、准备,可以忽略。
  • poll 阶段:poll 阶段是一个重要且复杂的阶段,几乎所有 I/O 相关的回调,都在这个阶段执行(除了setTimeout、setInterval、setImmediate 以及一些因为 exception 意外关闭产生的回调),这个阶段的主要流程如下图所示。

poll

  • check 阶段:执行 setImmediate() 设定的 callbacks。
  • close callbacks 阶段:执行关闭请求的回调函数,比如 socket.on('close', ...)。

除了把 Eventloop 的宏任务细分到不同阶段外。node 还引入了一个新的任务队列 Process.nextTick()。根据官方文档的解释

process.nextTick()is not technically part of the event loop. Instead, thenextTickQueuewill be processed after the current operation is completed, regardless of the current phase of the event loop. Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.

可以认为,Process.nextTick() 会在上述各个阶段结束时,在进入下一个阶段之前立即执行(优先级甚至超过 microtask 队列)。

Node.js 和浏览器端宏任务队列的另一个很重要的不同点是,浏览器端任务队列每轮事件循环仅出队一个回调函数接着去执行微任务队列;而 Node.js 端只要轮到执行某个宏任务队列,则会执行完队列中所有的当前任务,但是当前轮次新添加到队尾的任务则会等到下一轮次才会执行。

EventLoop 对渲染的影响

想必你之前在业务开发中也遇到过 requestIdlecallback 和 requestAnimationFrame,当你开始考虑它们在 Eventloop 的生命周期的哪一步触发,或者这两个方法的回调会在微任务队列还是宏任务队列执行的时候,才发现好像没有想象中那么简单。这两个方法其实也并不属于 JS 的原生方法,而是浏览器宿主环境提供的方法,因为它们牵扯到另一个问题:渲染

我们知道浏览器作为一个复杂的应用是多线程工作的,除了运行 JS 的线程外,还有渲染线程、定时器触发线程、HTTP 请求线程,等等。JS 线程可以读取并且修改 DOM,而渲染线程也需要读取 DOM,这是一个典型的多线程竞争临界资源的问题。所以浏览器就把这两个线程设计成互斥的,即同时只能有一个线程在执行。

渲染原本就不应该出现在 Eventloop 相关的知识体系里,但是因为 Eventloop 显然是在讨论 JS 如何运行的问题,而渲染则是浏览器另外一个线程的工作。但是 requestAnimationFrame 的出现却把这两件事情给关联起来,你可以看下 RAF 的英文解释:

requestAnimationFrame()method tells the browser that you wish to perform an animation and requests that the browser calls a specified function to update an animation before the next repaint.

通过调用 requestAnimationFrame 我们可以在下次渲染之前执行回调函数。那下次渲染具体是哪个时间点呢?渲染和 Eventloop 有什么关系呢?我们在 HTML协议对 Eventloop 的规范 里找到了答案。简单来说,就是在每一次 Eventloop 的末尾,判断当前页面是否处于渲染时机,就是重新渲染。而这个所谓的渲染时机是这样定义的:

Rendering opportunities are determined based on hardware constraints such as display refresh rates and other factors such as page performance or whether the page is in the background. Rendering opportunities typically occur at regular intervals.

有屏幕的硬件限制,比如 60Hz 刷新率,简而言之就是 1 秒刷新了 60 次,16.6ms 刷新一次。这个时候浏览器的渲染间隔时间就没必要小于 16.6ms,因为就算渲染了屏幕上也看不到。当然浏览器也不能保证一定会每 16.6ms 会渲染一次,因为还会受到处理器的性能、JavaScript 执行效率等其他因素影响。

回到 requestAnimationFrame,这个 API 保证在下次浏览器渲染之前一定会被调用,实际上我们完全可以把它看成是一个高级版的 setInterval。它们都是在一段时间后执行回调,但是前者的间隔时间是由浏览器自己不断调整的,而后者只能由用户指定。这样的特性也决定了 requestAnimationFrame 更适合用来做针对每一帧来修改的动画效果。

当然 requestAnimationFrame 不是 Eventloop 里的宏任务,或者说它并不在 Eventloop 的生命周期里,只是浏览器又开放的一个在渲染之前发生的新的 hook。另外需要注意的是微任务的认知概念也需要更新,在执行 animation callback 时也有可能产生微任务(比如 promise 的 callback),会放到 animation queue 处理完后再执行。所以微任务并不是像之前说的那样在每一轮 Eventloop 后处理,而是在 JS 的函数调用栈清空后处理。

但是 requestIdlecallback 却是一个更好理解的概念。当宏任务队列中没有任务可以处理时,浏览器可能存在“空闲状态”。这段空闲时间可以被 requestIdlecallback 利用起来执行一些优先级不高、不必立即执行的任务,如下图所示:

requestIdlecallback

当然为了防止浏览器一直处于繁忙状态,导致 requestIdlecallback 可能永远无法执行回调,它还提供了一个额外的 timeout 参数,为这个任务设置一个截止时间。浏览器就可以根据这个截止时间规划这个任务的执行。

探究宏任务 & 微任务的运行机制

宏任务和微任务的执行顺序基本是,在 EventLoop 中,每一次循环称为一次 tick,主要的任务顺序如下:

执行栈选择最先进入队列的宏任务,执行其同步代码直至结束;

检查是否有微任务,如果有则执行直到微任务队列为空;

如果是在浏览器端,那么基本要渲染页面了;

开始下一轮的循环(tick),执行宏任务中的一些异步代码,例如 setTimeout 等。

结合这个结论,以及 EventLoop 的内容,来看下它们的运转流程效果图。

EventLoop运转流程

Call-Stack(调用栈)也就是执行栈,它是一个栈的结构,符合先进后出的机制,每次一个循环,先执行最先入队的宏任务,然后再执行微任务。不管微任务还是宏任务,它们只要按照顺序进入了执行栈,那么执行栈就还是按照先进后出的规则,一步一步执行。

因此根据这个原则,最先进行调用栈的宏任务,一般情况下都是最后返回执行的结果。那么从上面的代码中可以看到 setTimeout 的确最后执行了打印的结果。

这就是宏任务和微任务代码夹杂的情况下,代码的执行顺序。

下面我们来看看宏任务到底有哪些,有什么值得关注的点。

宏任务

如果在浏览器的环境下,宏任务主要分为下面这几个大类:

  • 渲染事件(比如解析 DOM、计算布局、绘制);

  • 用户交互事件(比如鼠标点击、滚动页面、放大缩小等);

  • setTimeout、setInterval 等;

  • 网络请求完成、文件读写完成事件。

为了让这些任务在主线程上执行,页面进程引入了消息队列和事件循环机制,我们把这些消息队列中的任务称为宏任务。宏任务基本上满足了日常的开发需求,而对于时间精度有要求的宏任务就不太能满足了,比如渲染事件、各种 I/O、用户交互的事件等,都随时有可能被添加到消息队列中,JS 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。

🌰 Demo

function callback2(){
    console.log(2)
}

function callback(){
    console.log(1)
    setTimeout(callback2,0)
}

setTimeout(callback,0)

在上面这段代码中,目的是想通过 setTimeout 来设置两个回调任务,并让它们按照前后顺序来执行,中间也不要再插入其他的任务。但是实际情况我们难以控制,比如在调用 setTimeout 来设置回调任务的间隙,消息队列中就有可能被插入很多系统级的任务。如果中间被插入的任务执行时间过久的话,那么就会影响到后面任务的执行了。所以说宏任务的时间粒度比较大,执行的间隔是不能精确控制的。这就不适用于一些高实时性的需求了,比如监听 DOM 变化。

微任务

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。

当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,同时 V8 引擎也会在内部创建一个微任务队列。这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。不过这个微任务队列是给 V8 引擎内部使用的,所以是无法通过 JavaScript 直接访问的。

那么微任务是怎么产生的呢?在现代浏览器里面,产生微任务有两种方式。

  1. 使用 MutationObserver 监控某个 DOM 节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
  2. 使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。

通过 DOM 节点变化产生的微任务或者使用 Promise 产生的微任务都会被 JS 引擎按照顺序保存到微任务队列中。现在微任务队列中有了微任务,那么接下来就要看看微任务队列是何时被执行的。

通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就是在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。

如果在执行微任务的过程中,产生了新的微任务,一样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列清空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下一个循环中执行,而是在当前的循环中继续执行,这点是需要注意的。

📚 总结一夏

  1. 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
  2. 微任务的执行时长会影响当前宏任务的时长。比如一个宏任务在执行过程中,产生了 10 个微任务,执行每个微任务的时间是 10ms,那么执行这 10 个微任务的时间就是 100ms,也可以说这 10 个微任务让宏任务的执行时间延长了 100ms。
  3. 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。

监听 DOM 变化应用场景

MutationObserver 是用来监听 DOM 变化的一套方法。

虽然监听 DOM 的需求是比较频繁的,不过早期页面并没有提供对监听的支持,所以那时要观察 DOM 是否变化,唯一能做的就是轮询检测。比如使用 setTimeout 或者 setInterval 来定时检测 DOM 是否有改变。这种方式简单粗暴,但是会遇到两个问题:如果时间间隔设置过长,DOM 变化响应不够及时;反过来如果时间间隔设置过短,又会浪费很多无用的工作量去检查 DOM,会让页面变得低效。

从 DOM 4 开始,W3C 推出了 MutationObserver。MutationObserver API 可以用来监视 DOM 的变化,包括属性的变更、节点的增加、内容的改变等。因为上面我们分析过,在两个任务之间,可能会被渲染进程插入其他的事件,从而影响到响应的实时性。这时候,微任务就可以上场了,在每次 DOM 节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。这样当执行到检查点的时候,V8 引擎就会按照顺序执行微任务了。

综上所述,MutationObserver 采用了“异步 + 微任务”的策略:

  • 通过异步操作解决了同步操作的性能问题;
  • 通过微任务解决了实时性的问题。

📚 总结

宏任务 微任务
相应的方法事件 1. script
2.setTimeout/setInterval
3.UI rendering/UI 事件
4.postMessage,MessageChannel
5.setImmediate(Node.js)
1. Promise
2. MutaionObserver
3.Object.observe(Proxy对象替代)
4. Process.nextTick(Node.js)
运行顺序 后运行 先运行
是否触发新一轮Tick 🙅🏻‍♀️