宏任务 —> 微任务 —> requestAnimationFrame —> 重绘/重排 —> requestIdleCallback —> 宏任务 …….

CPU

计算机的核心是 CPU,它承担了所有的计算任务。
它就像一座工厂,时刻在运行。

进程

假定工厂的电力有限,一次只能供给一个车间使用。也就是说,一个车间开工的时候,其他车间都必须停工。
背后的含义就是,单个 CPU 一次只能运行一个任务。

进程就好比工厂的车间,它代表 CPU 所能处理的单个任务。
进程之间相互独立,任一时刻,CPU 只能运行一个进程,其他 进程处于非运行状态。CPU 使用时间片轮转进度算法,来实现同时运行多个 进程

CPU 是几核,就可以运行几个进程。

线程

一个车间里,可以有很多工人,他们共享着车间所有资源,协同完成一个任务。
线程就好比车间里的工人,一个 进程可以包括多个线程,多个线程共享 进程资源。

CPU、进程、线程之间的关系

在上文,我们已经简单了解 CPU、进程、线程,简单汇总一下

  • 进程是 CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位)
  • 线程是 CPU 调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)
  • 不同进程之间也可以通信,不过代价较大
  • 单线程多线程,都是指在一个进程内的单和多

浏览器是多进程的

我们已知 CPU进程线程之间的关系,对于计算机来说,每一个应用程序都是一个进程,而每一个应用程序都会分别有很多的功能模块,这些功能模块实际上是通过子进程来实现。对于这种子进程的扩展方式,我们可以称这个应用程序是多进程

而对于浏览器来说,浏览器就是多进程的,我在 Chrome 浏览器中打开了多个 tab,然后打开 windows 控制管理器:
image.png
如上图,我们看到一个 Chrome 浏览器启动了好多个进程。
总结一下:

  • 浏览器是多进程的。
  • 每一个 Tab 页,就是一个独立的进程。

浏览器包含了哪些进程

  • 主进程
    • 协调控制其他子进程(创建、销毁)
    • 浏览器界面显示,用户交互、前进、后退、收藏
    • 讲渲染进程得到内存中的 Bitmap,绘制到用户界面上
    • 处理不可见操作,网络请求,文件访问等
  • 第三方插件进程
    • 每种类型的插件对应一个进程,仅当使用该插件时才创建
  • GPU 进程
    • 用于 3D 绘制等
  • 渲染进程,就是我们说的浏览器内核
    • 负责页面渲染,脚本执行,事件处理等
    • 每个 tab 页一个渲染进程

那么对于普通的前端操作来说,最重要的进程就是 渲染进程,也就是我们常说的浏览器内核

浏览器内核(渲染进程)

从前文知道,进程和线程是一对多的关系

渲染进程有哪些线程

而对于渲染进程来说,它当然也是多线程的了,接下来我们来看一下渲染进程包含哪些线程

  • GUI渲染线程
    • 负责渲染页面,布局和绘制
    • 页面需要重绘和回流时,该线程就会执行
    • 与 js 引擎线程互斥,防止渲染结果不可预期
  • JS引擎线程
    • 负责处理解析 和 执行 JS 脚本程序(维护一个执行队列 和 内存堆)
    • 只有一个 JS 引擎线程(单线程)
    • 与 GUI 渲染线程互斥,防止渲染不可预期
  • 事件触发线程
    • 用来控制事件循环(鼠标点击、setTimeout、ajax等)
    • 当事件满足触发条件时,将事件放入到 JS 引擎所在的执行队列中
  • 定时触发器线程
    • setTimeout 与 setInterval 所在线程
    • 定时任务并不收由 JS 引擎计时的,是由定时触发线程来计时的
  • 异步 http 请求线程
    • 浏览器有一个单独的线程用于处理 AJAX 请求
    • 当请求完成时,若有回调函数,通知事件触发线程

当我们了解了渲染进程包括的这些线程后,我们思考两个问题:

  1. 为什么 JS 是单线程的
  2. 为什么 GUI 渲染线程 和 JS引擎线程 互斥

为什么 JS 是单线程的

首先是历史原因,在创建 JS 这门语言时,多进程多线程的架构并不流行,硬件支持并不好。
其次是因为多线程的复杂性,多线程操作需要加锁,编码的复杂性会增高。
而且,如果同时操作 DOM,在多线程不加锁的情况下,最终会导致 DOM 渲染的结果不可预期

比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

为什么 GUI 渲染进程 与 JS引擎线程 互斥

这是由于 JS 是可以操作 DOM 的,如果同时修改元素属性并同时渲染界面(即 JS线程UI线程同时运行),那么渲染线程前后获得的元素就可能不一致了。

因此,为了防止渲染出现不可预期的结果,浏览器设定GUI渲染线程JS引擎线程为互斥关系。
JS引擎线程执行时 GUI渲染线程会挂起,GUI 更新则会被保存在一个队列中等待JS引擎线程空闲时立即被执行

从 Event Loop 看 JS 的运行机制

到这里,我们开始进入我们的主题,什么是 Event Loop
先理解一些概念:

  • JS 分为同步任务和异步任务
  • 同步任务都在 JS 引擎线程上执行,形成一个执行栈
  • 事件触发线程管理一个任务队列,异步任务触发条件达成,将回调事件放到任务队列
  • 执行栈中所有同步任务执行完毕,此时 JS 引擎线程空闲,系统会读取任务队列,将可运行的异步任务回调事件添加到执行栈中,开始执行

事件循环 Event Loop 之浏览器 - 图2

我们写setTimeout/setIntervalXHR/fetch代码时,这些代码本身是同步任务,而他们的回调函数才是异步任务。

  1. 当代码执行到setTimeout/setTInterval,实际上是JS引擎线程通知定时触发器线程,间隔一个时间后,会触发一个回调事件,而定时触发器线程在接收到这个消息后,会在等待的时间后,将回调事件放入到由事件触发线程所管理的事件队列

  2. 当代码执行到XHR/fetch时,实际上是JS引擎线程通知异步http请求线程,发送一个网络请求,并制定请求完成后的回调事件,而异步http请求线程在接受到收到这个消息后,会在请求成功后,将回调事件放入到由事件触发线程所管理的事件队列

当我们的同步任务执行完,JS引擎线程会询问事件触发线程,在事件队列中是否有待执行的回调函数,如果有就会加入到执行栈中交给JS引擎线程执行

事件循环 Event Loop 之浏览器 - 图3

总结一下:

  • JS引擎线程只执行执行栈中的事件
  • 执行栈中的代码执行完毕,就会读取事件队列中的事件
  • 事件队列中的回调事件,是由各自线程插入到事件队列中的
  • 如此循环

宏任务、微任务

什么是宏任务

我们可以将每次执行栈执行的代码当做是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行), 每一个宏任务会从头到尾执行完毕,不会执行其他。

我们前文提到过JS引擎线程GUI渲染线程是互斥的关系,浏览器为了能够使宏任务DOM任务有序的进行,会在一个宏任务执行结果后,在下一个GUI渲染线程开始工作,对页面进行渲染

宏任务 —> 渲染 —> 宏任务 —> 渲染 —> ….

宏任务包含:script(整体代码)setTimeoutsetIntervalsetImmediateI/OUI Render

第一个例子

  1. document.body.style.background = 'red';
  2. document.body.style.background = 'blue';
  3. document.body.style.background = 'aqua';

效果如下:
Video_20220114105657.gif
页面背景在瞬间变成蓝色,以上代码属于同一次的宏任务,所以全部执行完才触发页面渲染,渲染时GUI线程会将所有 UI 改动优化合并,所以视觉效果上,只会看到页面变成蓝色

第二个例子

  1. document.body.style.background = 'red';
  2. setTimeout(function () {
  3. document.body.style.background = 'aqua';
  4. }, 500)

效果如下
Video_20220114110520.gif
页面先是红色,然后是蓝色,因为以上代码是两次宏任务,第一次宏任务执行的代码把背景变红,然后触发渲染,第二次宏任务把背景变蓝。

什么是微任务

我们已经知道宏任务结束后,会执行渲染,然后执行下一个宏任务,而微任务可以理解成在当前宏任务执行后,立即执行的任务,然后才进行渲染。

也就是说,当宏任务执行完后,会在渲染前,将执行期间产生的所有微任务都执行完。

宏任务 —> 微任务 —> 渲染 —> 宏任务 —> 微任务 —> 渲染 —> …

微任务包含:Promise的回调MutationObserver

事件循环 Event Loop 之浏览器 - 图6

第一个例子

  1. document.body.style = 'background:blue'
  2. console.log(1);
  3. Promise.resolve().then(()=>{
  4. console.log(2);
  5. document.body.style = 'background:black'
  6. });
  7. console.log(3);

Video_20220114121638.gif
控制台输出 1 3 2,是因为 promise 对象的 then 方法的回调是异步执行,所以 2 最后输出

页面的背景色直接变成黑色,没有经过蓝色的阶段,是因为,我们在宏任务中将背景设置为蓝色,但在进行渲染前执行了微任务回调,在微任务回调中,将背景变成黑色,然后才执行渲染。

第二个例子

  1. const fn1 = () => {
  2. console.log(1)
  3. Promise.resolve(3).then(data => console.log(data))
  4. }
  5. const fn2 = () => console.log(2);
  6. setTimeout(fn1, 0)
  7. setTimeout(fn2, 0)
  8. // print : 1 3 2

一开始,执行栈中有两个 setTimeout,代码执行后,fn1、fn2分别被放入到事件队列
由于执行栈已经空了,把 fn1放入 执行栈并执行,打印出了 1,还执行了promise ,并将 promise 回调存到微任务队列中。
现在执行栈为空,事件队列中还有 fn2微任务队列还有promise 回调,讲道理,微任务队列先执行,所以 打印出 3
最后,打印出 2

总结 Event Loop 过程

  1. 一开始,执行一个 宏任务(栈没有就从 事件队列中获取)
  2. 执行过程中如果遇到 微任务,就把微任务放入 微任务队列
  3. 宏任务执行完毕后,立即执行当前微任务队列中所有微任务(依次执行)
  4. 当前宏任务执行完毕,开始检查渲染,然后GUI 线程接管渲染
  5. 渲染完毕后,JS 线程继续接管,开始下一个宏任务(从事件队列中获取)

事件循环 Event Loop 之浏览器 - 图8

练习题

题(1)

  1. console.log(1);
  2. // timer-1
  3. setTimeout(() => {
  4. console.log(2);
  5. Promise.resolve().then(() => {
  6. // then-1
  7. console.log(3);
  8. })
  9. });
  10. new Promise((resolve, rejcet) => {
  11. // promise-1
  12. console.log(4);
  13. resolve(5);
  14. }).then(res => {
  15. // then-2
  16. console.log(res);
  17. })
  18. // timer-2
  19. setTimeout(() => {
  20. console.log(6);
  21. });
  22. console.log(7);

结果: 1 -> 4 -> 7 -> 5 -> 2 -> 3 -> 6

题(2)

  1. console.log('script start');
  2. async function async1() {
  3. await async2();
  4. console.log('async1 start');
  5. }
  6. async function async2() {
  7. console.log('async2 end');
  8. }
  9. async1();
  10. setTimeout(() => {
  11. console.log('setTimeout');
  12. }, 0);
  13. new Promise(resolve => {
  14. console.log('Promise');
  15. resolve()
  16. }).then(() => {
  17. console.log('promise1');
  18. }).then(() => {
  19. console.log('promise2');
  20. })
  21. console.log('script end');

答案:script start -> async2 end -> Promise -> script end -> async1 start -> promise1 -> promise2 -> setTimeout

因为 async-await 就是 Promise + generator 的一种语法糖而已,因此上述代码可以经过转换得到如下代码:

  1. console.log('script start');
  2. function async1() {
  3. // await 可以换成 promise.then, 后续代码嵌入 then 内
  4. async2().then(() => {
  5. console.log('async1 start');
  6. });
  7. }
  8. async function async2() {
  9. console.log('async2 end');
  10. }
  11. async1();
  12. setTimeout(() => {
  13. console.log('setTimeout');
  14. }, 0);
  15. new Promise(resolve => {
  16. console.log('Promise');
  17. resolve()
  18. }).then(() => {
  19. console.log('promise1');
  20. }).then(() => {
  21. console.log('promise2');
  22. })
  23. console.log('script end');

await 在执行 async 函数时是会有阻塞性的,所以 async end 出现在 Promise 之前。

参考文章

《「前端进阶」从多线程到Event Loop全面梳理》
《深入理解 JavaScript Event Loop》