阅读 320

【v8引擎】怎么理解事件循环(Eventloop)? 在node中和浏览器中有啥区别?

前言

本文将给大家分享的是JS中了不起的事件循环机制(event loop),通过精读可以了解到浏览器中的eventloop和nodejs中的eventloop 各自的特点和二者的区别,这也是笔者专栏原生JS灵魂拷问的第三篇,后续会持续更新,欢迎关注!(篇幅较长,有表达错误欢迎评论区指出和分享。)

一、什么是eventloop?为啥要去考究它?

众所周知,JS是一门单线程的脚本语言,而Event Loop即事件循环,就是浏览器或nodejs的一种JavaScript单线程运行时处理各种事件却不会造成阻塞的机制,这也是使用异步的原理。那么JS引擎是如何做到单线程处理同步、异步、计时器各类事件而不会造成阻塞的呢?这就是我们考究Event Loop的意义所在。

二、基础知识储备

进程和线程

官方标准:进程是CPU资源分配的最小单位,描述的是CPU在运行指令及加载和保存上下文所需要的时间。线程是CPU调度的最小单位,描述的是执行一段指令所需要的时间。(一个进程可以有多个线程,所以线程是进程中的更小的单位。)

对操作系统来说,线程是最小的执行单元,进程是最小的资源管理单元。

这里可以用一个实例来帮助理解:以Chrome浏览器为例,当新打开一个tab页面,其实就是创建了一个进程。而在该进程中,会有多个线程的存在:渲染线程、JS引擎线程、HTTP请求线程等。

多进程和多线程

  • 1、多进程:在同一时间内,同一个计算机系统如果允许两个或两个以上的进程处于运行状态。多进程的好处是可见的:同时可以工作多个进程,并且是互不干扰的,这可以明显地提高引擎的工作效率。
  • 2、多线程:程序中包含多个执行流,即在一个程序中可以同时运行多个不同的线程来执行不同的任务,即允许同时执行多行JS代码。

这里有个问题值得我们思考:JS为什么不修改为多线程?JS单线程能够带来什么好处?

  • 首先JS单线程带来的效果:

JS引擎在JS运行时会阻塞UI渲染(即JS引擎线程工作完,渲染线程才能工作)

  • JS不修改为多线程的原因

熟悉JS的人都知道,JS代码是可以修改DOM结构。如果JS改成多线程,就会导致,在JS引擎运行时,UI渲染线程也在工作,就可能导致不安全的渲染UI。

  • JS单线程带来的好处
  1. 节省运行内存
  2. 节约上下文切换的时间

微任务(Micro-Task)和宏任务(Macro-Task)

笔者有特意去查过Microtask和Macrotask的概念,但并未在官方文档找到相关描述,因此,二者的定义也无从得知,下文只能通过实例来理解二者概念。望诸位大佬找到后在评论区告知。

这里可以用银行处理业务为例,可以将JS引擎比作一个银行业务员。我们平时去到银行处理业务,首先需要排队拿号,这就是引擎处理线程的任务队列,每个号对应的人就可以看作一个宏任务。当叫到号码对应的人时,即引擎开始处理队列中的一个宏任务;而每个人在窗口可能需要办理多个业务,这些业务其实就是微任务队列,业务员只有把这些微任务完全处理完,才能继续叫号,处理下一个宏任务。(这里主要是形象化微任务和宏任务的概念,二者有个共性:都是需要异步执行的任务

  • 在JS中,哪些属于宏任务?哪些属于微任务?
  1. Micro-Task:process.nextTick,promise,promise.then,MutationObserver
  2. Macro-Task:script,setTimeout,setInterval,setImmediate,I/O,UI渲染
  • 这里还需要了解JS中异步回调的两种机制
  1. 把异步回调函数封装成一个宏任务,添加到消息队列尾部,当循环系统执行到该任务的时候执行回调函数。
  2. 执行时机是在主函数执行结束之后,当前宏任务结束之前执行回调函数,这通常以微任务的形式体现
  • 微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后,当前宏任务结束之前

三、浏览器中的EventLoop

1、浏览器中的事件循环机制运作的几个步骤:

  • 消息的添加:在浏览器中,每当一个事件监听器绑定在该事件上时,一个消息就会被添加到消息队列。

未命名文件 (1).png

  • 消息的处理:一个js运行时包含了一个待处理消息的消息队列,每个消息都关联着一个用以处理这个消息的回调函数。运行时会从最先进入队列的消息开始处理队列中的消息。被处理的消息会被移出队列,再作为输入参数来调用与之关联的函数。调用函数就会创建一个新的栈帧,压入调用栈,函数的处理会一直进行到执行栈为空为止。然后,事件循环才会去处理队列中的下一个消息。

2、举个例子:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});
console.log('script end');
复制代码

第一次执行

执行同步代码,将宏事件和微事件添加到对应的队列。这里先打印script start -> script end ,宏任务队列:setTimeout。微任务队列:promise.then1 , promise.then2。script执行完,执行栈内为空,开始执行微任务队列,将微任务队列中的各项按顺序放入执行栈中执行,直到执行栈中再次为空,此次执行结束。

所以第一次执行后已打印:script start -> script end ->promise1 -> promise2

第二次执行

将setTimeout从宏任务队列中取出,放入栈中执行。执行完后,栈为空,去微任务队列中查找,微任务队列为空,本次执行结束。

所以,最终的打印顺序是:script start -> script end ->promise1 -> promise2 -> setTimeout

3、一图胜千言

16860ae5ad02f993 (注:动态图出自该博文,这大佬太牛了,这图很清晰!)

4、这种事件循环机制的特点及优劣

  • 特性:只有当一个消息完整地执行完成后,其它消息才会被执行。
  • 好处:当一个函数执行时,它不会被抢占,只有在它运行完毕之后才会去运行任何其他的代码,才能修改这个函数操作的数据。
  • 坏处和解决方法:当一个消息需要太长时间才能处理完毕时,Web应用程序就无法处理与用户的交互,例如点击或滚动。为了缓解这个问题,浏览器一般会弹出一个“这个脚本运行时间过长”的对话框。一个良好的习惯是缩短单个消息处理时间,并在可能的情况下将一个消息裁剪成多个消息。

5、总结

  • 浏览器中的事件循环(EventLoop)的执行顺序:
    1. 首先执行同步代码(script脚本文件),这也属于宏任务
    2. 当执行完所有的同步代码后,此时执行栈为空,执行引擎会去查询是否有异步代码需要执行。如果有,执行所有的微任务。
    3. 执行完所有的微任务,此时执行栈再次为空时,如果有必要页面的渲染可以发生在这一步。第一次执行结束。
    4. 第二次执行开始,执行引擎去查询是否有宏任务需要执行。如果有,执行一个宏任务后,即栈再次为空后,再去查询是否有微任务需要被执行,若有,将微任务全部执行;若无,第二次执行完毕。
    5. 再开始下一轮Event-loop,重复上述操作,这就是浏览器中的事件循环机制(Event Loop)

四、nodejs中的EventLoop

1、node中事件循环的官方定义

事件循环是 Node.js 处理非阻塞 I/O 操作的机制——尽管 JavaScript 是单线程处理的——当有可能的时候,它们会把操作转移到系统内核中去。即使目前大多数内核都是多线程的,它们可在后台处理多种操作。当其中的一个操作完成的时候,内核通知 Node.js 将适合的回调函数添加到 轮询 队列中等待时机执行。

Node端的事件循环:Node.js采用V8作为js的解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现(下文会详细介绍)。

2、node中事件循环机制解析

当Node.js启动后,它会初始化事件循环,处理已提供的输入脚本,它可能会调用一些异步的API、调度定时器、或调用process.nextTick(),然后开始处理事件循环。下面的图表展示的就是事件循环操作顺序的简化模型(每个框都被称为事件循环机制的一个阶段)。由此不难看出,node的事件循环机制分为6个阶段,它们会按照顺序反复运行。 16841bd9860c1ee9.png 每个阶段的概述:

  1. 从外部输入数据并进入到轮询阶段(poll):获取新的I/O事件,适当的条件下node将阻塞在这里
  2. 进入检测阶段(check):执行setImmediate()的回调
  3. 进入关闭事件回调阶段(close callback):执行socket的close事件回调
  4. 进入定时器(timers)阶段:这个阶段执行timer(setTimeout,setInterval)的回调
  5. 进入I/O事件回调阶段(I/Ocallbacks):处理用一些上一轮循环中的少数未执行的I/O回调
  6. 进入闲置阶段(idle,prepare):仅在node内部使用
  7. 一轮结束,进入轮询进入下一轮

3、下面将对理解node的事件循环的顺序比较重要的几个阶段进行详细描述:

(1)timers

  • 这个阶段中,主要是进行计时器的回调执行。
  • 计时器(setTimeout,setInterval)有两个参数。第一个参数就是它的回调函数,第二个参数是指定可以执行所提供回调的阈值,而不是用户希望其执行的确切时间。 即在指定的一段时间间隔后,计时器的回调将尽可能早地执行。但是,系统调度或其他正在运行的回调可能会导致计时器的回调超出指定时间才执行。

(2)poll

  • 该阶段的两个功能:
  1. 计算应该阻塞和轮询的时间
  2. 处理轮询队列里的事件

(3)check

  • 此阶段允许人员在轮询阶段完成后立即执行回调。如果轮询阶段变为空闲状态,并且脚本使用 setImmediate() 后被排列在队列中,则事件循环可能继续到 检查 阶段而不是等待。

  • setImmediate() 实际上是一个在事件循环的单独阶段运行的特殊计时器。它使用一个 libuv API 来安排回调在 轮询 阶段完成后执行。

  • 通常,在执行代码时,事件循环最终会命中轮询阶段,在那等待传入连接、请求等。但是,如果回调已使用 setImmediate()调度过,并且轮询阶段变为空闲状态,则它将结束此阶段,并继续到检查阶段而不是继续等待轮询事件。

注意:每个阶段都有各自不同的特点,但相同的是每当事件循环进入一个指定的阶段时,都将先执行特定于该阶段的任何操作,然后执行该队列中的回调,直到队列用尽或最大回调数已执行。当队列已用尽或达到回调限制,事件循环才会移动至下一阶段。

4、举个例子

看完上面那么多的相对官方的解析,如果是初学者,大概已经有点懵了,那么又回到最初的问题:node中的事件循环机制到底是怎么样的顺序执行? 下面结合一个例子来得出这个问题的答案。

setTimeout(()=>{
    console.log('timer1')
    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)
setTimeout(()=>{
    console.log('timer2')
    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)
复制代码

这里需要用Node的两个版本来描述运行顺序和结果:

(1)Node11版本

  • 当Node更新到该版本时,其实可以说node的事件循环机制和浏览器端的相差无几了。在该版本中,一旦执行完一个阶段中的一个宏任务之后,就会立即执行微任务队列,所以它和浏览器端运行的结果是一致的,最后的结果为timer1 -> promise1 -> timer2 -> promise2
  • 该版本的运行顺序为:
    1. 首先执行同步代码(script脚本文件),将2个计时器依次放进timer队列,同步代码执行完毕,调用栈空闲,开始执行任务队列。
    2. 进入timers阶段,执行timer1的回调函数,打印timer1,并将peromise.then回调放入微任务队列,timer1执行完毕,调用栈再次为空,立刻执行微任务队列,打印promise1
    3. 再执行timer2的回调重复操作2
    4. timers阶段执行完毕,事件循环机制进入下一阶段

(2)node10及之前的版本

  • 在node11之前的版本中,事件循环机制的执行顺序和浏览器端还是有很大区别的,主要体现在node在上述6个阶段中,当每次某一个阶段执行完后,再执行微任务队列,所以执行结果为:timer1 -> timer2 -> promise1 -> promise2

1712f2e55556929c.png

  • 在该版本的运行顺序为:
    1. 首先执行同步代码(script脚本文件),将2个计时器依次放进timer队列,同步代码执行完毕,调用栈空闲,开始执行任务队列。
    2. 进入timers阶段,依次执行两个计时器的回调,依次打印timer1,timer2;并将promise1.then 和 promise2.then 依次放入微任务队列,到这里timers阶段执行结束
    3. 执行微任务队列,依次打印promise1 , promise2
    4. 微任务队列执行完后,事件循环进入下一阶段

五、浏览器的EventLoop 和 nodejs的EventLoop的区别是什么?

  • 在Node11之后,其实node端和浏览器端的事件循环机制已经很接近了,如果说要区分的话,我的理解是这二者对每一轮循环的定义还是有差距的:
    • 对浏览器端而言,执行一个宏任务,然后执行微任务队列中的微任务,直到全部执行完,这就完成了一轮。下一轮循环从执行下一个宏任务开始。
    • 对Node11而言,Node的libuv引擎(node中事件循环的官方定义中有提及)将事件循环分为 6 个阶段,在这些阶段中都有宏任务的执行,虽然在每次宏任务执行完,都会立刻执行微任务队列(这与浏览端相同),但执行完微任务队列后,继续执行宏任务,再到微任务,当该阶段的宏任务执行结束,进入下一阶段。这些都属于同一轮循环中的,直到把Node的libuv引擎定义的事件循环的 6 个阶段全跑完,这一轮循环才算结束。

六、最后

  • 在该篇文章中,还有一些知识笔者还没有真正的理解,如:
    1. 对比process.nextTick() 和 setImmediate()
    2. 对比setImmediate()和setTimeout()
    3. 为什么要使用process.nextTick()
    等笔者有了自己的理解后,也会在该文章上继续更新。并且,笔者最近在做一件事情:将学习所得通过归纳总结为文章,再通过文章来搭建自己的知识体系,本篇文章被收录到笔者的原生JS灵魂发问专栏中,后面也会持续更新,感兴趣的小伙伴可以持续关注,和笔者一起学习,共同进步。此外,若对该篇文章或者笔者的表达有什么建议的话,当然欢迎各位大佬在评论区指出!!!

参考文章

文章分类
前端
文章标签