前言
今天我们来聊聊浏览器中的event-loop,事件循环机制。在浏览器中的 Event Loop(事件循环)是一种机制,用于协调和管理 JavaScript 代码的执行顺序。
我们先要知道JavaScript是一种单线程语言。但是要了解线程呢,我们还需要先了解进程。
进程是CPU运行指令和保存上下文所需要的时间,他是个相对描述
进程是计算机中正在运行的程序的实例。在操作系统中,每个进程都有自己的内存空间、资源和状态。一个进程可以包含一个或多个线程,这些线程共享同一进程的内存和资源。
例如浏览器来说一个tab页面就是一个进程。
在现代操作系统中,进程是多线程的,这意味着一个进程可以同时执行多个线程,这些线程共享同一进程的内存空间和资源。
我们在前面的文章有讲过从输入url到页面渲染发生了哪些事。其中这个进程中就存在着几个线程:
整个浏览器的进程主要有:GPU渲染线程、http请求线程、js引擎线程。所以说一个进程由多个线程配合工作,这时就会创建一个执行上下文,也就是执行的内存空间。需要多个线程配合工作才能完成页面的展现。
线程是进程中更小的单位,描述了一段指令执行所需的时间。
线程之间是可以进行配合的,但是这里我们需要注意:
JS执行和html渲染是不可以同时进行的:
JS执行线程和html渲染线程这两个线程是不可以同时进行的。因为js这么语言是可以去操作DOM结构的,如果它们可以同时进行的话,假设js和html都作用于同一个DOM结构,一个想将它的宽设置为100px,另一个想设置为200px,这样会造成不安全的渲染。
总结一下
浏览器新开一个tab页面,就是新开一个进程
需要多个线程配合才能完成页面的展示,例如:
- 渲染线程(GPU)
- http请求线程
- js引擎线程
渲染线程(GPU)和 js引擎线程 是互斥的
js是单线程语言
js语言为何是一门单线程语言?
这是js的设计问题,因为当年设计js的是为了在浏览器中出现一些交互功能,例如让用户点击按钮,所以该语言设计之初是作为浏览器的脚本语言。多线程意味着高并发,高功耗,多线程就意味一定要多占用一些运行内存。使用单线程降低开销。
优点
- 节省内存
- 节约上下文切换的时间
上下文切换是指在多线程环境中,当操作系统决定停止当前线程的执行并将 CPU 时间分配给另一个线程时,需要保存当前线程的执行上下文(如寄存器的状态、栈指针等),然后加载另一个线程的执行上下文。这个过程需要一定的时间和开销,而且上下文切换的频繁发生会影响系统的性能。例如在java中有
锁
的概念,可以看看。
JavaScript异步
异步是指在代码执行时,某个操作不会立即返回结果,而是在将来的某个时间点返回结果。在这段时间内,代码可以继续执行其他任务,而不必等待该操作完成。
而js中异步又被分为宏任务和微任务:
将异步任务分为宏任务和微任务的主要目的是为了管理和控制它们的执行顺序,以提高程序的效率和性能。
宏任务 (macrotask)
- 标签
script
setTimeout
setInterval
setImmediate
:指定的dom结构加载完毕再执行,一般框架中用的多I/O
:输入输出事件,比如用户点击按钮,就是一个输入事件UI-rendering
:页面渲染,与v8引擎关系不大
微任务 (microtask)
promise.then()
: Promise是同步,Promise.then是异步微任务MutationObserver
: js中的高级方法,用来监听DOM树Process.nextTick()
event-loop(事件循环机制)
- 执行同步代码
- 当执行栈为空,查询是否有异步代码需要执行
- 执行微任务
- 如果有需要,会渲染页面
- 执行宏任务(这也叫下一轮的event-loop的开启)
我们要注意的一点是,执行异步代码是耗时的,但是耗时的代码不一定是异步代码。
setTimeout(() => console.log('setTimeout'), 1000) for(let i = 0; i<10000; i++){ console.log('hello world') }
这里我们for循环10000次,或者是10万次,执行这么多次循环肯定是需要耗时的,但是这个耗时是由电脑的性能,也就是电脑的CPU来决定的,而不是v8引擎。假设for循环的执行事件为1s(电脑配置高执行时间就短),那么输出setTimeout
就需要2s,因为先执行同步代码,再执行异步代码。for是同步代码,虽然需要耗时。
现在,我们来看一道代码输出题:
console.log('start'); setTimeout(() => { console.log('setTimeout'); setTimeout(() => { console.log('inner'); }); console.log('end'); }, 1000) new Promise((resolve, reject) => { console.log('Promise'); resolve() }) .then(() => { console.log('then1'); }) .then(() => { console.log('then2'); })
这种代码输出题是在面试过程中十分常见的,面试官往往会通过这类题来对你进行一个事件循环机制的考察。做这种题,一定要牢牢记住我们上面所讲的步骤。
代码的执行顺序是从上往下的。首先,碰到了console.log('start')
,这是一段同步代码,所以直接执行,输出start
。接着往下执行,发现宏任务setTimeout
,先不执行,将它放入宏任务队列当中
,这一轮event-loop是先不执行的。然后来到Promise
,它同样也是一个同步代码,所以输出Promise
。继续往下执行,发现两个.then
,它们是异步微任务,将它们放入微任务队列当中。
现在,同步代码已经执行完毕,并且执行栈也为空(也就是说代码已经全部处理完毕了,异步已经放入了队列当中),然后去检查是否有异步代码需要执行。第三步,我们来到微任务队列,开始执行微任务,所以依次输出then1
, then2
。
队列是先进先出,所以先进的先执行出队
第四步,渲染页面,但是这里并无渲染页面的需求。所以来到第五步,执行宏任务队列中的宏任务,也叫开始下一轮的event-loop。
检查宏任务队列,发现宏任务当中存在一个setTimeout
,将它从宏任务队列中取出来,开始执行。
现在我们已经来到了第二轮event-loop,所以又需要先执行同步代码,打印setTimeout
。接着又碰到一个setTimeout
,再将它存入宏任务队列当中。然后碰到同步代码,打印end
。此时执行栈又为空了,但是我们并没有微任务需要去执行,所以去执行宏任务,第三次事件循环event-loop开启了。
将setTimeout
从宏任务队列当中取出来,然后执行同步代码inner
。并且此时并无微任务,而且宏任务队列也为空了,所以代码全部执行完毕。
我们来看看输出结果:
async/await
这类输出题不仅只有promise.then
,有时候还会有async/await
,它们两个是会有些差距的。
async/await 是为了简化 JavaScript 中异步操作的语法,使得异步编程更加直观和易于理解。如果异步很多的情况下,promise.then调用是不是那么优雅的,它需要一排一排的写下去,就如链式一样:
function A(){ return new Promise((resolve, reject) =>{ setTimeout(() => { console.log('异步A完成') resolve() }, 1000) }) } function B(){ return new Promise((resolve, reject) =>{ setTimeout(() => { console.log('异步B完成') resolve() }, 500) }) } function C(){ setTimeout(() => { console.log('异步C完成') }, 100) } A() .then(() => { return B() }) .then(() => { C() })
如上,我们使用promise.then()链式调用去解决一个异步问题,最后输出异步A完成,异步B完成,异步C完成。但是如果当异步很多的话,我们就要写很多的.then
,这样很不优雅。
因此async/await
应运而生。
function A() { return new Promise((resolve, reject) => { setTimeout(() => { console.log('异步A完成'); resolve() }, 1000) }) } function B() { return new Promise((resolve, reject) => { setTimeout(() => { console.log('异步B完成'); resolve() }, 500); }) } function C() { return new Promise((resolve, reject) => { setTimeout(() => { console.log('异步C完成'); resolve() }, 100) }) } async function foo() { await A() await B() await C() } foo()
同样输出异步A完成,异步B完成,异步C完成。
但是要使用await
,一定需要在函数中加入async
标签,async
可以单独出现,但是await
不能单独出现。
浏览器会给await
开小灶,接在await后面的代码会立刻执行。并且awiat
会阻塞后续代码,将后续代码推入微任务队列:
async function foo() { await A() // await 会阻塞后续代码, 将后续代码推入到微任务队列 console.log(1); await B() await C() } foo()
看,我们在第一个await
后加入了一个同步代码打印,那么由于await
会阻塞后续代码,所以会将console.log
推入微任务队列当中
所以打印异步A完成,1,异步B完成,异步C完成。
我们来看一道结合题:
console.log('script start') async function async1() { await async2() console.log('async1 end') } async function async2() { console.log('async2 end') } async1() setTimeout(function () { console.log('setTimeout') }, 0) new Promise(resolve => { console.log('Promise') resolve() }) .then(function () { console.log('promise1') }) .then(function () { console.log('promise2') }) console.log('script end')
我们来分析一下,首先先执行同步代码,打印script start
,代码继续往下执行,到了async1的调用时,发现了一串代码await async2()
,我们上面讲了,碰到awiat
后面的代码,直接执行。所以打印async2 end
。但是await下面的console.log(async1 end)
会被推入到微任务队列中,因为await
会阻塞后面的代码。
接着碰到宏任务setTimeout
,推入宏任务队列中。看到Promise
,它是一个同步代码,输出Promise
。然后碰到两个.then
,推入到微任务队列当中。再打印同步代码script end
。
此时执行栈为空,开始执行微任务。依次输出async1 end
, promise1
, promise2
。
接下来取出宏任务,开始第二轮事件循环,打印setTimeout
。
还有一种理解方法,我认为也是还可以的。因为async/await是由promise进行转换过来,那么我们可以将async/await
的代码换成promise
来理解:
console.log('script start') function async1() { new Promise((resolve) => { console.log('async2 end') resolve(undefined) }).then(res => { console.log('async1 end') }) } async1() setTimeout(function () { console.log('setTimeout') }, 0) new Promise(resolve => { console.log('Promise') resolve() }) .then(function () { console.log('promise1') }) .then(function () { console.log('promise2') }) console.log('script end')
换成Promise之后,大家有没有觉得变得更加亲切了一些。这主要是依赖于await
的特殊性。转换之后结果也是一样的。
总结
事件循环机制event-loop是十分重要的。它在面试中出现的概率是非常大的。同时也相信小伙伴们看完这篇文章能对event-loop
有着更深的理解,下次碰到就可以无比自信的说出答案!
写文章不易,如果帮助到了小伙伴们,可以给本文点赞收藏评论三连呀。有不懂的地方欢迎到评论区留言,我会及时回复。