事件循环

引言

现在用到的计时器, Promise, ajax 和 node, 都是事件循环相关的东西. 它在就业的时候, 可以极大的缩短面试的事件.

因为事件循环是前端的一个分水岭 它决定了能够走的多高, 走的多远.

浏览器的进程模型

为了介绍事件循环, 我们不能直接介绍, 还需要介绍浏览器. 因为事件循环和浏览器更加相关.

何为进程

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

并且, 每个应用至少有一个进程, 每个进程之间是 相互独立 的. 例如一个程序崩溃了, 别的程序不会直接崩溃. 另外, 即便要进行通信, 也需要双方同意.

何为线程

有了进程以后, 就可以运行程序的代码了. 运行代码的东西, 就叫做 线程.

所以说, 一个进程至少有一个线程, 在进程开启后就会自动创建一个线程来运行代码, 这个线程就叫做 主线程.

如果程序需要同时执行多个代码, 主线程就会启动更多的线程来执行代码, 即一个进程中可以包含多个线程.

如下图, 可以看到, 就算有再多的线程, 走的也是自己的内存空间.

浏览器有哪些进程和线程

浏览器其实就是一个多进程多线程的应用程序. 如今的浏览器内部工作都极其复杂, 复杂程度已经贴近于系统开发了.

可以说, 这才是真正的技术开发. 面向需求的开发都是普通级别的开发而已.

为了避免程序的崩溃, 启动浏览器后, 它会自动的启动多个进程.

虽然其实不止上面这三个, 但是我们最需要关注的就是这三个部分了. 查看任务管理器, 其实就可以直接看到浏览器的几个进程了.

|475

其中:

  1. 浏览器进程: 主要负责界面显示, 用户交互, 子进程管理等. 浏览器进程内部会启动多个线程处理不同的任务.
  2. 网络进程: 负责加载网络资源, 会启动多个线程处理多个不同的网络任务.
  3. 渲染进程: 启动后, 会开启一个 渲染主线程, 主要负责执行 HTML, CSS, JS 代码. 默认情况下, 每个页面会自动创建一个新的渲染进程, 保证不同的标签页不相互影响.

不过这样就会占用很多很多的内存空间.

[!abstract] 未来 Google 浏览器可能会有所变化

渲染主线程是如何工作的

渲染主线程是浏览器中最繁忙的线程, 他需要处理的任务包括但不限于:

  • 解析 HTML
  • 解析 CSS
  • 计算样式 (计算像素, 计算冲突…)
  • 布局
  • 处理图层
  • 每秒画 60 次页面 (FPS)
  • 执行全局 JS 代码
  • 执行事件处理函数
  • 执行计时器的回调函数

[!info] 思考题
为什么渲染进程不适用于多个线程来处理这些事情?

原因是做不到, 但是为什么呢? 思考思考…

如果现在遇到了一个问题: 如何调度任务呢? 比如:

  • 正在执行 JS 代码, 但是执行到一半的时候, 用户点击了一个按钮, 应该立刻取执行点击事件的处理函数吗?
  • 正在执行 JS 代码, 但是执行到一半的时候, 计时器到时间了, 要立刻去执行吗?

于是, 浏览器想到了一个办法: 排队.

  1. 最开始的时候, 渲染主进程会进入一个无限循环
  2. 每次循环都会检查消息队列是否有任务存在, 有的话就取出第一个任务执行, 执行后进入下一次循环; 没有的话进入休眠状态
  3. 其他的所有进程都可以随时向消息队列添加任务. 新任务会添加到消息队列的末尾; 如果添加新任务的时候为休眠状态, 则重新进入循环状态

所以, 如果用户点了个按钮, 其实就是加入了队列, 前面的 JS 代码执行完了才会回来, 从消息队列拿取新的任务.

这整个过程, 就叫做 事件循环 .

细节

何为异步

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

  • 计时器完成后需要执行的任务
  • 网络通信后需要执行的任务
  • 用户操作后需要执行的任务

如果让渲染主线程等待这些任务的时机到达, 那么就会让主线程阻塞, 这种情况就叫做 同步.

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

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

我们使用 setTimeout 其实是一种通知, 通知计时线程开始计时, 等到计时结束的时候, 通知主线程即可. 比如下面这几行代码, 输出就是反过来的:

1
2
3
4
setTimeout(() => {
console.log("Good");
}, 2000);
console.log("Hello");
1
2
3
Hello
<等了2秒>
Good

这样, 主线程就显著提高了工作的效率. 这就叫做 渲染主线程用不阻塞.

[!question] 面试题: 如何理解 JS 的异步

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

但是渲染主线程承担着诸多的工作, 渲染页面, 执行 JS 都在其中运行.

如果使用同步的方式, 则极有可能造成主线程产生阻塞, 从而导致消息队列的很多其他方法无法得到执行; 这样一来, 一方面会导致繁忙的主线程白白的浪费时间, 以方便会导致页面无法及时更新, 给用户造成卡死的现象.

所以浏览器采用异步的方式来避免. 具体做法是在某些任务发生时, 比如计时器, 网络, 事件监听, 主线程将任务交给其他线程处理, 自身立刻结束任务的执行, 转而执行后续的代码; 当其他线程完成的时候, 将实现传递的回调函数包装成任务, 加入到消息队列的末尾, 等待主线程调度执行.

这种异步模式下, 浏览器永不阻塞, 从而最大限度的保证了单线程的流畅运行.

JS 为何会阻碍渲染

下面是一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<h1>Hello Lc</h1>
<button>Change Title</button>
<script>
const h1 = document.querySelector("h1");
const btn = document.querySelector("button");

btn.onclick = () => {
h1.textContent = "Greet!";
for (;;) {}
};
</script>
</body>
</html>

这段代码理论上, 看起来是点击按钮后就会修改页面的元素; 但是实际上点击按钮后页面就卡死了, 也就是所谓的 JS 阻碍了渲染.

如果使用事件循环来解释, 就很简单了.

一开始, 可以理解为消息队列为空. 点击按钮后, 把回调函数包装成任务, 加入了消息队列中. 主线程拿到了这个任务, 其中的修改页面, 其实是渲染进程, 相当于又拆分放在了消息队列; 但是前面的当前任务还没有执行完毕, 所以页面卡死了.

任务有优先级吗

任务没有优先级, 只是在消息队列中, 先进后出.

但是, 消息队列是有优先级的. 根据 W3C 的解释:

  • 每个任务都有一个任务类型, 同一个类型的任务必须在同一个队列; 不同类型的任务可以分别属于不同的队列. 一次事件循环中, 浏览器根据实际情况, 从不同的任务中取出任务执行.
  • 浏览器必须准备好一个微队列, 微队列中的任务优先于其他所有任务执行.

在目前的 Chrome 浏览器中, 至少存在如下队列:

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

添加一个任务到微队列, 主要是使用 Rromise 和 MutationObserver. 例如:

1
Promise.resolve().then(函数)

这个时候, 函数的优先级就是最高的了. 例如如下代码:

1
2
3
4
5
6
7
8
9
setTimeout(() => {
console.log(1);
}, 0);

Promise.resolve().then(() => {
console.log(2);
});

console.log(3);

可以这么想, 第一个优先级低于第二个, 第二个是优先, 但是第三个直接就是主线程运行, 不需要等待. 所以输出自然就是 3 2 1 了.

[!question] 面试题: 阐述一下 JS 的事件循环
事件循环 (Event Loop) 又叫做消息循环 (Message Loop), 是浏览器渲染主线程的工作方式.

在 Chrome 源码中, 它开启了一个 for 死循环, 每次循环都会从消息队列中取出第一个任务执行, 其他线程只需要在合适的时候将任务添加到队列末尾即可.

过去将消息队列分为宏队列和微队列, 目前已经无法满足复杂的浏览器环境, 取而代之的是一种更加灵活多变的处理方式.

根据 W3C 的解释, 每个任务有不同的类型, 同类型的任务必须在同一个队列, 不同的任务可以属于不同的队列. 同时, 不同的队列有不同的优先级, 一次事件循环中, 浏览器自行决定权责哪一个任务. 但是浏览器必须有一个微队列, 微队列的任务一定是具有最高的优先级的, 必须优先调度执行.

[!question] 面试题: JS 中的计时器能做到精确计时吗? 为什么?
不能. 因为:

  1. 计算机硬件中没有原子钟, 无法做到精确计时
  2. 操作系统的计时函数本来就有少量偏差, 由于 JS 中的计时器最终调用的是操作系统的函数, 也就携带了这些偏差
  3. 按照 W3C 标准, 浏览器实现计时器的时候, 如果嵌套层级超过了 5 曾, 就会有至少 4 ms 的延迟, 相当于有了额外的偏差
  4. 受到事件循环的影响, 计时器的回调函数只能在主进程空闲的时候运行, 因此又带来了偏差.

总结

简单说, 最重要的就是两句话: 事件循环是异步的实现方式. 单线程是异步产生的原因.