JavaScript系列 -- event loop 事件轮询

596 阅读13分钟

前言

  • JavaScript是单线程语言,一个时刻只能执行一段代码。打个比方:去银行办理业务(小银行,只有一个柜台),一个柜台的队员一次只能处理一个人的业务;
  • 同时JavaScript语言又存在异步任务,上面的代码还没执行完成,下面的代码就已经开始执行了。打个比方:就是说一个人还没完全办理完业务时,另外其他人已经准备办理业务了,但是必须得等前面的人的业务办理完成了才能上;
  • 如果此时出现一个情况:有一段代码执行时间特别长(ajax请求后台数据),就好比有一个人的业务的处理需要的时间特别长(或者业务特别多),那么后面的人越积越多,就容易造成“拥塞”的现象,而且引发混乱的结果;
  • 如何提高效率呢:① 现实中我们会另外派多几个人专门去处理那些业务较多、办理时间较长的人,处理完成后直接在总台消去这个人的号就行。在JavaScript中对应的就是宏任务微任务;而业务少的、办理时间短的人就按序排队在柜台办理就行,在JavaScript中对应的就是主线程的同步任务
  • 如何处理混乱呢:现实中我们是采用“取号排队”来解决这个问题的,万事讲究个“先来后到”嘛。在JavaScript中也是一样,采用了“先进先出”的任务队列模式 —— event loop;

在 ① 中提到多派几个“人”,其实对应到浏览器中就是多建几个独立的线程。浏览器是多进程的,其中的渲染进程是核心,这个进程是多线程的:包括GUI线程、JS引擎线程、定时器线程、事件触发线程、异步请求线程。其中后三个可以理解为是多派出的那几个“人”

执行上下文栈

我们通过例子来说明 执行上下文栈

function fn1(){
    console.log(2)
    console.log(3)
}
function fn2(){
    console.log(1)
    fn1()
    console.log(4)
}
fn2();
      执行栈              执行栈              执行栈               执行栈              执行栈
|                |  |                |  |                |  |                |  |                |
|                |  | console.log(1) |  |      fn1()     |  |                |  | console.log(4) |
|      fn2()     |  |      fn2()     |  |      fn2()     |  |      fn2()     |  |      fn2()     |
------------------  ------------------  ------------------  ------------------  ------------------
1. fn2()入栈
2. console.log(1)入栈执行后出栈
3. fn1()入栈
4. fn1()里的代码全部执行完后出栈
5. console.log(4)入栈执行后出栈
6. 最终栈空,表示`主线程的同步任务`执行完毕

同步任务和异步任务

我们知道浏览器的渲染进程包括五个线程:(1)GUI渲染线程;(2)JS引擎线程;(3)事件触发线程;(4)定时器线程;(5)异步的http网络请求线程

而 JS 是单线程语言,所以如果存在运行时间比较长的任务(如:定时器、DOM事件、异步请求等),如果把这些任务视为同步任务,那将会造成很严重的“阻塞现象”,所以浏览器把这些任务归为异步任务,交给其他线程(如:定时器线程、事件处理线程、异步请求线程等)去做,最后只需要把最终结果告诉 JS 引擎线程就行

  • 同步任务就好比是日常生活的刷牙、洗脸、吃饭
  • 异步任务就好比是日常家居里的烧水炉、洗衣机,我们在刷牙、洗脸、吃饭的时候它们也可以同时在工作,而我们只要它们工作完的最终结果
  • 同步任务是指在主线程上执行的任务,同步任务会在调用栈中按照顺序等待主线程一个一个的执行,同一时刻只能执行一个任务,当前任务完成了才继续下个任务。
打个比方:上学时候起床,刷牙、洗脸、吃早饭,你不可能边刷牙边吃早饭吧,肯定得刷完牙后再去吃早饭

同步任务包括:

  • DOM 元素渲染
  • DOM 节点操作
  • 异步任务是指主线程以外,进入任务队列的任务,只有任务队列通知主线程,某个异步任务可以执行了,该任务才会进入主线程,当我们打开网站时,像图片的加载,音乐的加载,其实就是一个异步任务
打个比方:起床后你需要煮水备用,这时你可以开启炉子烧水后去吃早饭,不用傻傻的做在那等水烧开后才去吃早饭

异步任务包括:

  • 各种DOM 事件(如:onclick、onsubmit...等等)
  • setTimeout、setInterval 定时器
  • Ajax
  • Promise
  • async
  • 两者关联:主线程外的线程会在异步任务有了结果后,将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),被读取到栈内等待主线程的执行
打个比方:你吃完早饭后,炉子发出滴滴声,你就知道水烧开了,你就可以用水壶把水装起来了

异步任务又分为:宏任务和微任务

通俗理解: 去银行办理业务,得排队取号,念到号的人上前去办理业务。由于办理业务需要一些时间,所以对于柜员来说,前来办理业务的客户就是柜员的宏任务,由于当前柜台只有一个,所以必须等当前办理业务的客户是业务完成后才能让下一个人上。

一个宏任务在执行的过程中,是可以添加一些微任务的。就好比:如果这位客户除了想办理存款这个主业务外,还想购买一些理财产品办一张信用卡,这些就属于微任务。而下一个客户必须得等上一个客户的全部业务办完才能这样上。也就是说,一个宏任务的执行可能会伴随着微任务的诞生。而我们需要把上一个宏任务所产生的所有微任务全部执行完,才会去执行下一个宏任务

  • 常见的宏任务:setTimeoutsetInterval各种DOM 事件(如:onclick、onsubmit等)、Ajax
  • 常见的微任务:Promise.then.catchasync、process.nextTick、MutationObserver

为什么要多弄一个微任务的队列呢,参考 JS为什么要区分微任务和宏任务?得出这样一个猜测:

可能是为了在下一个宏任务到来之前要(插队)提前做一些必要的任务。比如宏任务队列里面有多个定时器执行后的结果,此时宏任务队列输出一个值了可能就不需要后面的定时器去执行了,所以在上一个宏任务执行的过程中用Pormise...try...引出微任务去执行clearTimeout()清除后面的定时器。

宏任务和微任务的根本区别

  • 宏任务是 浏览器 规定的;(我的理解是就像定时器线程是浏览器的其中一个线程一样)
  • 微任务是 ES6语法 规定的;(我的理解是因为JS代码里存在Promiseasync

浏览器中的 event loop

event loop大体由三个部分组成:执行栈宏任务队列微任务队列。这三个部分在event loop中非常重要

image.png

event loop 的运行机制

  1. 所有同步任务都在JS线程上执行,形成一个执行栈
  2. JS线程之外,还存在定时器线程、事件处理线程、异步请求线程,它们负责处理异步任务,只要异步任务有了结果,就会在宏任务队列微任务队列中放置一个执行(任务完成)后的结果
  3. 一旦执行栈中的所有同步任务执行完毕,系统就会先执行微任务队列里的任务,然后执行宏任务队列里的一个宏任务,期间有可能会引发出新的微任务,所以会再次指向微任务队列里的任务,然后再回来执行下一个宏任务。这就有个 Loop 的过程。

我们先看 只有宏任务 的情况,再看 有宏任务和微任务 的情况

只有宏任务

function fn(){
    console.log(1)
    setTimeout(function(){
        console.log(3)
    },0)
    console.log(2)
}
fn();
      执行栈             执行栈              执行栈             执行栈             执行栈
|                | |                | |                | |                | |                |
|                | | console.log(1) | | console.log(2) | |                | |                |
|      fn()      | |      fn()      | |      fn()      | |      fn()      | |                |
-----------------  ------------------  -----------------  -----------------  -----------------

    宏任务队列           宏任务队列          宏任务队列           宏任务队列          宏任务队列
------------------  ------------------  ------------------  ------------------  ------------------
                                         `定时器正在计时`      console.log(3)
------------------  ------------------  ------------------  ------------------  ------------------
1. fn()入栈
2. console.log(1)入栈执行后出栈
3. 遇到`setTimeout`,主线程将任务下发给定时器线程。该线程完成计时后把结果放在`宏任务队列`里面
4. console.log(2)入栈执行后出栈
5. 栈空,表示主线程的同步任务执行完毕,开始执行宏任务队列里的`任务结果`
6. console.log(3)出队,入栈执行后出栈

这就是setTimeout()有时会出现“延时”的原因

再看一个经典例子(这道题涉及了异步、作用域、闭包)

for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}  // 输出: 3 3 3

这里输出3 3 3的原因是:3次循环里依次创建了3个定时器,各个定时器各自计时,3个执行结果按序排在宏任务队列里,等到执行栈空时依次执行console.log(对应的i),因为退出循环时i=3,而且由于var和let之间有无块级作用域的区别:

{
    var i = 3
}
{
    console.log(i) // 会被影响到
}
{
    let i = 3
}
{
    console.log(i) // 不会被影响到
}

导致到最后执行宏任务队列里的任务时i的值会不会被影响到,显然用var声明的变量会影响到后面的输出

既有宏任务,又有微任务

let p = new Promise(function(resolve){  
    console.log(1)
    resolve()
})
function fn(){
    p.then(funtion(){
	console.log(4)
    })
    console.log(2)
    setTimeout(function(){
        console.log(5)
    },0)
    console.log(3)
}
fn();
      执行栈              执行栈              执行栈               执行栈              执行栈
|                |  |                |  |                |  |                |  |                |
|                |  | console.log(2) |  | console.log(3) |  |                |  |                |
| console.log(1) |  |      fn()      |  |      fn()      |  |      fn()      |  |                |
------------------  ------------------  ------------------  ------------------  ------------------

    宏任务队列           宏任务队列          宏任务队列           宏任务队列          宏任务队列
------------------  ------------------  ------------------  ------------------  ------------------
                                        `定时器线程在执行中`   console.log(5)
------------------  ------------------  ------------------  ------------------  ------------------

    微任务队列           微任务队列          微任务队列           微任务队列          微任务队列
------------------  ------------------  ------------------  ------------------  ------------------
                                         `外部线程执行then`    console.log(4)
------------------  ------------------  ------------------  ------------------  ------------------
1. 创建一个Promise对象p,其中执行了console.log(1)(入栈执行后出栈),然后Promise状态变为resolved
2. 进入fn(),首先遇到Promise对象的then()方法,将其放入微任务队列执行(执行结果是consolelog(4))
3. console.log(2)入栈执行后出栈
4. 遇到setTimeout,将其放入宏任务队列执行(执行结果是consolelog(5))
5. console.log(3)入栈执行后出栈,
6. 栈空,先将微任务队列的所有执行结果按序出队后,入栈执行后出栈
7. 再将宏任务队列里的一个宏任务的执行结果按序出队后,入栈执行后出栈。`此时宏任务可能又会产生微任务`
8. 所以执行一个宏任务后要回去检查一下有无微任务,有则要将微任务队列的全部结果执行完才继续处理下一个宏任务
【所以才会有一个 `loop` 的过程】

event loop 什么时候开始触发?

  1. 当同步代码执行完成,call stack清空之后
  2. 执行当前的微任务
  3. 尝试 DOM 渲染
  4. 最后触发 event loop 机制

为什么微任务比宏任务执行的早?

  1. 微任务:DOM渲染前触发,如Promise
  2. 宏任务:DOM渲染后触发,如setTimeOut

思考题巩固

例子 1

new Promise(resolve => {
    resolve(1);
    Promise.resolve().then(() => console.log(2));
    console.log(4);
}).then(t => console.log(t));
console.log(3);

1. 首先new Promise执行,resolve(1)表示创建的promise对象的`状态变为resolved`
2. Promise.resolve()相当于创建了一个promise对象,then里面的`匿名回调函数进入微任务队列`
此时的微任务队列是[() => console.log(2)]
3. 输出 4
4. new Promise的then函数里面的`匿名回调函数进入微任务队列`
此时的微任务队列是[() => console.log(2), t => console.log(t)]
5. 输出 3

所以,最后输出的顺序是 4 3 2 1

例子 2

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

看到 async / await 不必紧张,这里有个秘诀:

  • async 表示函数里有异步操作,await 之前的代码该怎么执行怎么执行,await 右侧表达式照常执行,后面的代码被阻塞掉,等待 await 的返回
  • 返回是非 promise 对象时,执行后面的代码;
  • 返回的是 promise 对象时,等promise对象resolved时再执行。所以可以理解成后面的代码放到了promise.then 里面 对这块不是很熟的同学可以看看这篇文章:JavaScript系列 -- Promise、Generator、async及await
  1. 输出 script start
  2. 浏览器会在 100 ms 之后把setTimeout里面的匿名回调函数丢进宏任务队列 (请记得丢进任务队列里的是回调函数,函数!)
宏任务队列:[() => console.log('setTimeout')]
  1. 输出 async1 start
  2. 输出 async2
  3. 要输出 async1 end 代码被丢进微任务队列
微任务队列:[() => console.log('async1 end')]
  1. 输出promise1
  2. promise 对象状态变为 resolved
  3. promise.then 里的匿名函数进入微任务队列,此时的微任务队列为['async1 end', 'promise2']
微任务队列:[() => console.log('async1 end'),() => console.log('promise2')]
  1. 输出 script end
  2. 执行栈空
  3. 输出 async1 end
  4. 输出 promise2
  5. 微任务队列为空
  6. 输出 setTimeout

值得注意的是:

  • 我们在最开始 new Promise 的时候就已经执行 Promise 里面的箭头函数的代码了
  • 而 async 函数 test() 只有在 test().then().catch() 的时候才被调用,test() 函数里面的代码才开始被执行
  • await 后面如果跟的是 Promise 对象,则会等待该对象里面的函数执行完成;如果跟的不是 Promise 对象则会直接执行其后面的代码 / 直接等于后面的值,不会将其加入微任务队列

例子 3(new Promise 里面是同步任务,得走到 then() 才把里面的代码是放入微任务队列)

setTimeout(function(){
    console.log('timeout');
})
new Promise(function(resolve){
    console.log('promise1');
    for(var i = 0; i < 1000; i++) {
         i == 99 && resolve();
    }
    console.log('promise2');
}).then(function(){
    console.log('then');
})
console.log('global')
  1. console.log('timeout1') 放入宏任务队列
  2. console.log('promise1') 先执行(new 里面的同步任务),所以打印出 1. promise1
  3. console.log('promise2') 执行,打印出 2. promise2
  4. console.log('then1'),then()里面的都放入微任务队列
  5. console.log('global1') 为同步任务,所以打印 3. global
  6. 执行栈空,微任务队列出队,打印 4. then,此时微任务队列空
  7. 宏任务队列出队,打印 5.timeout,此时宏任务队列为空

例子 4(setTimeout 里面是一个整体,整块任务放入宏任务队列)

console.log("start")
setTimeout(()=>{
    console.log("timer1")
    Promise.resolve().then(function(){
        console.log("promise1")
    })
    console.log("timer2")
},0)
setTimeout(()=>{
    console.log("timer3")
    Promise.resolve().then(function(){
        console.log("promise2")
    })
},0)
console.log("end")

把每一个 setTimeout 里面的所有代码看成是一块整体:

console.log("timer1")
Promise.resolve().then(function(){
    console.log("promise1")
})
console.log("timer2")

所以在这一块整体里面的执行顺序为:1. timer1;2. timer2;3.promise1;

同理在另一个定时器里面的所有代码的执行顺序为:1.timer3;2.promise2

然后两个定时器里面的整体任务都放在宏任务队列里面

关于输出顺序,有规律可循:

  • promise.then 里面、await 下面的任务都是微任务,settimeout 里面是宏任务
  • 先执行所有微任务再执行一个宏任务,再执行所有微任务,... ,有个 loop 过程

了解 event loop 机制后,那我们怎么实现异步编程呢?

JavaScript系列 -- Promise、Generator、async及await

参考文章