带你了解JavaScript的异步
一、异步的由来
异步编程常见于如JavaScript一类的单线程语言,用于解决由单线程导致的执行效率低下的问题。
单线程语言是相对于多线程语言(如Java)而言的。多线程语言具备同时利用CPU的多个内核并行执行程序的能力,代码执行效率较高。多线程语言通常采用同步编程模型,即任何一个任务都必须等待它的上一个任务执行完毕后才可以执行,这种编程模型更方便组织代码逻辑和划分线程任务。
而单线程语言则无法利用CPU的多核优势,它的主线程在同一时间只能执行一个任务。这样,一旦遇到非常耗时的任务(特别是像网络I/O这样的任务),执行引擎就必须停下等待I/O响应,因此而处于长时间的空闲状态,这种现象在单线程语言中称为线程阻塞。
这里我们所说的任务是异步任务,它是一些必须依赖其他进程(或线程)才能完成的任务,如数据库查询(需要依赖数据库驱动程序)、网络I/O(需要依赖网络接口)、文件读写(需要依赖文件系统)等。与之对应的是同步任务,它可以由当前执行引擎自身直接完成。同步模型和异步模型的差异就体现在对异步任务的处理上:同步模型遇到异步任务时会停下等待任务执行完毕;而异步模型则不等待,它会继续执行其他代码,等待异步任务完成后通过回调函数的形式回头处理它。
线程阻塞出现的标志是主线程因为要等待某个事件的发生而暂时无法执行后续任务。以网络I/O为例,假如主线程向服务器发起了一个ajax请求,在使用同步模型的情况下,在ajax请求响应到来之前,js引擎是不能执行任何任务的,即发生了阻塞。并不是说多线程语言就不存在线程阻塞,只是多线程语言的并行优势可以抵消这个性能损耗,但在单线程语言中,这样的阻塞是致命的。
为了解决这个问题,单线程语言通常采用异步的编程模型。此时执行引擎不会等待该请求得到响应才执行后续任务,而是将其交给I/O线程(I/O线程会在收到响应后向任务队列中注册一个回调函数),随后继续执行后续任务。当主程序执行完毕,并且响应到达之后,执行引擎再从任务队列中取出对应的回调函数来执行。两种模型的示意图如下:
需要注意的是,我们说JavaScript采用单线程模型,并不是说它只能拥有一个线程,而是说它的主线程只有一个。除了主线程,浏览器还为它提供了I/O线程、定时器线程等,来相对独立地处理异步任务。但是这些线程并不参与主程序的执行,因此只能算是辅助线程,所以JavaScript还是一门单线程语言。
图中遇到异步任务时先交给其他线程,等任务完成后把函数推入事件队列,引擎再回头去调用的模式称为回调函数模式,它是异步编程最重要的思维方式。
JavaScript之所以采用单线程异步非阻塞模型,一方面是因为前端程序通常不是CPU密集型的,它不需要处理大量的运算(同步任务),而是更多地需要处理I/O请求和用户操作这类的异步任务,这类场景并不是多线程语言所擅长的;另一方面,js引擎运行在浏览器环境下,操作系统不会分配其太多的资源,所以需要采用资源消耗更少的单线程模型。
下面我们来详细探讨异步编程。
二、异步模型解析
1. 宏任务、微任务和事件循环
对JavaScript来说,异步任务分为两类:宏任务(macro task)和微任务(micro task)。在ES6中,宏任务又称为tasks,而微任务则称为jobs。
宏任务主要包括以下几类:
- 网络I/O,如ajax事件
- setTimeout/setInterval/setImmediate(nodejs)等定时器任务
- requestAnimationFrame,请求动画帧
微任务主要包括以下几类:
- process.nextTick(nodejs),nodejs提供的注册微任务的方法
- MutationObserver,DOM变动事件,如click监听
- Promise.then/catch/finally,为Promise注册的回调函数
宏任务和微任务对应的回调函数分别被存放在两个队列内,在同一个事件循环中,微任务优先于宏任务执行。我们后面会介绍什么是事件循环。
先看一个典型的异步示例:
console.log('程序开始!');
// 异步任务1
setTimeout(() => {
console.log('这是一个定时器任务!');
}, 0);
// 异步任务2
axios.get('xxx').then(res => {
console.log('这是一个ajax任务!');
});
// 异步任务3
Promise.resolve().then(() => {
console.log('这是一个promise回调任务!');
});
console.log('程序结束!');
这里我们定义了三个异步任务,分别是定时器任务、ajax任务和Promise任务。我们以JavaScript引擎的角度来看这几个任务是如何执行的:
- 执行
console.log('程序开始!')
,这是一段同步代码,直接向控制台输出程序开始!
。 - 执行第一个异步任务setTimeout。这是一个宏任务,js引擎把它交给定时器线程进行计时,自己继续向下执行。
- 执行第二个异步任务:ajax任务。这也是一个宏任务,js引擎调用I/O线程来处理这个任务,自己继续向下执行。
- 执行第三个异步任务:promise任务。这是一个微任务,js引擎先执行Promise的同步内容,然后把注册的then回调函数推送到微任务队列,自己继续向后执行。
- 执行
console.log('程序结束!')
,这是同步代码,直接在控制台打印程序结束!
。
现在主程序已经执行完毕了,但是控制台实际上只输出了以下两行内容:
程序开始!
程序结束!
接下来,主线程将进入事件循环(Event Loop)周期,js引擎将不断检查当前的微任务队列和宏任务队列是否有可执行的回调函数,一旦存在,立即取出执行。所以上述代码的图示如下:
图中“主程序执行完毕”之后的部分是一个永不停止的循环(除非页面关闭),引擎总在不停地检查微任务队列和宏任务队列是否有待执行的回调函数,一旦发现有,则立刻取出执行。这个永不停止的循环,就称为事件循环(Event Loop)。
因此,以上面的例子而言,我们假设ajax请求的响应时间大于4ms(4ms是ES6规定的最小计时时间,即使setTimeout指定的延迟为0,浏览器也会启动一个4ms的计时),那么程序将依次进行如下输出:
程序开始!
程序结束!
这是一个promise回调任务!
这是一个定时器任务!
这是一个ajax任务!
由于promise内没有封装异步任务,因此它的回调函数将作为一个微任务立即被推送到微任务队列。而定时器任务和ajax任务都需要等待对应的事件到来(即计时结束和请求得到响应),这两个事件哪个先到来,哪个就会先被推送到宏任务队列,从而先于另一个执行。因此,也有可能出现下面的输出结果:
...
这是一个ajax任务!
这是一个定时器任务!
实际上,这两个宏任务未必会在同一个循环周期内被执行,并且完全无法预料它们各自会在哪个周期内被执行,这取决于一个循环周期的长短(循环周期的长短取决于js引擎在这个周期内执行了哪些任务),以及两个异步任务的执行时长。
一般来说,两个定时器任务的执行顺序是易于预测的,而其他异步任务的回调函数的执行顺序很难预测。比如:
setTimeout(() => { console.log('计时器1'); }, 10);
setTimeout(() => { console.log('计时器2'); }, 10);
此时计时器1的结果必然先于计时器2输出,因为两者的计时时长均为10毫秒,而第一个计时器先于第二个被计时,因此它必然会先计时完成,从而优先被推送到宏任务队列。但是下面的例子也证明,某些情况下,计时器的执行顺序也不那么简单:
setTimeout(() => {
console.log('计时器1');
}, 11);
for (var i = 0; i < 1000000; i++) {
// 一个复杂的for循环,执行时长远超过1毫秒
}
setTimeout(() => {
console.log('计时器2');
}, 10);
表面看去,第一个计时器的延迟是11毫秒,应该晚于第二个计时器输出。但是js引擎在将第一个计时器任务交给计时器线程后,就执行了一个复杂的for循环,花费了超过1毫秒的时间。那么当它处理到第二个计时器时,第一个计时器的剩余时长已经不足10毫秒,因此它会先被推送到宏任务队列,从而先于第二个计时器输出。当然了,如果for循环的执行时长不足1毫秒,第二个计时器仍然会更早输出。
需要强调的是,js引擎不会在主程序执行完毕前去处理任务队列的回调函数,也就是说,即使这个for循环执行了1000毫秒(此时第一个计时器早已计时完毕),计时任务的回调函数也不会立即被执行,而是等待所有的主程序执行完毕后才执行。
正是因为这个原因,计时器并没有想象的那么可靠,它只是在大多数情况下提供了较为精确的计时而已。
除了主程序的执行过程不会被打断外,一个循环周期的执行也不会被打断。比如当前js进程已经在处理宏任务队列了,而你又向微任务队列推送了一个函数,那么它将无法在本次循环周期执行,只能在js引擎进入下个循环周期时才会被执行。
但还有一种情况,假如js引擎正在处理微任务队列,而此时你向微任务队列推送了函数,它就会在本次事件循环内被执行,因为js引擎只有在微任务队列为空时才会处理宏任务队列。这个问题在某些时候可能导致宏任务的饥饿现象,即我们不断向微任务队列推送函数,导致宏任务一直不能执行。比如我们构造下面这样一个微任务函数:
let times = 10;
function doMicroTask() {
Promise.resolve().then(() => {
console.log('微任务');
for(let i = 0; i < 10000000; i++){
[1,2,3,4].map(item => item)
}
while(times > 0) {
doMicroTask();
times--;
}
})
}
这个函数在执行时会向微任务队列推送一个Promise回调函数。这个回调函数会执行一行输出,随后是一个很耗时的for循环,执行完for循环后,继续调用函数自身,向微任务队列添加当前回调函数。每次调用该函数,promise的回调函数都被连续推送到微任务队列11次。我们在浏览器测试一下该函数的执行时长:
可以看到,执行了长达154毫秒。假如我们执行了下面的程序:
doMicroTask();
setTimeout(() => {console.log('计时器任务')});
执行结果是下面这样的:
这里定时器任务的定时为10毫秒,而上述回调函数每次的执行时长就达到了154 / 11 = 14ms
。按理来说,本来应该在第一次执行完那个微任务后就去执行计时器宏任务,随后在下一个循环周期再继续执行后续的微任务。但是由于每次添加微任务时js引擎仍处于微任务执行阶段,因此它仍然以微任务优先,导致了宏任务一直等待,直到154毫秒后才得到执行。假如函数doMicroTask
所推送的回调函数名为cb
,则整个过程如下图:
宏任务饥饿现象正是图中虚线部分导致的。由于js引擎必须保证微任务执行完毕才可以执行宏任务,所以在微任务执行过程中添加到微任务队列的回调函数就会继续占用引擎的执行权,从而导致宏任务一直等待。可想而知,如果上述是个无限循环,那么我们定义的setTimeout宏任务将永远不会得到执行。
在Vue中也有类似的现象,解决方案是,对推送到微任务队列的函数进行计数,一旦发现推送的次数过多,就自动降级为宏任务,这样它就不会阻碍其他宏任务的执行了。
2. 回调函数模式
在采用异步模型的情况下,代码不能再使用常规的同步逻辑编写,而是需要采用回调函数模式。比如我们在Node中如果调用同步读取文件的接口(Nodejs运行于服务端,一般是从磁盘读取文件,因此阻塞问题不像前端这样明显),则会这样写:
let str = fs.readFileSync('test.txt', 'utf8');
str.replace('a', 'b');
... //对文件的其他处理
... // 与文件处理无关的其他逻辑
这里,readFileSync是同步读取文件的函数,只有当文件读取成功,才会继续执行后续的处理逻辑。这种编码方式在逻辑上非常易于接受,但它的问题是,在readFileSync执行完毕前,js引擎始终处于等待状态,而我们希望它应该去执行与文件处理无关的逻辑,这就造成了CPU资源的浪费。另外,这种同步写法会导致一旦文件读取失败,整个程序都会崩溃。
为了解决这个问题,我们改写为以下的异步模型:
fs.readFile('test.txt', 'utf8', (err, data) => {
str.replace('a', 'b');
... //对文件的其他处理
})
... // 与文件处理无关的逻辑
现在,当js引擎执行到readFile
时,立即向fs
模块发出一个异步任务,fs
模块会启用另外的线程来读取文件。与此同时,js引擎继续处理后续与文件处理无关的其他代码。当fs
模块执行完文件读取后(成功或失败都有可能),会把这个回调函数推入宏任务队列,传入相应的参数,等待js引擎进入循环周期时调用。
如果需要按顺序读取多个文件,一般来说需要使用嵌套的方式:
fs.readFile('test.txt', 'utf8', (err, data) => {
str.replace('a', 'b');
fs.readFile('test2.txt', 'utf8', (err, data) => {
... // 其他处理
})
})
可想而知,如果需要按顺序读取很多个文件(一般很少需要这么做,但当后一个文件的读取依赖于前一个文件的某些内容时,这是必要的),就会出现一层一层的嵌套,形成“回调地狱”,这会导致代码看起来无比丑陋。
为了解决这个问题,ES6新增了Promise,ES7新增了async函数。Promise可以将嵌套的异步任务整合成链式结构,而async使得我们可以像写同步逻辑一样编写异步代码。关于Promise和async的相关知识,可以参考我之前的博文:前端异步方案之Promise(附实现代码)和ES6之Generator和async,本文不再赘述。
3. 异步模型的应用
理解异步模型对学习前端极其重要,可以这么说:理解了异步不一定就掌握了前端开发,但不理解异步就一定不懂前端开发。
我们举几个常见的例子,来应用上面讲到的异步知识。
1. 为什么需要onload?
在编写前端代码时,我们经常会这样写:
document.onload = function() {
... // 程序逻辑
}
其实,即使我们直接把代码逻辑放在onload之外,程序在大多数时候也能正常执行。这种情况下,引擎会在读取到代码块时立即执行它。我们知道,浏览器在执行js代码时会暂停DOM和CSS的解析,因此,假如这些代码出现在文档的顶部,它就会直接影响页面的渲染过程。
另外,由于DOM和CSS此时还没解析完,所以如果代码中需要访问DOM树,访问的就是不完整的DOM树,这很容易导致程序异常。不过将这段脚本放在文档底部通常可以避免这个问题,我们建议总是这样做。
而将代码逻辑作为回调函数注册进document.onload
事件内就可以轻松避免这些问题。由于onload
事件是一个异步任务,所以引擎走到这里不会去执行内部逻辑,而是等待文档加载完成后才去宏任务队列取出它并执行。不过你仍然需要把脚本放在文档底部,因为脚本一般是来自外部文件,而下载外部文件同样会阻塞渲染。
另外,iframe的处理逻辑一般也需要注册在onload事件内,保证其内容已渲染完毕:
let frame = document.createElement('iframe');
frame.src = './test.html';
document.body.appendChild(frame);
// 这会导致错误,因为iframe还未渲染完毕,内部的方法还不能访问
frame.contentWindow.document.soSomething();
在执行完appendChild
后立即访问iframe内部的方法是不可行的,因为渲染过程是异步的,必须将逻辑注册到iframe的onload事件内:
frame.contentWindow.document.onload = function () {
frame.contentWindow.document.soSomething();
}
window.onload和document.onload事件是类似的,只是document.onload会在DOM和样式渲染完毕后就触发,而window.onload需要等待图片等外部资源就绪后才会触发。
2. Vue.nextTick
在Vue中,如果我们在修改了data之后需要访问更新后的DOM,下面这种写法是错误的:
...
this.userList.push('小明');
let users = document.querySelector('#userList');
... // 直接操作DOM
尽管Vue不提倡直接操作DOM,但有时也是不可避免的。如果我们像上面一样在修改了业务数据后就立即访问DOM ,得到的其实并不是最新的DOM。
这是因为,Vue为了提高页面的渲染性能,不会在每次修改了业务数据后立即去触发页面的重新渲染,而是先将其推送到微任务队列。等所有的业务数据修改完毕后,统一执行微任务队列的回调函数进行视图重绘。这样,一次修改多个业务数据就只会产生一次重绘。
也就是说,修改了userList后,DOM的重绘函数仍在微任务队列等待,尚未执行。此时直接访问DOM,访问的自然就是旧的DOM。使用nextTick就可以访问到最新的DOM:
...
this.userList.push('小明');
this.$nextTick(() => {
let users = document.querySelector('#userList');
... // 操作DOM
})
这里的this.$nextTick
就是Vue.nextTick
,Vue在创建实例时会自动将nextTick
赋值给它,它的作用是将一个函数推送到微任务队列。
现在,重新渲染视图的回调函数,和nextTick
注册的回调函数都在微任务队列内,而Vue可以保证nextTick
注册的回调函数永远位于队列靠后的位置。这样,当主程序执行完,js引擎会去检查微任务队列,取出位于前面的重新渲染视图的函数执行,更新DOM,然后再执行nextTick
注册的回调函数,于是它就可以访问到更新后的DOM了。
总结
异步是JavaScript中最重要的概念之一,理解它对编写正确而优雅的JavaScript代码是极其重要的。
本文我们简要介绍了异步模型中的一些基本概念,轻示例而重原理,希望开发者读完本文后自己进行更深入的思考,以更灵活地掌握异步。
本文地址:https://blog.csdn.net/qq_41694291/article/details/107292050