了解JavaScript中的异步处理(1)执行模型和任务队列

567 阅读7分钟

目标人物和目的

  • 我知道如何实现异步处理,但有时我不知道最佳做法,因为我不知道它的工作原理
  • 由于对执行顺序的保证不甚了解,有些变化不能放心地进行部署
  • 我想更多地了解它的工作原理,以便我能够更系统地实施它

JavaScript执行模型

JavaScript执行模型嵌套在 Agent Cluster → Agent → Realm 中,如下图所示。HTML规范还规定了在网络浏览器中应如何处理这些问题。

Realm:

  • 单个页面对应于一个境界。 用 <iframe>window.open 等创建的窗口将是一个不同的Realm
  • 假设由浏览器扩展程序插入的内容脚本也会产生一个专门的Realm(需要验证)
  • Realm共享一个全局环境和图书馆功能

Agent:

  • 共享一个JavaScript对象的页面集合,如<iframe>window.open(相似来源的窗口代理)
  • WorkerWorklet属于与主页面不同的Agent(Dedicated worker agent, Shared worker agent, Service worker agent, Worklet agent)
  • 因为Agent共享一个事件循环,所以在Agent中总是最多只有一段JavaScript代码在运行

Agent Cluster:

  • SharedArrayBuffer是一个共享内存的Agent集合
  • 当页面本身的JavaScript和Web Worker中的JavaScript共享一个SharedArrayBuffer时,它就成为一个代理集群

除Agent Cluster之外的:

  • 通过消息传递的交互,如postMessage,没有特别的定义,因为它们不需要被建模为JavaScript规范

在Agent外部,可以使用线程并行,但在Agent内部,异步处理是使用任务并行,而不是线程并行。在下文中,我们将重点讨论Agent内部的异步处理问题(尤其是在同一领域内)

原子性

事实上,JavaScript没有并行性(在每个代理的基础上),这是一个强大的功能,使大多数处理在本质上是原子性的

// 不包括 await 或 then。这是在原子状态下执行的
this.counter++;

这就是为什么像Mutex这样熟悉的模式不会像其他语言那样经常出现在JavaScript中。

另一方面,这种约束可能会阻碍并行性和并发性的有效实施

工作队列

在JavaScript中,事件循环是由语言处理器管理的,而应用程序的执行是服从于它的。从事件循环中执行的单段JavaScript代码在ECMAScript中被称为"作业(Host)"

window.onload = () => { console.log("loaded"); }
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 负载完成后,这将被添加到作业队列中。
setTimeout(() => console.log("1s has passed"), 1000);
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 这将在1秒后被添加到作业队列中。

一次只能有一个工作在运行。当一个作业完成后(例如最外层的函数返回),下一个作业被执行。

工作没有统一的优先级,可能会被之前排队的工作打断。

在调度方面,主机环境并不要求统一对待Job。例如,网络浏览器和Node.js对待Promis-handling例如,网络浏览器和Node.js将处理承诺的工作视为比其他工作更优先;未来的功能可能会增加不以如此高优先级处理的工作

然而,规定与 "承诺 "有关的工作必须在同一个队列中。

工作 必须按照安排它们的HostEnqueuePromiseJob调用的相同顺序运行。

这确保了以下程序将依次输出0-9。

(async () => { for(let i = 0; i < 10; i += 2) console.log(await i); })()。
(async () => { for(let i = 1; i < 10; i += 2) console.log(await i); })()。

截至2021年,ECMAScript标准中没有定义与Promise无关的工作。这些将在其他规范中被额外定义,如HTML。

从今以后,我们将使用自己的术语,把一个microtick定义为Promise作业队列的一个周期。

web浏览器任务队列

ECMAScript作业在HTML规范中被称为任务。队列中还加载了ECMAScript作业以外的任务。该队列分为两种类型

  • 正常)任务队列
    • 可以有多个(正常)任务队列,用于不同类型的任务(取决于实施)。
  • 微任务队列
    • 排在微任务队列中的任务被称为微任务

在ECMAScript规范中,所有的任务队列和微任务队列一起被认为是一个作业队列

requestIdleCallback中定义的空闲回调列表/可运行回调列表也可以被视为任务队列的一个变体

事件循环的处理在§8.1.6.3中规定,与本文相关的内容可归纳为以下几点

  • 如果在任务队列中有一个任务,它将被检索和执行。
  • 微任务队列中的任务被获取并逐一执行,直到没有更多的任务。
  • 更新图纸。
  • 如果任务队列中有空间,背景任务将根据 RequestIdleCallback 规范进行排队。
  • 从头开始

这意味着

  • 微型任务比普通任务有优先权。
  • 微型任务的优先级高于绘图。

微任务比普通任务有优先权,这意味着它们会饿死。下面的等待忙碌循环对浏览器的负面影响几乎和无限循环一样,因为它阻断了绘图。

(async () => {
  while(true) {
    await null;
  }
})();

有两种典型的方法来排队等候微任务

  • 功能排队微任务
  • Promise.prototype.then函数。这个实体的HTML规范实现, HostEnqueuePromiseJob,要被加载到一个微任务队列中。
    • 这也是async/await中的await的情况。

另一方面,要显式地排队等候一个正常的任务并不容易。另一方面,要显式地对一个普通的任务进行排队并不容易,因为任务通常是由一些事件来排队的。 在 用于网络浏览器环境的setImmediate的polyfill中,使用了以下实现。

  • 在窗口代理下,你可以通过向自己发送 window.postMessage 来启动一个事件
  • 在Worker代理下,使用 MessageChannel postMessage
  • 使用 setTimeout 作为退路

我们使用自己的术语,将一个microtick定义为网络浏览器环境中微任务队列的一个周期。这与上一节中的定义是一致的。我们还将 任务队列周期定义 为一个tick,这是我们自己的术语。

Node.js任务队列

Node.js是基于Chrome的JavaScript引擎V8,任务的概念与网络浏览器相似。(queueMicrotask 和 Promise.prototype.then 是可用的)

除了微任务队列之外 process.nextTick 队列是Node.js的一个特性。一般来说, process.nextTick 优先于微任务,但如果它在一个微任务中被排队,它的处理优先级将低于其他微任务。这是因为任务队列是按以下顺序处理的

while(true) {
  waitForAnyTask();
  if (taskQueue.length > 0) taskQueue.pop().run();
  do {
    while (nextTickQueue.length > 0) nextTickQueue.pop().run();
    while (microtaskQueue.length > 0) microtaskQueue.pop().run();
  } while (nextTickQueue.length > 0);
}

process.nextTick 的存在是为了Node.js内置的I/O处理和历史原因。对于Node.js中内置的I/O处理,由于历史原因,现在推荐使用 queueMicrotask 。

与浏览器中的JavaScript不同,Node.js还提供了一个函数 setImmediate ,可用于将任务排入常规任务队列 。它的使用方式与 setTimeout 基本相同。

// 在执行之前处理当前的I/O任务等。
setImmediate(() => console.log("Hello!"))

在Node.js环境中,微任务队列的一个周期在我们自己的术语中被定义为一个Microtick。这与上一节中的定义是一致的。我们也把我们自己的术语定义 为一个任务队列周期,即一个tick

setTimeout

一个旧的API用于在一定时间后排队任务,而不是直接排队,这就是 setTimeout ,它在网络浏览器和Node.js中都有实现。

setTimeout(() => console.log("1 second has passed"), 1000);

网络浏览器和Node.js之间有许多不同之处

  • 最小秒数的行为差异(见下文)。
  • 在Node.js中,处理程序不能作为字符串传递。
  • 在Node.js中,返回的是一个定时器对象(Timeout),而不是一个定时器ID(数字)。
  • Node.js允许对定时器事件进行ref/unref操作。

浏览器

  • 时间的下限最初是0ms,但如果 setTimeout / setInterval 嵌套了5层以上,则为4ms。
  • 允许你等待的时间超过规定的时间

这里的嵌套指的是在 setTimeout 回调任务中调用 setTimeout 而增加的值。

// ← nesting level = 0
setTimeout(() => {
  // ← nesting level = 1
  setTimeout(() => {
    // ← nesting level = 2
    setTimeout(() => {
      // ← nesting level = 3
      setTimeout(() => {
        // ← nesting level = 4
        setTimeout(() => {
          // ← nesting level = 5
          setTimeout(() => {
            // ← nesting level = 6

	    // nesting level > 5 
            setTimeout(() => {
              // ← nesting level = 7
            }, 0);
          }, 0);
        }, 0);
      }, 0);
    }, 0);
  }, 0);
}, 0);

从历史上看,似乎类似的规则在实施时,每个浏览器的规则都略有不同

Node.js

  • 时间的下限是1ms,四舍五入为毫秒的整数值(截断)。
  • 不能保证你会准确地等待你所指定的时间,你有可能被向前或向后推迟。
// 在1毫秒后运行
setTimeout(() => console.log("foo"), 0.5);
// 5毫秒后运行
setTimeout(() => console.log("foo"), 5.1);

另外,Node.js返回的定时器对象支持ref/unref操作:在Web浏览器中,JavaScript环境的寿命是由页面的寿命决定的,但在Node.js中,进程的寿命需要以不同的方式确定。具体来说,当没有 "事情可做 "时,Node.js进程就会被终止,例如I/O事件或任务队列。然而,有些事件,如定时器,不应包括在终止条件中,使用unref允许你终止进程而不等待定时器事件的发生

摘要

  • JavaScript的执行模型有一个分层结构:Agent Cluster → Agent → Realm。对于大多数工作负载来说,你只需要考虑单个 Realm 中的行为。
  • 在单个Agent内(特别是在单个Realm内),每次只执行一个JavaScript代码,不会发生中断。这使得JavaScript的并行性和并发性模式与众不同。
  • JavaScript程序是由处理器提供的事件循环驱动的。这个执行单元在ECMAScript中称为作业,在Web浏览器/Node.js中称为任务/微任务。微观任务在执行中优先于任务。
  • 大多数任务是通过事件处理程序排队的,但也有一些API可以直接排队任务和微任务(setImmediate, queueMicrotask, Promise.prototype.then, process.nextTick)。
  • setTimeout 是一个定时器事件API,但由于它的时间分辨率有限,所以不能很好地替代 setImmediate

脚注

process.nextTick是在v0.1.26(2010)中添加的,setImmediate是在v0.9.1(2012)中添加的,Promise是在v0.11.13(2014)中添加的,而queueMicrotask是在v11.0.0(2018)中添加的