前言
统筹调度,一直是任务众多的系统的必备机制(类比操作系统中的调度系统)。对于浏览器来说,它内部大部分功能都是服务于页面的,因此,浏览器中的调度机制——事件循环,也是围绕着渲染进程中,处理众多任务的主线程来运行的。
建立事件循环的目的
正如前言说的,事件循环负责浏览器中的任务调度。这里简单剖析一下任务调度中需要解决的问题,并初步介绍相关概念。
在系统运行时处理新任务——任务队列
可以见得,在浏览器与我们交互的过程中,随时会产生新的任务,如重新计算样式,触发事件执行响应的JavaScript回调等。
为了接受新任务,一个通用解决办法就是建立任务队列(消息队列)。
任务队列,如其名,它采用队列的数据结构,“先进先出”的维护任务。在浏览器中,任务队列多是维护在渲染进程中(同样,暂时只能把精力放在Chrome上),通过其内部的I/O线程与其他进程通信,取得跨进程的新任务。
渲染进程的主线程,就需要不断的从任务队列中拿出任务执行(但这并不是事件循环的全部)。
处理延迟执行的任务——延迟队列
延迟队列,就负责维护像setTimeout回调这样延迟执行的异步任务。
(对于JS的同步和异步任务,可以简单的理解为,前者是在当前执行上下文中进行处理、或得到返回结果,后者则是在其他执行上下文中。)
说是延迟队列,但这应该是为了与任务队列联系在一起。他实质上是一个HashMap的结构,每一个任务都包含了回调函数,任务发起时间,任务延迟时间等。
在渲染进程主线程执行任务的过程中,每执行完任务队列中的一个任务,就会根据延迟队列中任务的发起时间和延迟时间,计算出到期的任务,然后依次放进任务队列中。感兴趣的大佬可以看以前Chromium关于这部分的源码(我还没有看,赐予我观看“机缘”的东西暂时没法用了)。
所以,延迟队列维护的任务是属于宏任务的。
平衡异步任务的优先级,性能与时效性——宏任务,微任务
在上一篇浏览器学习记录中,大体能够了解渲染进程主线程需要处理的任务类型,例如解析HTML,执行JavaScript,计算样式,布局等等。不过还有一些不与页面有直接联系的任务,如输入事件(鼠标滚动、点击、移动)、文件读写、WebSocket、JavaScript 定时器等等。
但为了满足时间精度更高的需求,产生了微任务,而微任务,是放在全局执行上下文中的微任务队列中的。为了与之区分,以前的任务就成了宏任务。
在实际场景中,我们会希望一些任务优先于某些任务执行,这就是任务的优先级。
对于同步的任务,我们只需要设置任务的先后调用顺序即可。然而如果没有微任务,我们无法保证一些异步任务的执行时机,即以前宏任务的时间精度不够高。
微任务出现的原因——如果没有宏任务微任务之分
对于我们自身的代码,只有通过设置定时器(我暂时只知道这个和eval。。。),将需要异步的任务添加到上面所说的延迟队列的尾部。
并且,我们只能保证任务被添加到延迟队列,既不能保证后来的优先级更高的任务优先执行,也不能保证每个异步任务的执行时间。
如李兵老师的文章所说的,
因为页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。
例如:
let interval = '任务调度的最小时间';
setTimeout(() => {
console.log(1);
setTimeout(() => {
console.log(2);
}, interval);
}, 0);
我们期望在输出“1”之后,立马再输出“2”。但实际上却难以保证,执行步骤大概是这样:
- 运行代码,发现setTimeout(忽略无关紧要的声明),将回调函数和相关信息放入延迟队列中
- 本次任务结束,计算到期的任务,即setTimeout的回调,将其放入任务队列中
- 从任务队列中拿出任务,如果该任务是刚刚的回调,执行
问题存在于第三点,拿出的任务不一定是设置的回调。即在步骤1和步骤2的过程中,随时都有可能会产生新的任务(本线程或其他线程),并先于回调放入任务队列。
除此以外,通过定时器实现的异步任务,还会有很多限制。
- 需要等待本次任务的结束,受本次任务持续时间的影响
- 延时执行时间有最大值(32 个 bit 来存储延时值)
- 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
- 定时器嵌套调用超过五次以上,后面每次的调用最小时间间隔是 4 毫秒,由别人引自Chromium 实现 4 毫秒延迟的代码。
static const int kMaxTimerNestingLevel = 5;
// Chromium uses a minimum timer interval of 4ms. We'd like to go
// lower; however, there are poorly coded websites out there which do
// create CPU-spinning loops. Using 4ms prevents the CPU from
// spinning too busily and provides a balance between CPU spinning and
// the smallest possible interval timer.
static constexpr base::TimeDelta kMinimumInterval = base::TimeDelta::FromMilliseconds(4);
事件循环的执行过程
在相关规范中,详细定义了事件循环机制。不过与实际实现也会有一些出入(规范中多个队列,Chrome中的任务、延迟、微任务队列)。宏任务(包括延迟任务,因为同样是添加在任务队列)执行机制大致如下:
- 先从多个消息队列中选出一个最老的任务,这个任务称为 oldestTask;
- 然后循环系统记录任务开始执行的时间,并把这个 oldestTask 设置为当前正在执行的任务;
- 当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个 oldestTask;
- 最后统计执行完成的时长等信息。
如此循环。那么微任务呢?
一般来说,在当前宏任务中的 JavaScript 执行完成时, JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。
(规范中将执行微任务的时间点称为检查点)
为了偷懒,这里同样引入李兵老师的图和结论(腰撑不住了):
微任务添加和执行过程示意图:


结论:
- 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
- 微任务的执行时长会影响到当前宏任务的时长。比如一个宏任务在执行过程中,产生了 100 个微任务,执行每个微任务的时间是 10 毫秒,那么执行这 100 个微任务的时间就是 1000 毫秒,也可以说这 100 个微任务让宏任务的执行时间延长了 1000 毫秒。所以你在写代码的时候一定要注意控制微任务的执行时长。
- 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。
微任务应用之一——Promise
不会吧不会吧,不会还有人不知道Promise罢,啊,是我自己啊,那没事了。
其实Promise这东西,大部分人应该都是在接触ES6的时候接触的。
我们了解它解决的问题,了解它的各种内置玩法,了解它的实现原理,了解它的适用场景。。。
这里我们从事件循环的角度来进一步理解Promise。
Promise,用来执行异步任务,免得堵塞我们的代码,然而对于一些异步任务,我们是希望它们能尽快执行的(拿到了返回数据就执行,如果不用微任务,那可能拿到数据很久之后,才轮到setTimeout的宏任务执行),便应用了微任务。
从微任务的角度来看,Promise利用了微任务的特性,表现出对回调函数的延迟绑定。
通过将异步任务设置成微任务,将所有需要等待结果才能执行的逻辑都放在微任务中执行。在同步代码中,看起来像是先触发回调函数,再绑定回调函数的样子。
再结合返回值穿透,错误冒泡等特性,Promise以同步的方式来编写异步任务,解决了异步逻辑不连续、回调地狱等问题。
(另外,Promise搭配上协程,又能发挥出更大的作用,异步任务就一点也不像异步任务了。一旦微任务执行完毕,主线程的控制权在我们创建的协程中反复横跳。)
感谢观看
该文章首发于个人博客,若文中存在理解错误的地方,欢迎大佬指正。
title: 浏览器事件循环体系梳理
author: Jankin
authorLink: 'https://www.laic.club'
tags:
- 浏览器
- 事件循环
keywords: 前端 浏览器 事件循环 宏任务 微任务
description: 梳理浏览器事件循环体系
date: 2020-07-27 09:24:14