Hello 2022,2022年的第一篇文章,给新的一年开一个好头。本篇文章主要写了JavaScript中异步编程的事件循环模型,后面还会再有下一节继续更新。
1. js的运行机制
同步和异步
JavaScript是⼀⻔单线程的语⾔,在同一时间点只能做一件事情,这种机制就会造成某种局限性。假设开发中请求服务器接口的场景,在这一时间点只能等待接口返回数据,如果网络很慢的情况,就会造成页面白屏卡住。因为这一时间点只能做这一件事,就不能再做页面页面的渲染等工作,所以造成白屏,网页假死。但实际开发中我们并不会遇到这样的情况。
所以在js中肯定存在⼀种解决⽅案,来处理单线程造成的问题。这就是同步【阻塞】和异步【⾮阻塞】执⾏模式的出现。
- 同步(阻塞)
同步的意思是JavaScript会严格按照单线程从上到下、从左到右的⽅式执⾏代码逻辑。如下:
var a = 1
var b = 2
var c = a + b
//正常执行顺序 总是会输出 3
console.log(c)
一个线程阻塞的例子:
var a = 1
var b = 2
var d1 = new Date().getTime()
var d2 = new Date().getTime()
while(d2-d1<2000){
d2 = new Date().getTime()
}
// 不会立即输出a+b的值, 等while循环执行完毕后,也就是延迟2秒,才会输出a+b的值
console.log(a+b)
- 异步(非阻塞)
异步的意思就是和同步对⽴,所以异步模式的代码是不会按照默认顺序执⾏的。JavaScript执⾏引擎在⼯作时,仍然是按照从上到 下从左到右的⽅式解释和运⾏代码。在解释时,如果遇到异步模式的代码,引擎会将当前的任务“挂起”并略过。也 就是先不执⾏这段代码,继续向下运⾏⾮异步模式的代码,直到同步代码全部执⾏完毕后,程序会将之前“挂起”的异步代码按照“特定的顺序”来进⾏执⾏。
var a = 1;
var b = 2;
// setTimeout 时间,从任务被挂起时就开始计算
setTimeout(function () {
console.log("异步任务");
}, 2000);
//直接输出3,等待2秒左右的时间再输出 "异步任务"
console.log(a + b);
异步非阻塞式运⾏的代码,程序运⾏到该代码⽚段时,执⾏引擎会将程序保存到⼀个暂存区,等待所有同步代码全部执⾏完毕后,⾮阻塞式的代码会按照特定的执⾏顺序,分步执⾏。
js的线程组成
上面说了,浏览器是单线程执行代码的,但浏览器实际是以多个线程协助操作来实现单线程异步模型的,具体线程组成如下:
- GUI渲染线程
- JavaScript引擎线程
- 事件触发线程
- 定时器触发线程
- http请求线程
- 其他线程
那么,实际上运行js的线程并不是一个,那为什么还说Js是单线程的呢?因为实际参与代码执行并不是所有线程,在JS代码运⾏的过程中实际执⾏程序时同时只存在⼀个活动线程,这⾥实现同步异步就是靠多线程切换的形式来进⾏实现的。
所以,我们通常分析时,将上面的细分归纳为两条线程:
- 【主线程】:这个线程⽤了执⾏⻚⾯的渲染,JavaScript代码的运⾏,事件的触发等
- 【⼯作线程】:这个线程是在幕后⼯作的,⽤来处理异步任务的执⾏来实现⾮阻塞的运⾏模式
2. JavaScript的运⾏模型
如上图所示,是js运行时的工作流程,包含以下几个要点:
- 执行js的代码的线程为主线程,主线程代码在运行时,分出同步代码和异步代码两个分支,如果是同步代码,会进入函数执行栈内执行,异步代码进入工作线程中暂时挂起等待。
- 程序运行时,同步代码会按顺序进入执行栈,上一个同步任务经过进栈->代码执行->出栈步骤后,下一个同步任务才会进栈。工作线程中保存的是定时任务函数,网络请求等耗时操作。
- 任务队列内的任务来源是工作线程,工作线程的定时任务从挂起时就开始计时,计时结束后或接收到服务端数据返回时,此任务会进入到工作线程中。
- 当执⾏栈内的任务全部执⾏完毕后,执⾏栈就会清空。执⾏栈清空后,事件循环就会⼯作,事件循环会访问任务队列,将任务队列中存在的任务,按顺序先进先出放在执⾏栈中继续执⾏,直到任务队列清空。
- 事件循环(Event Loop) 是js的执行机制,执行栈中的同步任务都执行完毕,执行栈被清空了,主线程空闲了,这个时候就会去任务队列中按照顺序读取一个任务放入到栈中执行。每次栈被清空,都会去读取任务队列有没有任务,有就读取执行,一直循环读取-执行的操作,就形成了事件循环。
关于执行栈
执⾏栈是⼀个栈的数据结构先进后出,当我们运⾏单层函数时,执⾏栈执⾏的函数进栈后,会出栈销毁然后下⼀个进栈下⼀个出栈,当有函数嵌套调⽤的时候栈中就会堆积栈帧,如下嵌套函数的示例:
function task1() {
console.log('task1执⾏')
task2()
console.log('task2执⾏完毕')
}
function task2() {
console.log('task2执⾏')
task3()
console.log('task3执⾏完毕')
}
function task3() {
console.log('task3执⾏')
}
task1()
console.log('task1执⾏完毕')
以上代码输出的结果是:
/*
task1执⾏
task2执⾏
task3执⾏
task3执⾏完毕
task2执⾏完毕
task1执⾏完毕
*/
下面用图解分析一下代码的执行过程:
上面图示,执行task1(),先输出"task1执行",然后执行task2(),如下图所示:
上图所示,执行task2()的时候,会将task2放到栈顶并执行,此时task1暂时停顿,执行task2(),控制台输出"task2执行"。到此时,task2中发现调用了task3,继续执行task3(),如下图:
上图所示,task2内部调用task3时,暂时停顿task2的执行,将task3放到栈顶并执行,控制台输出"task3执行"。然后,task3函数执行完毕,进行出栈动作,如下图:
紧接着,task2会在刚才停顿的位置继续向下执行,也就是console.log('task3执⾏完毕')。控制台输出task3执行完毕执行完这行代码后,task2全部执行完毕,会像上述步骤进行出栈销毁。如下图:
task2出栈销毁,会继续执行task1里面未执行的代码console.log('task2执⾏完毕'),task1执行完毕后也会重复上面的动作,出栈销毁。task1出栈后,主线程中的最后一个任务console.log('task1执⾏完毕'),会进入执行栈执行并销毁,如下图:
最后一行代码执行完毕后,主线程和函数执行栈全部清空了。按照这个流程就会出现上面的输出结果。
关于递归
递归函数是项⽬开发时经常涉及到的场景。我们经常会在未知深度的树形结构,或其他合适的场景中使⽤递归。递归函数就可以看成是在⼀个函数中嵌套n层执⾏,那么在执⾏过程中会触发⼤量的栈帧堆积,如果处理的数据过⼤,会导致执⾏栈的⾼度不够放置新的栈帧,⽽造成栈溢出的错误。下面做一个测试,在Chrome中运行下面代码:
var i = 0;
function task() {
i++;
console.log(`递归了${i}次`);
task();
}
task();
运行的结果是:
执⾏栈的深度根据不同的浏览器和JS引擎有着不同的区别,可以看到,在Chrome浏览器中递归了11417次之后,报了超出调用最大堆栈大小的错误,代码无法再正常执行。所以在做递归操作时要主要层次的深度。
那么,这个问题有办法解决吗?答案是有的,可以使用异步任务调用递归函数,优化上面的代码:
var i = 0;
function task() {
i++;
console.log(`递归了${i}次`);
// 改为异步任务调用的方式
setTimeout(function () {
task();
}, 0)
}
task()
可以在Chrome控制台看到,已经执行了20000多次也没有像上面那样栈溢出报错。
用两个流程图来对比一下为什么使用异步任务就不会栈溢出报错,下面是没有使用异步任务的图示:
再来看使用了异步任务递归的图示:
有了异步任务后,递归就不会在叠加栈帧了。因为执行到异步任务的时候,递归函数就会进入工作线程挂起,此时一次递归的同步任务完成,执行栈就会被清空,所以执行栈中最多只存在一个任务,这样就防止了栈帧的无限叠加。
在Chrome中运行异步递归代码的时候,发现每次递归的速度都会变慢,无法保证运行速度。在实际的⼯作场景中,如果考虑性能问题,还需要使⽤while循环等解决⽅案,来保证运⾏效率的问题。在实际⼯作场景中,尽量避免递归循环,因为递归循环就算控制在有限栈帧的叠加范围内,其性能也远远不及指针循环。
3.宏任务和微任务
任务队列中的异步任务分为宏任务和微任务。下面是常见的两种任务的划分:
宏任务
- setTimeout
- setInterval
- I/O
- requestAnimationFrame (仅浏览器环境)
- setImmediate (仅Node.js 环境)
微任务
- Promise.then catch finally
- MutationObserver (仅浏览器环境)
- process.nextTick (仅Node.js 环境)
在JS运行环境中,任务内部会存在一个微任务队列,在每下⼀个宏任务执⾏前,事件循环系统都会先检测当前的代码块中是否包含已经注册的微任务,并将队列中的微任务优先执⾏完毕,然后开始下一个宏任务。如下图:
下面看两个示例:
console.log('1');
// s1
setTimeout(function () {
console.log('2');
new Promise(function (resolve) {
console.log('3');
resolve();
}).then(function () {
// p2
console.log('4')
})
// s3
setTimeout(function () {
console.log(5);
})
})
new Promise(function (resolve) {
console.log('6');
resolve();
}).then(function () {
// p1
console.log('7')
})
// s2
setTimeout(function () {
console.log('8');
new Promise(function (resolve) {
console.log('9');
resolve();
}).then(function () {
// p3
console.log('10')
})
})
分析一下上面代码的输出顺序应该是什么:
- 代码开始执行,第一行
console.log(),直接进入主线程,输出1。 - 继续向下执行遇到
setTimeout,为异步宏任务进入工作线程挂起,标记为s1。 - 继续向下执行遇到
new Promise,Promise对象虽然是微任务,但是new Promise时的回调函数是同步执⾏的,所以输出6。Promise.then为微任务,此时不执行,标记为p1。 - 继续向下执行遇到
setTimeout,为异步宏任务进入工作线程挂起,标记为s2。 - 在工作线程挂起的任务,按序进入队列。执行下一次宏任务s1之前,发现有一个微任务p1未执行,所以先执行微任务p1输出7。
- 接下来,执行s1,直接输出2。然后
new Promise()输出3。Promise.then为微任务,进入下一个的微任务队列,会在下一次执行宏任务之前执行完毕,标记为p2。然后下面是一个setTimeout宏任务,在宏任务里面碰到了宏任务,会将这个宏任务扔到任务队列的最后面,标记为s3。 - 接下来,执行宏任务s2之前检测到了微任务p2,先执行p2,输出4。
- 执行宏任务s2,直接输出8,碰到
new Promise()先输出9,promise.then是微任务,进入下次的微任务队列,标记为p3,会在下一个宏任务执行之前执行。 - 最后一个任务,是被扔到任务队列最后的宏任务s3,在执行s3之前发现了微任务p3,所以先执行p3,输出10。最后执行s3,输出5。
综上,输出的顺序就是 1,6,7,2,3,4,8,9,10,5。在Chrome控制台验证也是这个顺序。
再来看第二个示例:
document.addEventListener('click', function () {
Promise.resolve().then(() => console.log(1));
console.log(2);
})
document.addEventListener('click', function () {
Promise.resolve().then(() => console.log(3));
console.log(4);
})
上面的代码,给document绑定了点击事件,所以点击网页触发事件。在代码运⾏时相当于按照顺序注册了两个点击事件,两个点击事件会被放在⼯作线程中实时监听触发时机,当元素被点击时,两个事件会按照先后的注册顺序放⼊异步任务队列中进⾏执⾏,所以事件1和事件2会按照代码编写的顺序触发。
由于事件执⾏时并不会阻断JS默认代码的运⾏,所以事件任务也是异步任务,并且是宏任务,所以两个事件相当于按顺序执⾏的两个宏任务。
第一个事件执行时,触发宏任务中的同步代码,输出2,然后promise.then微任务等待下一次宏任务前执行。所以在下个事件执行前,会输出1。下一个事件同理,所以正确输出顺序是 2,1,4,3。
下一篇
- Promise相关 juejin.cn/column/7037…
- Vue3 juejin.cn/column/7001…