Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

说一说JavaScript的事件循环机制(Event Loop) #17

Open
JCHappytime opened this issue Feb 24, 2021 · 0 comments
Open

说一说JavaScript的事件循环机制(Event Loop) #17

JCHappytime opened this issue Feb 24, 2021 · 0 comments

Comments

@JCHappytime
Copy link
Owner

JCHappytime commented Feb 24, 2021

参考文章

1. 【前端体系】从一道面试题谈谈对EventLoop的理解
2. JavaScript 运行机制详解:再谈Event Loop
3. 详解JavaScript中的Event Loop(事件循环)机制
4. 再话js的事件循环机制
5. JavaScript中的Event Loop(事件循环)机制

前言:为什么 JavaScript 是单线程语言?(单线程+非阻塞)

JavaScript的单线程与它的用途紧密相关。作为浏览器的脚本语言,JavaScript 的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。如果说JavaScript同时又2个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这个时候浏览器该如何处理呢?
所以,为了避免复杂性,从一开始,JavaScript就是单线程,这已经成为了这门语言的核心特点,未来也不会改变。
为了利用多核CPU的计算能力,HTML5提出了WEB Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,并且不允许操作DOM。所以这个标准并没有改变JavaScript单线程的本质。

任务队列

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。但是很多时候CPU是处于空闲状态的,因为IO设备很慢,比如说Ajax获取数据,这个时候就不得不等到结果出来之后,再往下继续执行。
这个时候JavaScript的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到设备返回了结果,再回过头,把挂起的任务继续执行下去。
因此,所有的任务分成了2种,一种是同步任务(synchronous),一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务是指,不进入主线程、而进入“任务队列”(task queue)的任务,只有任务队列通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
具体的运行机制如下:

  • 1.所有同步任务都在主线程上执行,形成一个执行栈;
  • 2.主线程外,还存在一个任务队列,只要异步任务有了运行结果,就在“任务队列”之中放置一个事件;
  • 3.一旦“执行栈”中的所有同步任务执行完成,系统就会读取“任务队列”,看看里面有哪些事件,哪些对应的异步任务,于是结束等待状态,进入执行栈,开始执行;
  • 4.主线程不断重复上面三个步骤。
    只要主线程空了,就会去读取“任务队列”,这就是JavaScript的运行机制,整个过程都会不断重复。

事件与回调函数

  • “任务队列”是一个事件的队列/消息的队列,IO设备完成一项任务,就在任务队列中添加一个事件,表示相关的异步任务可以进入执行栈了。主线程读取任务队列,就是读取里面有哪些事件。
  • 任务队列中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(如:鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入任务队列,等待主线程读取。所谓的回调函数就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
  • 任务队列是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,任务队列上第一位的事件就自动进入主线程。但是,由于存在“定时器”功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。

Event Loop

主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。如下图所示:
事件循环
上图,在主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码会调用各种外部的API,它们在“任务队列”中加入各种事件(click, done, load)。一旦栈中的代码执行完成,主线程就会去读取“任务队列”,一次执行其中的事件对应的回调函数。执行栈中的代码(同步任务),总是在读取“任务队列”(异步任务)之前执行。

  • Heap(堆):是线性数据结构,相当于一维数组,是一种动态存储的结构。在js中,Heap是动态分配的内存大小不定,也不会自动释放,保存指向对象的指针。其作用是为了存储引用类型的值的数据。
  • Stack(栈):它会自动分配内存和自动释放占据固定大小的空间,其作用主要是:存放基本类型和简单的数据段,如:string, number, boolean等等;提供代码的执行环境。

总结起来:Event Loop会循环不断的去拿宏任务队列中最老的一个任务,推入队列中执行,并在当次循环里一次执行并清空microtask队列里的任务,执行完microtask队列里的任务之后,更新渲染。也就是:

  • 检查 Macrotask 队列是否为空,若不为空,则进行下一步,若为空,则跳到3
  • 从 Macrotask 队列中取队首(在队列时间最长)的任务进去执行栈中执行(仅仅一个),执行完后进入下一步
  • 检查 Microtask 队列是否为空,若不为空,则进入下一步,否则,跳到1(开始新的事件循环)
  • 从 Microtask 队列中取队首(在队列时间最长)的任务进去事件队列执行,执行完后,跳到3
    其中,在执行代码过程中新增的microtask任务会在当前事件循环周期内执行,而新增的macrotask任务只能等到下一个事件循环才能执行了。

宏任务与微任务

这两种任务都是在任务队列的异步事件中注册的回调函数。那为什么要引入宏任务与微任务,只有一种类型的任务可不可以呢?

页面渲染事件,各种IO的完成事件等随时被添加到任务队列中,一直会保持先进先出的原则执行,我们不能准确地控制这些事件被添加到任务队列中的位置。但是这个时候突然有高优先级的任务需要尽快执行,那么一种类型的任务就不合适了,所以引入了微任务队列。不同的异步任务被分为:宏任务和微任务。

宏任务:

  • script(整体代码)
  • setTimeout()
  • setInterval()
  • postMessage
  • I/O
  • UI交互事件

微任务:

  • new Promise().then(回调)
  • MutationObserver(html5新特性)

运行机制

异步任务的返回结果会被放到一个任务队列中,根据异步事件的类型,这个事件实际上会被放到对应的宏任务与微任务队列中去。在当前执行栈为空时,主线程会查看微任务队列是否有事件存在:

  • 存在。依次执行队列中的事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的事件,把当前的回调加到当前执行栈。
  • 不存在。再去宏任务队列中取出一个事件,并把对应的回调加到当前执行栈。
    当前执行栈执行完毕后,会立刻处理所有的微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。
    在事件循环中,每进行一次循环操作称为tick,每一次tick的任务处理模型都是比较复杂的,但关键的步骤主要是:
  1. 执行一个宏任务(栈中没有就从事件队列中获取)
  2. 执行过程如果遇到微任务,就将它添加到微任务的任务队列中;
  3. 宏任务执行完毕之后,立即执行当前微任务队列中的所有微任务(依次执行);
  4. 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染;
  5. 渲染完成后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)。

宏任务和微任务优先问题

为了方便理解,我们可以认为在任务队列里面有宏任务和微任务;宏任务有多个,微任务只有一个。执行顺序应该是:

  1. 从开始执行调用一个全局执行栈,script标签作为宏任务执行;
  2. 执行过程中同步代码立即执行,异步代码放到任务队列中,任务队列存放2种来兴的异步任务,宏任务队列,微任务队列。
  3. 同步代码执行完毕也就意味着第一个宏任务执行完毕(script)
    3.1 先查看任务队列中的微任务队列是否存在宏任务执行过程中所产生的的微任务:①如果有,就将微任务队列中的所有微任务清空;②微任务执行过程中所产生的微任务放到微任务队列中去,在此次执行中一并清空。
    3.2 如果没有再看看宏任务队列中有没有宏任务,有的话执行,没有的话事件轮训第一波结束。执行过程中所产生的微任务放到微任务队列中,完成宏任务之后执行清空微任务队列的代码。

所以总体是:宏任务优先,在宏任务执行完毕之后才会来一次性清空任务队列中的所有微任务。

再来看一道经典的题目

// 代码一执行就开始执行了一个宏任务-宏0
console.log('script start');

setTimeout(() => {   // 宏1
  console.log('setTimeout');
}, 1* 2000)

Promise.resolve().then(function() {
  console.log(promise1);   // 微1-1
})
.then(function() {
  console.log(promise2);   // 微1-4,这个then中的会等待上一个then执行完毕之后得到其他状态才会Queue注册状态对应的回调,假设上一个then中主动抛出错误且没有被捕获,那就注册的是这个then中的第二个回调了。
})

async function foo() {
  await bar();    // await是promise的语法糖,会异步等待获取其返回值
                       //  后面的代码可以理解为放到异步队列微任务中。
  console.log('async1 end');
}

foo();

function bar() {
  console.log('async2 end');
}

async function errorFunc() {
  try {
    await Promise.reject('error!');
  }.catch(e) {
    // 从这后面开始,所有的代码可以理解为放到微任务队列中去
    console.log(e);   // 微1-3
  }
  console.log('async1');
  return Promise.resolve('async1 success');
}
errorFunc().then(res => console.log(res));   // 微1-5

console.log('script end');

PS:注意一点就是Promise.then().then(),在注册异步任务的时候,第二个then中的回调是依赖第一个then中回调的结果的,如果执行没有异常才会在该异步任务执行完成之后注册状态对应的回调。

第一次执行

全局一个宏任务执行,输出同步代码。挂载宏1,微1-1, 微1-2,微1-3, 微1-4。1-表示属于第一次轮询。输出的结果是:

script start->async2 end->script end

第二次执行

同步代码执行完毕,开始执行异步任务中的微任务队列中的代码。
微任务队列:只有一个队列且会在当前轮询一次性清空。

执行微1-1:promise1
执行微1-2:async1 end
执行微1-3:error!, async1。当前异步回调执行完毕才Promise.resolve('async1 success'),然后注册.then()中的成功的回调:微1-5
执行微1-4:promise2
执行刚注册的微1-5:async1 success

到这里,第一波轮询结束。

第三次执行

开启第二波轮询:执行宏1

run: setTimeout

到此为止,整个轮询结束。其实相对难以理解的也就是微任务,对于微任务也就是上面说的只有一个队列会在此次轮询中一次清空(包括此次执行过程中所产生的微任务)。
整个打印的顺序为:script start -> async2 end ->script end -> promise1 -> async1 end -> error! -> async1 -> promise2 -> async1 success -> setTimeout

深入

通过上面的分析,再来看一些类似的题目。

console.log(1)
setTimeout(function() {
  //settimeout1
  console.log(2)
}, 0);
const intervalId = setInterval(function() {
  //setinterval1
  console.log(3)
}, 0)
setTimeout(function() {
  //settimeout2
  console.log(10)
  new Promise(function(resolve) {
    //promise1
    console.log(11)
    resolve()
  })
  .then(function() {
    console.log(12)
  })
  .then(function() {
    console.log(13)
    clearInterval(intervalId)
  })
}, 0);

//promise2
Promise.resolve()
  .then(function() {
    console.log(7)
  })
  .then(function() {
    console.log(8)
  })
console.log(9)

打印顺序是:1 -> 9 -> 7 -> 8 -> 2 -> 3 ->10 -> 11 -> 12 -> 13
解析:

  1. 第一次事件循环
  • console.log(1)执行,输出1;
  • setTimeout1执行,加入macrotask队列;
  • setInterval1执行,加入macrotask队列;
  • setTimeout2执行,加入macrotask队列;
  • promise2执行,它的两个then函数加入microtask队列;
  • console.log(9)执行,输出9;
  • 根据事件循环的定义,接下来会执行新增的microtask任务,按照进入队列的顺序,执行console.log(7)和console.log(8),输出7和8;microtask队列为空回到第一步,进入下一个事件循环,此时macrotask队列为:setTimeout1,setInterval1,setTimeout2。
  1. 第二次事件循环
  • 从macrotask队列里取出位于队首的任务(setTimeout1)并执行,输出2;
  • microtask队列为空,回到第一步,进入下一个事件循环,此时macrotask队列为:setInterval1,setTimeout2。
  1. 第三次事件循环
  • 从macrotask队列里取出位于队首的任务setInterval1并执行,输出3;
  • 然后又将新生成的setInterval1加入macrotask队列,microtask队列为空,回到第一步,进入下一个事件循环,此时macrotask队列为:setTimeout2,setInterval1。
  1. 第四次事件循环
  • 从macrotask队列里取出位于队首的setTimeout2并执行,输出10,并且执行new Promise内的函数(new promise内的函数是同步操作,并不是异步操作),输出11,并将它的2个then函数加入microtask队列;
  • 在microtask队列中,取队首的任务执行,知道为空为止。因此2个新增的microtask任务按顺序执行,输出12和13,并且将setInterval1清空。此时,microtask队列和macrotask队列都为空,浏览器会一直检查队列是否为空,等待新的任务加入队列。

注意:由于在执行microtask任务的时候,只有当microtask队列为空的时候,它才会进入下一个事件循环,因此,如果它源源不断地产生新的microtask任务,就会导致主线程一直在执行microtask任务,而没有办法执行macrotask任务,这样我们就无法进行UI渲染/IO操作/ajax请求了,因此,我们应该避免这种情况发生。在nodejs里的process.nexttick里,就可以设置最大的调用次数,以此来防止阻塞主线程。

问题思考

  1. 为什么在第一次Event Loop时,不先执行macrotask,因为按照流程的话,不应该是先检查macrotask队列是否为空,再检查microtask队列吗?
    因为一开始Js主线程中跑的任务就是macrotask任务,而根据事件循环的流程,一次事件循环只会执行一个macrotask任务,所以执行完主线程的代码之后,它就去从microtask队列里面取队首的任务来执行。

  2. 定时器是否是真实可靠的呢?比如我执行一个命令:setTimeout(task, 1000),他是否就能准确的在1000毫秒后执行呢?
    实根据以上的讨论,我们就可以得知,这是不可能的。
    因为你执行setTimeout(task,100)后,其实只是确保这个任务,会在100毫秒后进入macrotask队列,但并不意味着他能立刻运行,可能当前主线程正在进行一个耗时的操作,也可能目前microtask队列有很多个任务,所以这也可能是大家一直诟病setTimeout的原因吧。

@JCHappytime JCHappytime changed the title 说一说JavaScript的事件循环机制 说一说JavaScript的事件循环机制(Event Loop) Feb 26, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant