*PS: 浏览器原理与实践系列文章内容来自极客时间李兵老师《浏览器工作原理与实践》,主要是记录自己的学习过程,基于自己的理解对内容做的一些总结,包括《宏观视角下的浏览器》《浏览器中的JS执行机制》《V8引擎工作原理》《事件循环系统》《浏览器中的页面》《网络协议》《浏览器安全》七篇,此为第四篇
消息队列和事件循环
每个渲染进程都有一个主线程,并且主线程非常繁忙,既要处理DOM,又要计算样式,还要处理布局,同时还需要处理JavaScript任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,即消息队列和事件循环系统。
消息队列是一种数据结构,可以存放要执行的任务 。它符合队列"先进先出 "的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取 。IO线程中产生的新任务添加进消息队列尾部;渲染主线程会循环地从消息队列头部中读取任务,执行任务。渲染进程还有一个IO线程专门用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程。
任务类型有很多,包括内部消息类型,如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript定时器等等。除此之外,消息队列中还包含了很多与页面相关的事件,如JavaScript执行、解析DOM、样式计算、布局计算、CSS动画等。
如何处理高优先级的任务。
比如一个典型的场景是监控DOM节点的变化情况(节点的插入、修改、删除等动态变化),然后根据这些变化来处理相应的业务逻辑。一个通用的设计的是,利用JavaScript设计一套监听接口,当变化发生时,渲染引擎同步调用这些接口,这是一个典型的观察者模式。不过这个模式有个问题,因为DOM变化非常频繁,如果每次发生变化的时候,都直接调用相应的JavaScript接口,那么这个当前的任务执行时间会被拉长,从而导致执行效率的下降。
如果将这些DOM变化做成异步的消息事件,添加到消息队列的尾部,那么又会影响到监控的实时性,因为在添加到消息队列的过程中,可能前面就有很多任务在排队了。这也就是说,如果DOM发生变化,采用同步通知的方式,会影响当前任务的执行效率 ;如果采用异步方式,又会影响到监控的实时性 。为了权衡效率 和实时性 ,微任务就应用而生了。
事件循环 :消息队列中的任务称为宏任务 ,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果DOM(微任务)有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,就解决了执行效率的问题。等宏任务中的主要功能都直接完成之后,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,因为DOM变化的事件都保存在这些微任务队列中,这就解决了实时性问题。 如此循环执行宏任务,再执行清空相应的微任务队列,便是事件循环系统。
浏览器是如何实现setTimeout的
渲染进程中所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按照顺序执行消息队列中的任务。
所以说要执行一段异步任务,需要先将任务添加到消息队列中。不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,不能将定时器的回调函数直接添加到消息队列中。
在Chrome中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和Chromium内部一些需要延迟执行的任务。所以当通过JavaScript创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。主线程处理完消息队列中的一个任务之后,会执行一个专门处理异步任务的函数,该函数根据发起时间和延迟时间计算出到期的任务,然后依次执行这些到期的任务。等到期的任务执行完成之后,再继续下一个循环过程。通过这样的方式,一个完整的定时器就实现了。
使用setTimeout的注意事项
-
如果当前任务执行时间过久,会影响定时器任务的执行。通过setTimeout设置的回调任务被放入了消息队列中并且等待下一次执行,这里并不是立即执行的;要执行消息队列中的下个任务,需要等待当前的任务执行完成。
-
如果setTimeout存在嵌套调用,那么系统会设置最短时间间隔为4毫秒
scssfunction cb() { setTimeout(cb, 0); } setTimeout(cb, 0);
-
未激活的页面,setTimeout执行最小间隔是1000毫秒。如果标签不是当前的激活标签,那么定时器最小的时间间隔是1000毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量
-
延时执行时间有最大值。Chrome、Safari、Firefox都是以32个bit来存储延时值的,32bit最大只能存放的数字是2147483647毫秒,这就意味着,如果setTimeout设置的延迟值大于 2147483647毫秒(大约24.8天)时就会溢出,那么相当于延时值被设置为0了,这导致定时器会被立即执行
-
使用setTimeout设置的回调函数中的this不符合直觉。指向全局window。
宏任务和微任务
由消息队列和事件循环系统的工作方式可知,消息队列中这种粗时间颗粒度的任务已经不能胜任部分领域的需求,所以出现了微任务。微任务可以在实时性和效率之间做一个有效的权衡。
页面中的大部分任务都是在主线程上执行的,这些任务包括了:
- 渲染事件(如解析DOM、计算布局、绘制);
- 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
- JavaScript脚本执行事件;
- 网络请求完成、文件读写完成事件。
为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个for循环,不断地从这些任务队列中取出任务并执行任务。这些消息队列中的任务称为宏任务。
微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
当JavaScript执行一段脚本的时候,V8会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8引擎也会在内部创建一个微任务队列。这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。不过这个微任务队列是给V8引擎内部使用的,所以无法通过JavaScript直接访问。也就是说每个宏任务都关联了一个微任务队列。
微任务产生的时机:
第一种方式是使用MutationObserver监控某个DOM节点,然后再通过JavaScript来修改这个节点,或者为这个节点添加、删除部分子节点,当DOM节点发生变化时,就会产生DOM变化记录的微任务。
第二种方式是使用Promise,当调用Promise.resolve()或者Promise.reject()的时候,也会产生微任务。
执行微任务队列的时机:
通常情况下,在当前宏任务中的JavaScript快执行完成时,也就在JavaScript引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。
有以下几个结论:
- 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
- 微任务的执行时长会影响到当前宏任务的时长。比如一个宏任务在执行过程中,产生了100个微任务,执行每个微任务的时间是10毫秒,那么执行这100个微任务的时间就是1000毫秒,也可以说这100个微任务让宏任务的执行时间延长了1000毫秒。所以你在写代码的时候一定要注意控制微任务的执行时长。
- 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。
Promise
promise出现为了解决两个问题:
消灭嵌套调用 (回调地狱)和 合并多个任务的错误处理
消灭嵌套调用:首先,Promise实现了回调函数的延时绑定 。回调函数的延时绑定在代码上体现就是先创建Promise对象x1,通过Promise的构造函数executor来执行业务逻辑;创建好Promise对象x1之后,再使用x1.then来设置回调函数。其次,需要将回调函数onResolve的返回值穿透到最外层。根据onResolve函数的传入值来决定创建什么类型的Promise任务,创建好的Promise对象需要返回到最外层,这样就可以摆脱嵌套循环了。
批量处理异常:Promise对象的错误具有"冒泡"性质,会一直向后传递,直到被onReject函数处理或catch语句捕获为止。具备了这样"冒泡"的特性后,就不需要在每个Promise对象中单独捕获异常了。
async/await作用及其实现原理
ES7 引入了async/await,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰。也支持try catch来捕获异常。
生成器 VS 协程:
生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的
lua
function* genDemo() {
console.log("开始执行第一段")
yield 'generator 2'
console.log("开始执行第二段")
yield 'generator 2'
console.log("开始执行第三段")
yield 'generator 2'
console.log("执行结束")
return 'generator 2'
}
console.log('main 0')
let gen = genDemo()
console.log(gen.next().value)
console.log('main 1')
console.log(gen.next().value)
console.log('main 2')
console.log(gen.next().value)
console.log('main 3')
console.log(gen.next().value)
console.log('main 4')
由以上代码可以看出,函数genDemo并不是一次执行完的,全局代码和genDemo函数交替执行。这就是生成器函数的特性,可以暂停执行,也可以恢复执行。
生成器函数的具体使用方式:
- 在生成器函数内部执行一段代码,如果遇到yield关键字,那么JavaScript引擎将返回关键字后面的内容给外部,并暂停该函数的执行。
- 外部函数可以通过next方法恢复函数的执行。
JavaScript引擎V8是如何实现一个函数的暂停和恢复: 要搞懂函数为何能暂停和恢复,首先要了解协程的概念。协程是一种比线程更加轻量级的存在 。可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程,比如当前执行的是A协程,要启动B协程,那么A协程就需要将主线程的控制权交给B协程,这就体现在A协程暂停执行,B协程恢复执行;同样,也可以从B协程中启动A协程。通常,如果从A协程启动B协程,就把A协程称为B协程的父协程。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。下图是以上代码的执行流程图:
从图中可以看出来协程的四点规则:
- 通过调用生成器函数genDemo来创建一个协程gen,创建之后,gen协程并没有立即执行。
- 要让gen协程执行,需要通过调用gen.next。
- 当协程正在执行的时候,可以通过yield关键字来暂停gen协程的执行,并返回主要信息给父协程。
- 如果协程在执行期间,遇到了return关键字,那么JavaScript引擎会结束当前协程,并将return后面的内容返回给父协程。
async/await
async是一个通过异步执行 并隐式返回 Promise 作为结果的函数。
执行到await时会发生什么:async函数返回的是一个Promise对象,结合以下这段代码来看看await到底是什么:
javascript
async function foo() {
console.log(1)
let a = await 100
console.log(a)
console.log(2)
}
console.log(0)
foo()
console.log(3)
首先,执行console.log(0)这个语句,打印出来0。
紧接着就是执行foo函数,由于foo函数是被async标记过的,所以当进入该函数的时候,JavaScript引擎会保存当前的调用栈等信息,然后执行foo函数中的console.log(1)语句,并打印出1。
当执行到await 100时,会默认创建一个Promise对象,代码如下所示:
scss
let promise_ = new Promise((resolve,reject){
resolve(100)
})
在这个promise_对象创建的过程中,在executor函数中调用了resolve函数,JavaScript引擎会将该任务提交给微任务队列。然后JavaScript引擎会暂停当前协程的执行,将主线程的控制权转交给父协程执行,同时会将promise_对象返回给父协程。主线程的控制权已经交给父协程了,然后父协程调用promise_.then来监控promise状态的改变。
接下来继续执行父协程的流程,执行console.log(3)。随后父协程将执行结束,在结束之前,会进入微任务的检查点,然后执行微任务队列,微任务队列中有resolve(100)的任务等待执行,执行到这里的时候,会触发promise_.then中的回调函数,该回调函数被激活以后,会将主线程的控制权交给foo函数的协程,并同时将value值传给该协程。foo协程激活之后,会把promise返回的的value值赋给变量a,然后foo协程继续执行后续语句,执行完成之后,将控制权归还给父协程。以上就是await/async的执行流程。