什么是浏览器的Event Loop

967 阅读8分钟

Event Loop是什么

因为js设计之初,多线程的执行模式还不流行,所以一直以为,js都是单线程执行的。但是js拥有异步执行的能力,这依赖于事件循环(Event Loop)的执行模式。我们将通过js在浏览器中的执行来研究一下该模式。

其中涉及到一些概念,我们先简单研究一下,以便后续更好地了解。

进程和线程

参考阮一峰的解释,将整个CPU比喻为一座工厂,进程就是其中的车间,车间中的需要完成的工序就是线程。一个工厂可以有多个车间,每个车间有一个或者多个工序,但是必须 按照顺序执行,这就是单线程的概念。也是浏览器事件执行的基础。

浏览器渲染过程

浏览器是一个多进程应用,每一个窗口就是一个进程,其中包含以下线程:

  • GUI渲染线程

负责渲染页面,布局和绘制

页面需要重绘和回流时,该线程就会执行

与js引擎线程互斥,防止渲染结果不可预期

  • JS引擎线程

负责处理解析和执行javascript脚本程序

只有一个JS引擎线程(单线程)

与GUI渲染线程互斥,防止渲染结果不可预期

  • 事件触发线程

用来控制事件循环(鼠标点击、setTimeout、ajax等)

当事件满足触发条件时,将事件放入到JS引擎所在的执行队列中

  • 定时触发器线程

setInterval与setTimeout所在的线程

定时任务并不是由JS引擎计时的,是由定时触发线程来计时的

计时完毕后,通知事件触发线程

  • 异步http请求线程

浏览器有一个单独的线程用于处理AJAX请求

当请求完成时,若有回调函数,通知事件触发线程

各个进程之间的关系

  • 同步任务都在js引擎线程上完成,当前的任务都存储在执行栈中;

  • js引擎线程执行到setTimeout/setInterval的时候,通知定时触发器线程,间隔一定时间,触发回调函数;

  • 定时触发器线程在接收到这个消息后,会在等待的时间后,将回调事件放入到由事件触发线程所管理的事件队列(事件队列分为宏任务队列微任务队列)中;

  • js引擎线程执行到XHR/fetch时,通知 异步http请求线程,发送一个网络请求;

  • 异步http请求线程在请求成功后,将回调事件放入到由事件触发线程事件队列中;

  • 如果JS引擎线程中的执行栈没有任务了,JS引擎线程会询问事件触发线程,在 事件队列中是否有待执行的回调函数,如果有就会加入到执行栈中交给JS引擎线程执行;

  • JS引擎线程空闲之后,GUI渲染线程开始工作

各个进程的关系

总结:

  • JS 是可以操作 DOM 的, 因此浏览器设定 GUI渲染线程和 JS引擎线程为互斥关系;

  • setTimeout/setIntervalXHR/fetch代码执行时, 本身是同步任务,而其中的回调函数才是异步任务

  • JS引擎线程只执行执行栈中的事件

  • 执行栈中的代码执行完毕,就会读取事件队列中的事件

  • 事件队列中的回调事件,是由各自线程插入到事件队列中的

  • 如此循环

js如何异步执行

了解了浏览器多线程之间的关联之后,我们开始探究,js是如何依赖Event Loop,进行异步操作的。

执行栈和事件队列

在分析多线程之间的关系时,我们提到了两个概念,执行栈执行队列

执行栈

栈,是一种数据结构,具有先进后出的原则。JS 中的执行栈就具有这样的结构,当引擎第一次遇到 JS 代码时,会产生一个全局执行上下文并压入执行栈,每遇到一个函数调用,就会往栈中压入一个新的上下文。引擎执行栈顶的函数,执行完毕,弹出当前执行上下文

事件队列

事件队列是一个存储着 异步任务 的队列,按照先进先出的原则执行。事件队列每次仅执行一个任务。当执行栈为空时,JS 引擎便检查事件队列,如果事件队列不为空的话,事件队列便将第一个任务压入执行栈中运行。

宏任务和微任务

异步任务又分为宏任务跟微任务、他们之间的区别主要是执行顺序的不同。

宏任务(macrotask)

也叫tasks,一些异步任务的回调会依次进入macro task queue,等待后续被调用,这些异步任务包括:

  • 包括整体代码script
  • setTimeout
  • setInterval
  • requestAnimationFrame

微任务(microtask)

也叫jobs,另一些异步任务的回调会依次进入micro task queue,等待后续被调用,这些异步任务包括:

  • Promise
  • MutationObserver

理解Event Loop

示例

下面上一道很经典的题目:

    console.log('1');
    setTimeout(()=>{
        console.log('2');
    },100);
    setTimeout(()=>{
        console.log('3');
    },0);
    console.log('4');

没有研究event loop之前,答案很可能以为是1 3 4 2,但是实际答案是1 4 3 2。其中的原理下面来分析一下。

异步任务执行的时候,有这样一个顺序:

  1. 执行全局Script代码,如果碰到异步任务,将该任务放入微任务队列中
  2. 全局Script执行完,执行栈清空
  3. 从微队列microtask queue中取出位于队首的回调任务,放入调用栈Stack中执行
  4. 一个微任务执行完毕之后,再从微任务队列中取出一个任务放入执行栈执行,若微任务中还有微任务,则放入当前微任务队列末尾
  5. 微任务队列为空,执行栈也为空,此时从宏任务队列取出一个任务执行,如果其中有微任务,放入微任务队列
  6. 重复执行3-5步骤...
  7. 重复执行3-5步骤...

*注:

  1. 宏任务队列一次只从队列中取一个任务执行,执行完后就去执行微任务队列中的任务;
  2. 微任务队列中所有的任务都会被依次取出来执行,直到队列为空;
  3. GUI渲染线程在微任务执行完,执行栈为空,下一个宏任务执行之前执行,。

以上就是浏览器事件循环——event loop。

理解了异步任务的执行顺序之后,再来回顾上面这道题:

    console.log('1');
    setTimeout(()=>{
        console.log('2');
    },100);
    setTimeout(()=>{
        console.log('3');
    },0);
    console.log('4');
  1. 执行整个script,console.log('1')是同步任务,setTimeout是宏任务,js引擎线程通知事件触发线程,在定时n秒后存入宏任务队列中,所以先存入console.log('3'),后存入console.log('2');
  2. 执行下一个同步任务console.log('4');
  3. 执行栈为空,查询微任务队列也为空,查询宏任务队列
  4. 根据先进先出原则,执行console.log('3');,后执行console.log('2');
  5. 输出1 4 3 2;

实践

再来2道题巩固一下:

一.

    setTimeout(() => {
      console.log('A');
    }, 0);
    var obj = {
      func: function() {
        setTimeout(function() {
          console.log('B');
        }, 0);
        return new Promise(function(resolve) {
          console.log('C');
          resolve();
        });
      },
    };
    obj.func().then(function() {
      console.log('D');
    });
    console.log('E');
  1. 第一个setTimeout放到宏任务队列,此时宏任务队列为['A'];
  2. 接着执行objfunc方法,将setTimeout放到宏任务队列,此时宏任务队列为['A', 'B']
  3. 函数返回一个Promise,因为这是一个同步操作,所以先打印出'C';
  4. 接着将then放到微任务队列,此时微任务队列为 ['D'];
  5. 接着执行同步任务console.log('E');,打印出 'E';
  6. 因为微任务优先执行,所以先输出 'D';
  7. 最后依次输出 ['A', 'B'];
  8. 输出结果:C E D A B

二.

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

await前面的代码是同步的,调用此函数时会直接执行;而await a(); 这句可以被转换成 Promise.resolve(a()); await 后面的代码 则会被放到 Promise.then() 方法里。因此上面的代码可以被转换成如下形式:

    function async1() {
      console.log('async1 start'); // 2
    
      Promise.resolve(async2()).then(() => {
        console.log('async1 end'); // 6
      });
    }
    
    function async2() {
      console.log('async2'); // 3
    }
    
    console.log('script start'); // 1
    
    setTimeout(function() {
      console.log('settimeout'); // 8
    }, 0);
    
    async1();
    
    new Promise(function(resolve) {
      console.log('promise1'); // 4
      resolve();
    }).then(function() {
      console.log('promise2'); // 7
    });
    console.log('script end'); // 5
  1. 首先打印出script start
  2. 接着将settimeout添加到宏任务队列,此时宏任务队列为['settimeout']
  3. 然后执行函数async1,先打印出async1 start,又因为Promise.resolve(async2()) 是同步任务,所以打印出async2,接着将async1 end 添加到微任务队列,此时微任务队列为['async1 end']
  4. 接着打印出promise1,将promise2 添加到微任务队列,此时微任务队列为['async1 end', promise2]
  5. 打印出script end
  6. 因为微任务优先级高于宏任务,所以先依次打印出 async1 endpromise2
  7. 最后打印出宏任务settimeout