大概是目前最细致的事件循环详解(EventLoop)

886 阅读11分钟

本文正在参加「金石计划」

前言

之前也写过一篇关于JS事件循环机制的文章,但总感觉有些地方表述不大清晰,最近看了下袁老师大师课,结合自己的理解又重新梳理了下,希望对这方面有疑惑的同学能有所帮助

浏览器的进程模型

为什么讲事件循环要提到进程模型?

因为只有清楚了解了浏览器的进程模型,你才能够清楚的知道事件循环机制是发生在浏览器的哪个位置

image-20230315162318102

何为进程

程序运行需要有它自己专属的内存空间,可以把这块内存空间简单的理解为进程

何为线程

有了进程之后,就可以运行代码了

运行代码的"人",称之为"线程"

一个进程至少有一个线程,在进程开启后会自动创建一个线程来运行代码,该线程称之为主线程

如果一个程序需要同时执行多块代码,主线程就会启动更多的线程来执行代码,所以一个进程中可以包含多个线程(只有一个主线程)

浏览器有哪些进程和线程

浏览器是一个多进程多线程的应用程序

浏览器的内部工作非常复杂,为了避免相互影响,启动浏览器后,会启动多个进程同时工作

这里画一个简化的浏览器进程模型,包含

  • 浏览器进程

主要负责界面显示,用户交互,子进程管理等

  • 网络进程

负责加载网络资源

  • 渲染进程(重点)

渲染进程启动后,会开启一个渲染主线程,主线程负责执行HTML,CSS,JS代码

默认情况下,浏览器会为每个标签开启一个新的渲染进程,来保证不同标签页间互不影响(也是现在浏览器吃内存的原因之一)

image-20230315164600895

渲染主线程是如何工作的

前面说过,每个进程至少都有一个主线程在负责执行任务

这里我们着重关注渲染进程的渲染主线程是如何工作的

它要处理的任务包括但不限于

  • 解析 html
  • 解析 CSS
  • 计算样式
  • 布局
  • 绘制界面
  • 执行全局 js 代码
  • 执行事件处理函数
  • 执行定时器回调函数
  • 等等。。。。。

那么这么多的任务,浏览器是如何有序的工作呢?

答案就是排队

image-20230316104823173

我们先把渲染主线程的工作在做进一步的简化理解

所有的 JS 代码都由渲染主线程来负责执行,其它子线程负责把要执行的JS代码(一般是我们的回调函数)打包成任务,送去排队

为什么这里要着重强调其它线程,以往大部分资料都简单粗暴的把JS代码执行过程中生成的宏任务直接丢到事件队列中去,这是不对的,少了对应子线程处理任务的环节,会导致一些现象理解起来有问题

常见的就是定时器

setTimeout(() => {
  // fn1
  console.log(1)
}, 2000)
setTimeout(() => {
  // fn2
  console.log(2)
}, 1000)
​
// 2, 1

如果执行阶段就把宏任务的回调函数(fn1, fn2)丢到执行队列排队,那么先执行先排队,预期结果就会是先输出1,在输出2

但实际上这个回调任务是交给了其它线程(定时器线程)处理

  • 该线程会在1秒后把fn2丢到任务队列里排队,等待主线程执行,
  • 在2秒后把fn1丢到任务队列里排队,等待主线程执行
  • 要这么理解整个过程,才会得出先2后1的结果

通过渲染线程的工作理解事件循环

  • 在最开始的时候,渲染主线程会进入一个无限循环的过程
  • 每一次循环都会检查消息(任务)队列中是否有任务存在,如果有,取出第一个任务执行,执行完了进入下一次循环
  • 其它所有线程,都可以随时向消息队列添加任务,新的任务会追加在任务队列的末尾

上面整个过程,称为事件循环

看到这里,应该跟大家平时接触到的事件循环的理解就比较接近了,但是还没完,真正的重点开始了,就是为什么我们在聊事件循环的时候,会扯上异步呢?

何为异步?

代码执行过程中,会遇到一些无法立即处理的任务,比如:

  • 计时器完成后需要执行的任务 —— setTimeout
  • 网络请求后需要执行的任务 —— XHRfetch
  • 用户操作后需要执行的任务 —— addEventListener
  • ...

如果让渲染主线程等待这些任务的时机到达,就会导致主线程长期处于阻塞状态,从而导致浏览器卡死,如下:

image-20230316165052015

渲染主线程承担着极其重要的工作,无论如何都不能阻塞!

因此,浏览器选择使用异步的方式,来解决这个问题

image-20230316171014447

这样,主线程就只要按顺序一个一个执行任务就好,实际上它在执行任务的时候甚至都不用知道哪些是异步任务,它也不需要关心这些

这里可能有同学会疑问,说如果这样子的话那计时器是不是就很有可能不准了?

答案是是的,计时器在这种模式下就会产生误差

这里提供一段代码辅助大家理解

// 一段立即执行函数,但是里面有一个时长 5 秒的死循环
function waiting(time) {
    let curTime = new Date()
    while (curTime - time < 5000) {
        curTime = new Date()
    }
}
​
// 打印当前时间
console.log(new Date());
​
// 添加一个 1 秒后打印时间的计时器
setTimeout(() => {
    console.log('setTimeout:' + new Date())
}, 1000)
​
// 执行上面的延迟函数
waiting(new Date())

大家觉得这种情况下计时器能否按时在 1 秒后打印出结果呢?

答案是在 5 秒后,因为渲染主线程执行的代码里有一个 5 秒的循环

即使 1 秒后定时器线程会把打印当前时间的任务推到事件队列中去

但是此时渲染主线程还在忙,只有当渲染主线程的代码执行完毕后,它才会去事件队列中拿取第一个任务进行执行

image-20230317111650835

好了,了解浏览器异步的实现,我们来看一道常见的题目

如何理解 JS 的异步?

逻辑线是这样的:

因为JS是单线程,所以用同步策略会导致阻塞

为了解决阻塞问题,浏览器采用了异步的方式来避免

那异步是怎么做的呢,主线程将异步的任务交给其他线程处理

JS 是一门单线程的语言,这是因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个

渲染主线程负责诸多工作,渲染页面,执行 JS 都在其中

如果使用同步的方式,就极有可能导致主线程产生阻塞,从而导致任务队列中的其它任务无法得到执行

这样一来,繁忙的主线程白白的消耗时间,另一方面导致页面无法及时更新,给用户造成卡死现象

所以浏览器用异步的方式来避免

具体做法是当某些任务发生时,比如计时器,网络,事件监听等,主线程会将任务交给其他线程去处理,自身立即结束任务的执行,转而执行后续的代码。当其他线程完成时,将事先传递的回调函数包装成任务,加入到任务队列末尾排队,等待主线程的调度执行

在这种异步模式下,浏览器最大限度的保证了单线程的流畅运行

JS为何会阻塞渲染?

这个问题也很常见,一般会提到避免在 js 代码执行过程中操作了 Dom 或者样式,为了避免在页面布局样式还未稳定时就进行渲染,导致后续改变后需要重复渲染的问题

这个说法也对,但今天我们尝试从事件循环的角度来理解这个问题

同样先来看一段代码

<body>
    <h1>修改前</h1>
    <button>修改</button>
    <script>
        function waiting(duration) {
            let start = Date.now()
            while (Date.now() - start < duration) { }
        }
​
        let h1 = document.querySelector('h1')
        let button = document.querySelector('button')
        button.addEventListener('click', () => {
            console.log(Date.now())
            h1.textContent = '修改后'
            waiting(5000)
            console.log(Date.now())
        })
    </script>
</body>

简单解释下,就是界面有一段文本,我们用一个按钮添加一个点击修改文本的事件

在事件中我们加了一段5秒的死循环

按代码顺序来看,会是先执行修改文本,更新界面,然后进入死循环,输出时间

但实际是

会进入一段 5 秒等待,然后看到文本被修改

这里主要是想说明,渲染主线程它执行代码和渲染界面的工作是互斥的

js 在点击的时候有没有触发更新文本的动作,有

但是界面有没有渲染更新,还没有

但是会产生一个新的任务(绘制),当排到绘制任务执行完时,界面就更新了

image-20230317161913997

任务有优先级么?

看到这里,有的同学可能开始着急了,因为文章都快完了都还没有提到我们平时最常提到的微任务

我知道你很急,但你先别急,微任务的产生,实际上是为了完善任务的优先级

为什么这么说?

因为任务本身是没有优先级的概念一说,它遵循最简单的准则,先进先出

但是,消息队列是有优先级的!

浏览器通过把任务放到不同优先级的队列中,来实现不同类型任务的先后执行

而其中,我们常说的微任务队列,就是那个优先级最高的消息队列

在目前的浏览器实现中,常见的队列有下面这些:

  • 延迟队列:用于存放计时器到达后的回调任务(优先级 —— 中)
  • 交互队列:用于存放用户操作后产生的事件回调任务(优先级 —— 高)
  • 微队列:用于存放需要最快执行的任务(优先级 —— 最高)

关于微任务队列,最常见的就是我们可以通过 Promise 往里面添加任务,这里就不展开了

这个时候,我们就可以把完整的任务执行模型画出来了

image-20230317163853053

看到这里,基本上浏览器的事件循环机制就都清晰了,以后遇到一些关于异步任务输出先后顺序的题,按照这个思路去分析,绝对是稳的

常见问题

阐述一下 JS 的事件循环机制

事件循环又叫消息循环,是浏览器渲染主线程的工作方式

主线程会开启一个循环,每次循环从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列的末尾即可

过去把消息队列简单的分为宏任务队列和微任务队列,这种说法目前已经不适用于现代浏览器的工作模式

根据 W3C 的标准,每个任务有不同的类型,同类型的任务会在同一个队列中,并且不同的队列会有不同的优先级,其中,微任务队列的优先级必须是最高的

JS 的计时器能做到精确计时吗

不能

  • 计算机硬件层面本身就不支持精确计时
  • 调用操作系统的时间方法本身就会存在延迟
  • 按照 W3C 标准,多层计时器嵌套(5层),会带来 4 毫秒的误差
  • 最重要的,受事件循环的影响,有时候这个误差会更加不可控

总结

最后有一段话可以好好琢磨一下

  • 单线程是异步产生的原因(因为单线程有问题需要用异步来解决)
  • 事件循环是异步的实现方式(浏览器通过事件循环这个模式,实现了任务的异步执行)

最后

发个求职信息,今年刚从杭州转到深圳,目前咸鱼中,有内推坑位的欢迎私信滴滴^_^