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/setInterval
和XHR/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
。其中的原理下面来分析一下。
异步任务执行的时候,有这样一个顺序:
- 执行全局Script代码,如果碰到异步任务,将该任务放入微任务队列中
- 全局Script执行完,执行栈清空
- 从微队列
microtask queue
中取出位于队首的回调任务,放入调用栈Stack中执行 - 一个微任务执行完毕之后,再从微任务队列中取出一个任务放入执行栈执行,若微任务中还有微任务,则放入当前微任务队列末尾
- 微任务队列为空,执行栈也为空,此时从宏任务队列取出一个任务执行,如果其中有微任务,放入微任务队列
- 重复执行3-5步骤...
- 重复执行3-5步骤...
*注:
- 宏任务队列一次只从队列中取一个任务执行,执行完后就去执行微任务队列中的任务;
- 微任务队列中所有的任务都会被依次取出来执行,直到队列为空;
- GUI渲染线程在微任务执行完,执行栈为空,下一个宏任务执行之前执行,。
以上就是浏览器事件循环——event loop。
理解了异步任务的执行顺序之后,再来回顾上面这道题:
console.log('1');
setTimeout(()=>{
console.log('2');
},100);
setTimeout(()=>{
console.log('3');
},0);
console.log('4');
- 执行整个script,
console.log('1')
是同步任务,setTimeout
是宏任务,js引擎线程通知事件触发线程,在定时n秒后存入宏任务队列中,所以先存入console.log('3')
,后存入console.log('2');
; - 执行下一个同步任务
console.log('4');
; - 执行栈为空,查询微任务队列也为空,查询宏任务队列
- 根据先进先出原则,执行
console.log('3');
,后执行console.log('2');
- 输出
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');
- 第一个
setTimeout
放到宏任务队列,此时宏任务队列为['A']
; - 接着执行
obj
的func
方法,将setTimeout
放到宏任务队列,此时宏任务队列为['A', 'B']
- 函数返回一个
Promise
,因为这是一个同步操作,所以先打印出'C'
; - 接着将
then
放到微任务队列,此时微任务队列为['D']
; - 接着执行同步任务
console.log('E');
,打印出'E'
; - 因为微任务优先执行,所以先输出 'D';
- 最后依次输出
['A', 'B']
; - 输出结果:
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
- 首先打印出
script start
- 接着将
settimeout
添加到宏任务队列,此时宏任务队列为['settimeout']
- 然后执行函数
async1
,先打印出async1 start
,又因为Promise.resolve(async2())
是同步任务,所以打印出async2
,接着将async1 end
添加到微任务队列,此时微任务队列为['async1 end']
- 接着打印出
promise1
,将promise2
添加到微任务队列,此时微任务队列为['async1 end', promise2]
- 打印出
script end
- 因为微任务优先级高于宏任务,所以先依次打印出
async1 end
和promise2
- 最后打印出宏任务
settimeout