EventLoop

180 阅读7分钟

这是我参与8月更文挑战的第13天,活动详情查看: 8月更文挑战”juejin.cn/post/698796…

介绍

EventLoop的运行机制在浏览器端还是Node端都在使用,虽然机制不同,但是都利用了JS语言单线程和非阻塞的特点。

EventLoop流程

JS在单线程上执行所有操作,虽然是单线程却有多线程的感受,实际是通过使用一些比较合理的数据结构来达到该效果。

  • 调用堆栈(call stack)负责各种所有要执行的代码

    • 每个函数执行完成后,就会从堆栈中pop弹出;有其他代码需要进去执行,就进行push进入。
  • 事件队列(event queue)负责将新的函数发送到队列中进行处理

    • 遵循队列的数据结构特点:先进先出。按照顺序发送操作以供执行。
  • 每当调用事件队列中的异步函数时,都会将其发送到浏览器API。

    • 根据从调用堆栈收到的命令,API开始自己的单线程操作。
    • 如setTimeout,在堆栈中处理setTimeout操作时,会将其发送到相应的API,该API一直等到指定的时间将此操作送回到事件队列进行处理。如此就有了一个循环的系统,用于进行异步操作。
    • 循环系统:堆栈处理setTimout操作,传递给对应的API,API等到指定时间之后把操作送回事件队列,时间队列检查堆栈是否为空,为空就把操作推进去执行。
  • JS是单线程,而浏览器API充当单独的线程。

    • 事件循环会不断的检查调用堆栈是否为空。如果为空,就从事件队列中添加新的函数进入调用堆栈;如果不为空,则处理当前函数的调用。

CioPOWBHaz-AIvXzAAMjXUqLjBw024.png

EventLoop内部

内部通过两个队列来实现Event Queue 放进来的异步任务。

  • 以setTimeout为代表的任务称为宏任务,放在宏任务队列(macrotask queue)

    • script(整体代码),
    • setTimeout/setInterval,
    • setImmediate,
    • I/O,
    • UI rendering,
    • event listner
  • 以Promise为代表的任务称为微任务,放在微任务队列(microtask queue)

    • process.nextTick,
    • Promise,
    • Object.observe,
    • MutationObserver

EventLoop处理逻辑

  • JS引擎首先从宏任务队列取出第一个任务
  • 执行完毕后,再将微任务队列中所有任务取出,按照顺序分别执行;如果这一步过程中产生新的微任务,也要执行
  • 再从宏任务队列中取出一个,执行完毕后,再讲微任务队列中全部取出执行。循环往复,直到两个队列中任务都取完。

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

宏任务和微任务的运行机制

代码执行顺序一

console.log('begin');  // 这里是同步代码
setTimeout(() => {
    // 异步代码
  console.log('setTimeout')
}, 0);
new Promise((resolve) => {
    // 这里是同步代码
  console.log('promise');
  resolve()
}).then(() => {
    console.log('then1');  // 异步代码
  }).then(() => {
    console.log('then2');   // 异步代码
  });
console.log('end');  // 这里是同步代码

begin
promise
end
then1
then2
setTimout

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

  • 执行栈选择最先进入队列的宏任务,执行其同步代码直至结束;
  • 检查是否有微任务,如果有则执行直到微任务队列为空;
  • 如果是在浏览器端,那么基本要渲染页面了;
  • 开始下一轮的循环(tick),执行宏任务中的一些异步代码,例如 setTimeout 等。

宏任务

浏览器环境下,宏任务主要分为几大类:

  • 渲染事件(如解析DOM、计算布局、绘制)
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小)
  • setTimeout、setInterval
  • 网络请求完成、文件读写完成事件

宏任务基本满足日常开发需求,但不满足对于时间精度有要求的情况,比如渲染事件、I/O操作、用户交互事件等,随时都可能被插入到消息队列中。JS无法控制任务在队列中插入的位置所以很难控制任务开始执行的时间。

微任务

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

JS执行脚本时,V8会为其创建一个全局执行上下文,同时也会创建一个微任务队列用来存放微任务。在执行当前宏任务的过程中有可能胡产生多个微任务,微任务队列无法通过JS直接访问。

微任务产生方式

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

微任务执行时机

在当前宏任务中的JS快执行完成时,即为JS引擎准备退出全局执行上下文并清空调用栈的时候,JS引擎会检查全局上下文中的微任务队列,然后按照顺序执行队列中的微任务。如果执行微任务的过程中,产生了新的微任务,一样会将该微任务添加到微任务队列中进行执行,直到队列清空才算结束。在执行微任务过程中产生的新的微任务并不会推迟到下一个循环中执行,而是在当前的循环中继续执行。

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

MutationObserver监听DOM变化

早期页面并没有对页面监听的支持,需要对DOM变阿虎进行观察时唯一能做的就是轮询检测。比如使用setTimeout/setInterval来定时检测DOM是否有变化。但会有两个问题:

  • 时间间隔设置过长,DOM变化响应不及时;
  • 时间间隔设置过短,会浪费无用的工作量来检查DOM,页面性能减低

MutationObserver API可以用来监视DOM变化,包括属性变更、节点增加、内容改变等。在两个任务之间,可能会被渲染进程插入其他事件,从而影响到响应的实时性;在DOM节点发生变化时,渲染引擎将变化记录封装成微任务并添加到当前的微任务队列中。

异步+微任务的策略:

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

代码执行顺序二

async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}
async function async2() {
  console.log("async2");
}
async1();
setTimeout(() => {
  console.log("timeout");
}, 0);
new Promise(function (resolve) {
  console.log("promise1");
  resolve();
}).then(function () {
  console.log("promise2");
});
console.log("script end");

async1 start
async2
promise1
script end
async1 end
promise2
timeout
  • await后面如果不是 promise 对象, await会阻塞后面的代码,先执行async外面的同步代码,同步代码执行完,再回到async内部,把这个非promise的东西,作为 await表达式的结果。
  • await后面如果是 promise 对象,await 也会暂停async后面的代码,先执行async外面的同步代码,等着 Promise 对象 fulfilled,然后把 resolve 的参数作为 await 表达式的运算结果。
  • 将await形式代码改为Promise.形式代码,或许会更加容易理解。
async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}
async function async2() {
  console.log("async2");
}
async1();

// 已上代码改为Promise行式为
console.log("async1 start");
new Promise(function (resolve) {
    new Promise(function (resolve) {
        console.log("async2");
        resolve();
    })
    resolve()
}).then(function(){
   console.log("async1 end");
})

代码执行顺序三

setTimeout(function () {
  console.log('6')
}, 0)
console.log('1')
async function async1() {
  console.log('2')
  await async2()
  console.log('5')
}
async function async2() {
  console.log('3')
}
async1()
console.log('4')

// 1 2 3 4 5 6
  • 6是宏任务在下一轮事件循环执行
  • 先同步输出1,然后调用了async1(),输出2。
  • await async2() 会先运行async2(),5进入等待状态。
  • 输出3,这个时候先执行async函数外的同步代码输出4。
  • 最后await拿到等待的结果继续往下执行输出5。
  • 进入第二轮事件循环输出6。