JavaScript 中的事件循环

JavaScript 有一个基于事件循环的并发模型,它负责执行代码、收集和处理事件以及执行队列中子任务,它被称为事件循环(Event Loop)。了解事件循环的工作方式和原理对于进行优化非常重要。

运行时概念

Stack,Heap,Queue

函数调用形成了一个有若干帧组成的栈。

栈是一种仅限在表为进行添加和删除的线性数据结构。

对象被分配在堆中。堆是一个用来表示一块非结构化内存区域的计算机术语。

堆是一种利用完全二叉树维护的线性数据结构。

队列

一个 JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用来处理这个消息的回调函数。

在事件循环的合适时刻,JavaScript 运行时会从最早进入消息队列的消息开始处理消息。被处理的消息会被移出消息队列,并以自身为参数调用与之关联的函数。

函数的处理会一直到执行栈再次为空为止,然后事件循环会处理下一个消息。

事件循环

事件循环的概念非常简单,一个无限的循环:JavaScript 引擎等待任务、执行任务,然后进入休眠状态,等待更多任务。

1
2
3
while (queue.waitForMessage()) {
queue.processNextMessage();
}

queue.waitForMessage() 会同步地等待消息到达。

消息队列中的消息只有在当前消息完全的执行完成后,接下来的消息才会被执行。这个特性会带来一些优秀的特性,包括:当一个函数执行时,它不会被抢占,只有在它运行完毕之后才会去运行任何其他的代码,才能修改这个函数操作的数据。

这个模型的一个缺点在于当一个消息需要太长时间才能处理完毕时,Web应用程序就无法处理与用户的交互。一个良好的习惯是缩短单个消息处理时间,并在可能的情况下将一个消息裁剪成多个消息。

零延迟

零延迟并不意味着回调会立即执行。以 0 为第二参数调用 setTimeout 并不表示在 0 毫秒后就立即调用回调函数,其等待的时间取决于队列里待处理的消息数量。延迟参数是运行时处理请求所需的最小等待时间,但并不保证是准确的等待时间。

运行时通信

一个 web worker 或者一个跨域的 iframe 都有自己的栈、堆和消息队列。两个不同的运行时只能通过 postMessage 方法进行通信。如果另一个运行时侦听 message 事件,则此方法会向该运行时添加消息。

浏览器下的事件循环实现

浏览器中所有任务都会被放到调用栈中等待主线程执行。

调用栈

调用栈是解释器(比如浏览器中的 JavaScript 解释器)追踪函数执行流的一种机制。当执行环境中调用了多个函数时,通过这种机制,我们能够追踪到哪个函数正在执行,执行的函数体中又调用了哪个函数。

宏任务和微任务

在每一个宏任务执行完成后,浏览器会立即执行所有来自微任务队列的微任务,这是比执行任何其它宏任务或者渲染更具有优先级的操作。

只有当微任务队列中的所有微任务都执行完之后,才会执行其它宏任务,这保证了在微任务执行时应用程序的环境不会改变。

如果想要在渲染或处理新的事件之前运行一个异步函数,可以通过 queueMicrotsk 方法来达成。

  • 宏任务 - scripts/setTimeOut/setInterval/I/O/UI Rendering
image-20201201110148857
  • 微任务 - Promise.then catch finally/MutationObserver
image-20201201110210529

同步任务和异步任务

Javascript 单线程任务被分为同步任务异步任务,同步任务会在调用栈中按照顺序等待主线程依次执行,异步任务会在异步任务有了结果后,将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),被读取到栈内等待主线程的执行。

任务执行逻辑

  • -> 选择当前要执行的任务队列,选择任务队列中最先进入的任务,如果任务队列为空即null,则执行跳转到微任务(MicroTask)的执行步骤。

    -> 将事件循环中的任务设置为已选择任务。

    -> 执行任务。

    -> 将事件循环中当前运行任务设置为 null

    -> 将已经运行完成的任务从任务队列中删除。

    -> microtasks 步骤:进入microtask检查点。

    -> 更新界面渲染。

    -> 返回第一步。

  • -> 设置 microtask 检查点标志为 true

    -> 当事件循环 microtask 执行不为空时:选择一个最先进入的 microtask 队列的 microtask,将事件循环的 microtask 设置为已选择的 microtask,运行 microtask,将已经执行完成的 microtasknull,移出 microtask 中的 microtask

    -> 清理 IndexDB 事务

    -> 设置进入 microtask 检查点的标志为 false

img img

事件循环的一些应用

分割占用 CPU 较多的任务

假设我们有一个需要占用非常多 CPU 的任务,当浏览器在处理这个任务时,它就不能做其它 DOM 相关的操作,处理用户事件等,这将导致浏览器变的卡顿甚至崩溃,显然这是不可接受的。但我们可以通过将大任务分割成较小任务的方法来避免这个问题。

比如下面的代码,在执行时会导致浏览器挂起一段不短的时间,这期间用户事件都不会得到处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let i = 0;

let start = Date.now();

function count() {
// 大任务
for (let j = 0; j < 1e9; j++) {
j++;
}

console.log("Done in " + (Date.now() - start) + "ms");
}

count();

如果用 setTimeout 将其分割成较小的任务,如果在小任务执行时有其它任务要执行,会被放入任务队列,当目前的分割小任务完成后立即执行这个任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let i = 0;

let start = Date.now();

function count() {
do {
i++;
} while(i % 1e6 !== 0)

if (i === 1e9) {
console.log("Done in " + (Date.now() - start) + "ms");
} else {
setTimeout(count);
}
}

count();

永不阻塞

JavaScript 的事件循环模型与许多其他语言不同的一个非常有趣的特性是,它永不阻塞。 处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待一个 IndexedDB 查询返回或者一个 XHR 请求返回时,它仍然可以处理其它事情,比如用户输入。

Node 中的事件循环、定时器和 process.nextTick()

作者

Y2hlbmdsZWk=

发布于

2019-06-06

更新于

2021-09-01

许可协议