JavaScript-多线程编程-全-

JavaScript 多线程编程(全)

原文:zh.annas-archive.org/md5/17dd1a4e39b13da9482cb2aaac826026

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

您现在手中的这本书非常有趣。它是一本 JavaScript 书籍,以 C 语言编写的示例开篇,使用显式单线程编程语言讨论多线程,并提供了何时以及如何有意地阻塞事件循环的绝佳示例,这与多年来专家一直告诉您绝不能这样做形成了鲜明对比,并以关于为什么您实际上可能不想使用本书描述的机制的优缺点的出色列表结束。更重要的是,这是一本我认为无论您的代码将被部署和运行在何处,都应该阅读的 JavaScript 开发者必读书籍。

当我与公司合作帮助它们构建更高效、性能更优的 Node.js 和 JavaScript 应用程序时,我经常不得不退后一步,首先讨论许多开发者在编程语言方面的常见误解。例如,我曾经与一位有着悠久 Java 和.NET 开发经验的工程师辩论,他认为在 JavaScript 中创建一个新的 Promise 很像在 Java 中创建一个新线程(事实并非如此),并且 Promise 允许 JavaScript 并行执行(实际上并不允许)。在另一次对话中,有人创建了一个 Node.js 应用程序,同时产生了超过一千个并行的工作线程,但在仅有八个逻辑 CPU 核心的机器上测试时,并没有看到预期的性能改进。从这些对话中得到的教训很明显:多线程、并发和并行仍然对很大比例的 JavaScript 开发者来说是非常陌生和困难的主题。

处理这些误解直接导致我(与我的同事和 Node.js 技术指导委员会成员 Matteo Collina 合作)开发了“破碎的承诺”研讨会,我们在其中阐述了 JavaScript 异步编程的基础,教导工程团队如何更有效地推理他们的代码执行顺序和各种事件的时序。这也直接导致了与 Node.js 核心贡献者 Anna Henningsen 合作开发的 Piscina 开源项目,该项目提供了在 Node.js worker 线程之上的工作池模型的最佳实践实现。但这些只是帮助解决挑战的一部分。

在这本书中,Bryan 和 Thomas 精心阐述了多线程开发的基础知识,巧妙地展示了各种 JavaScript 运行时(如 Web 浏览器和 Node.js)如何在不具备内置机制的编程语言中实现并行计算。因为多线程支持的责任已落在运行时上,并且这些运行时之间存在许多差异,因此浏览器和 Node.js 等平台采用了不同的多线程实现方式。虽然它们共享类似的 API,但 Node.js 中的工作线程并不真正等同于 Web 浏览器中的 Web Worker。在浏览器中,对共享工作者、Web Worker 和 Service Worker 的支持几乎是普遍存在的,而 Node.js 中的工作线程已经存在了好几年,但对于 JavaScript 开发者来说,它们依然是一个相对较新的概念。无论你的 JavaScript 运行在哪里,这本书都会提供重要的见解和信息。然而,最重要的是,作者们花时间详细解释为什么你应该关心在你的 JavaScript 应用程序中使用多线程。

James Snell,

Node.js 技术指导委员会成员

前言

我(Thomas)和 Bryan 第一次见面是在我去日本移动游戏开发公司 DeNA 旧金山分公司面试时。显然,大部分高层管理人员都要说不,但在那天晚上我们俩在一个 Node.js 聚会上一起玩了一会儿后,Bryan 去说服他们给我一个 offer。

在 DeNA 工作期间,Bryan 和我致力于编写可重用的 Node.js 模块,使游戏团队可以构建他们的游戏服务器,根据需要合并组件以满足他们的游戏需求。性能一直是我们始终在衡量的东西,并且在性能上指导游戏团队也是工作的一部分;我们的服务器一直在由传统依赖于 C++ 的行业开发者持续审查。

我们两个也在其他领域合作过。在一个名为 Intrinsic 的小型安全创业公司工作时,我们的另一个角色是专注于加固 Node.js 应用程序,达到了一个完全细化的水平,我怀疑世界上再也不会出现像它这样的产品。性能调优也是那款产品的一个重大问题,因为客户不希望降低吞吐量。我们花费了许多时间进行基准测试,仔细研究火焰图,并深入研究内部的 Node.js 代码。如果工作线程模块在我们的客户需求的所有 Node.js 版本中都可用,我毫不怀疑我们会将其纳入产品中。

我们也在非就业角色中合作过。NodeSchool SF 就是一个例子,我们两个都自愿教授他人如何使用 JavaScript 并创建 Node.js 程序。我们还在许多同样的会议和聚会上演讲过。

你们两位作者都对 JavaScript 和 Node.js 充满热情,并且乐于将它们教给他人,以消除误解。当我们意识到关于构建多线程 JavaScript 应用程序的文档极度匮乏时,我们知道我们必须采取行动。这本书源于我们不仅要教育他人 JavaScript 的能力,还要帮助证明像 Node.js 这样的平台在构建利用现有硬件的高性能服务方面与其他任何平台一样强大。

目标读者

本书的理想读者是一名已经写了几年 JavaScript 的工程师,可能没有写多线程应用程序的经验,甚至没有使用过传统多线程语言如 C++ 或 Java 的经验。我们确实包含了一些示例 C 应用程序代码,作为一种多线程的共同语言,但读者不必熟悉或理解这些代码。

如果您有使用这类语言的经验,那很好,本书将帮助您了解 JavaScript 相应功能的等价物。另一方面,如果您只用 JavaScript 编写代码,那么本书同样适合您。我们包括跨多个学习层次的信息;这包括低级 API 参考、高级模式以及足够填补任何空白的技术边角料。

目标

也许本书最激动人心的目标之一是向社区传达这样的知识:使用 JavaScript 可以构建多线程应用程序。传统上,JavaScript 代码受限于单个核心,事实上,有许多 Twitter 线程和论坛帖子描述了这种语言。通过《多线程 JavaScript》这样的标题,我们希望彻底消除 JavaScript 应用程序被限制在单个核心的观念。

更具体地说,本书的目标是教会读者有关编写多线程 JavaScript 应用程序的多个方面。在阅读完本书之后,您将了解到浏览器中提供的各种 Web Worker API、它们的优势和劣势,以及何时应该使用哪种 API。关于 Node.js,您将了解到 worker threads 模块以及其 API 与浏览器中 API 的比较。

本书着重介绍了构建多线程应用的两种方法:一种是使用消息传递,另一种是使用共享内存。通过阅读本书,您将了解到实现每种方法所使用的 API,以及何时应该选择其中一种方法或两种方法的结合,并且您甚至将亲自动手使用一些基于这些方法的高级模式。

本书中使用的惯例

本书使用以下排版惯例:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

常量宽度

用于程序列表,以及在段落中引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

常量宽度粗体

显示用户应该直接输入的命令或其他文本。

常量宽度斜体

显示应该由用户提供的值或根据上下文确定的值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般注释。

警告

此元素表示警告或注意。

使用代码示例

补充材料(代码示例、练习等)可从https://github.com/MultithreadedJSBook/code-samples下载。

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书的目的是帮助您完成工作。一般情况下,如果本书提供示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分,否则您无需联系我们以获得许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发 O’Reilly 书籍的示例需要许可。引用本书回答问题并引用示例代码不需要许可。将本书的大量示例代码整合到您产品的文档中需要许可。

我们感谢但通常不需要署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Multithreaded JavaScript by Thomas Hunter II and Bryan English(O’Reilly)。Copyright 2022 Thomas Hunter II and Bryan English, 978-1-098-10443-6。”

如果您觉得您对代码示例的使用超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。

O’Reilly 在线学习

注意

40 多年来,O’Reilly Media为企业提供技术和商业培训、知识和见解,帮助它们取得成功。

我们独特的专家和创新者网络通过书籍、文章以及我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问实时培训课程、深度学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的广泛文本和视频的机会。欲了解更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版商:

  • O’Reilly Media, Inc.

  • Gravenstein Highway North 1005 号

  • 加利福尼亚州塞巴斯托波尔 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为本书设立了一个网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/multithreaded-js

发送电子邮件至bookquestions@oreilly.com以就本书发表评论或提出技术问题。

欲了解有关我们的图书和课程的新闻和信息,请访问http://oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://www.youtube.com/oreillymedia

致谢

感谢以下人员提供的详细技术审查,使本书得以实现:

Anna Henningsen(@addaleax

Anna 目前是德国 MongoDB 开发者工具团队的一员,过去五年来一直是 Node.js 核心的最活跃贡献者之一,并在平台上实现了工作线程的重要参与。她对 Node.js 及其社区充满激情。

Shu-yu Guo (@_shu)

Shu 致力于 JavaScript 的实现和标准化工作。他是 TC39 委员会代表之一,ECMAScript 规范的编辑之一,也是内存模型的作者。他目前在 Google 的 V8 引擎团队工作,负责领导 JavaScript 语言特性的实现和标准化工作。之前,他曾在 Mozilla 和 Bloomberg 工作过。

Fernando Larrañaga (@xabadu)

Fernando 是一名工程师和开源贡献者,多年来在南美和美国领导 JavaScript 和 Node.js 社区。他目前是 Square 的高级软件工程师,是 NodeSchool SF 的组织者,在 Twilio 和 Groupon 等其他主要科技公司有过任职,自 2014 年以来一直在开发企业级 Node.js 并扩展数百万用户使用的 Web 应用程序。

第一章:引言

计算机过去要简单得多。这并不是说它们易于使用或编写代码,但从概念上讲,可用的东西要少得多。20 世纪 80 年代的个人电脑通常只有一个 8 位 CPU 核心,而且内存并不多。通常情况下,你只能一次运行一个程序。如今我们所认识的操作系统甚至不会与用户交互的程序同时运行。

最终,人们希望能够同时运行多个程序,于是多任务处理诞生了。这使得操作系统能够通过在程序之间切换执行来同时运行多个程序。程序可以通过让出执行权给操作系统来决定何时适当地让另一个程序运行。这种方法被称为协作式多任务

在协作式多任务环境中,如果程序由于任何原因未能让出执行权,则没有其他程序可以继续执行。这种打断其他程序的行为是不可取的,因此操作系统最终向抢占式多任务靠拢。在这种模型中,操作系统会确定在何时将哪个程序运行在 CPU 上,使用自己的调度概念,而不是依赖于程序自身决定何时切换执行。直到今天,几乎每个操作系统都使用这种方法,即使在多核系统上也是如此,因为通常我们运行的程序比 CPU 核心要多。

同时运行多个任务对程序员和用户来说都非常有用。在引入线程之前,单个程序(即单个进程)无法同时运行多个任务。相反,希望并发执行任务的程序员要么将任务分割成较小的部分并在进程内部进行调度,要么在不同的进程中运行独立的任务并通过彼此通信。

即使在今天,某些高级语言中运行多个任务的适当方式仍然是运行额外的进程。在一些语言中,如 Ruby 和 Python,存在全局解释器锁(GIL),意味着一次只能执行一个线程。虽然这使得内存管理更加实际,但使得多线程编程对程序员来说并不那么有吸引力,而是采用多进程的方式。

直到不久前,JavaScript 是一种仅支持将任务分割并安排其后续执行的语言,在 Node.js 的情况下则运行额外的进程。我们通常会使用回调函数或 Promise 将代码分割成异步单元。用这种方式编写的典型代码块可能看起来像是示例 1-1,通过回调或await来分割操作。

示例 1-1. 使用两种不同模式编写的典型异步 JavaScript 代码块的示例
readFile(filename, (data) => {
  doSomethingWithData(data, (modifiedData) => {
    writeFile(modifiedData, () => {
      console.log('done');
    });
  });
});

// or

const data = await readFile(filename);
const modifiedData = await doSomethingWithData(data);
await writeFile(filename);
console.log('done');

如今,在所有主要的 JavaScript 环境中,我们都可以访问线程,与 Ruby 和 Python 不同的是,我们没有全局解释器锁(GIL),使它们在执行 CPU 密集型任务时变得无效。相反,会做出其他权衡,比如不直接在线程之间共享 JavaScript 对象。尽管如此,线程对 JavaScript 开发人员来说仍然是有用的,用于隔离 CPU 密集型任务。在浏览器中,还有专用线程,它们具有与主线程不同的功能集。我们可以做到这一点的详细内容是后续章节的主题,但是为了给您一个概念,例如在浏览器中生成一个新线程并处理消息,可以像示例 1-2 那样简单。

示例 1-2. 生成浏览器线程
const worker = new Worker('worker.js');
worker.postMessage('Hello, world');

// worker.js
self.onmessage = (msg) => console.log(msg.data);

本书的目的是探索和解释 JavaScript 线程作为一种编程概念和工具。您将学习如何使用它们,更重要的是,何时使用它们。并非每个问题都需要用线程来解决。即使是每个 CPU 密集型问题也不需要用线程来解决。软件开发人员的工作是评估问题和工具,以确定最合适的解决方案。这里的目标是为您提供另一种工具,并提供足够的知识,使您知道何时以及如何使用它。

什么是线程?

在所有现代操作系统中,除了内核外,所有执行单元都被组织成进程和线程。开发人员可以使用进程和线程及它们之间的通信,为项目添加并发性。在具有多个 CPU 核心的系统上,这也意味着增加并行性。

当您执行一个程序,例如 Node.js 或代码编辑器时,您正在启动一个进程。这意味着代码被加载到该进程独有的内存空间中,没有其他内存空间可以被程序直接访问,除非向内核请求更多内存或映射到不同的内存空间。如果不添加线程或额外的进程,只有一个指令会按程序代码规定的适当顺序依次执行。如果您不熟悉,您可以将指令看作是代码的单个单元,如一行代码。(事实上,指令通常对应于处理器汇编代码中的一行!)

程序可能会生成额外的进程,这些进程有它们自己的内存空间。这些进程不共享内存(除非通过额外的系统调用映射),并且有它们自己的指令指针,这意味着每个进程可以同时执行不同的指令。如果这些进程在同一个核心上执行,处理器可能会在这些进程之间来回切换,暂时停止一个进程的执行,然后执行另一个进程。

一个进程也可以生成线程,而不是完整的进程。线程与它所属的进程共享内存空间,除了共享内存空间外,线程就像进程一样,每个线程都有自己的指令指针。关于进程执行的所有属性同样适用于线程。因为它们共享内存空间,所以在线程之间共享程序代码和其他值很容易。这使得它们比进程更有价值,用于向程序添加并发性,但也增加了编程的一些复杂性,我们将在本书后面讨论。

利用线程的典型方法是将 CPU 密集型工作(如数学运算)转移到额外的线程或线程池,而主线程则可以通过检查内部的新交互来与用户或其他程序进行外部交互。许多经典的 Web 服务器程序,如 Apache,使用这样的系统来处理大量的 HTTP 请求负载。这可能看起来类似于图 1-1。在这种模型中,HTTP 请求数据传递给工作线程进行处理,当响应准备就绪时,将其返回给主线程,以返回给用户代理。

图 1-1. HTTP 服务器中可能使用的工作线程

要使线程有用,它们需要能够相互协调。这意味着它们必须能够做一些像等待其他线程上的事件发生和从它们那里获取数据的事情。正如讨论的那样,我们在线程之间有一个共享的内存空间,并且通过一些其他基本的原语,可以构建传递消息的系统。在许多情况下,这些构造在语言或平台级别是可用的。

并发与并行比较

区分并发和并行非常重要,因为在多线程编程中经常会遇到它们。这些术语密切相关,根据情况可能意味着非常相似的事物。让我们从一些定义开始。

并发

任务在重叠的时间内运行。

并行

任务在完全相同的时间内运行。

虽然它们看起来可能意味着相同的事情,但请考虑任务可能会被分解成较小的部分,然后交错执行。在这种情况下,并发可以在没有并行的情况下实现,因为任务运行的时间框架可以重叠。要使任务并行运行,它们必须在完全相同的时间内运行。一般来说,这意味着它们必须在不同的 CPU 核心上完全同时运行。

考虑图 1-2。在图中,我们有两个并行和并发运行的任务。在并发的情况下,任何时刻只有一个任务在执行,但在整个时间段内,执行会在这两个任务之间切换。这意味着它们在重叠的时间内运行,因此符合并发的定义。在并行的情况下,两个任务同时执行,因此它们是并行运行的。由于它们也在重叠的时间段内运行,它们也同时在并发运行。并行是并发的一个子集。

可视化并行性差异。

图 1-2. 并发与并行比较

线程不会自动提供并行性。系统硬件必须通过具有多个 CPU 核心来允许此操作,并且操作系统调度程序必须决定在单独的 CPU 核心上运行线程。在单核系统或运行线程多于 CPU 核心的系统中,多个线程可以通过在适当时刻之间切换在单个 CPU 上同时运行。此外,在具有像 Ruby 和 Python 中的 GIL 的语言中,线程明确地被阻止提供并行性,因为整个运行时只能同时执行一个指令。

在时间上也要考虑这一点很重要,因为线程通常被添加到程序中以提高性能。如果您的系统只允许由于只有一个 CPU 核心可用或已经加载了其他任务而导致并发,则使用额外的线程可能没有任何感知上的好处。事实上,线程之间的同步和上下文切换的开销可能会导致程序表现更差。始终在预期运行的条件下测量应用程序的性能。这样,您可以验证多线程编程模型是否真正对您有益。

单线程 JavaScript

历史上,JavaScript 运行的平台不提供任何线程支持,因此这门语言被认为是单线程的。无论何时听到有人说 JavaScript 是单线程的,他们指的是这种历史背景以及它自然倾向的编程风格。事实上,尽管本书的标题如此,但语言本身没有任何内建功能来创建线程。这并不会让人太惊讶,因为它也没有任何内建功能来与网络、设备或文件系统交互,或者进行任何系统调用。确实,即使像 setTimeout() 这样的基础功能实际上也不是 JavaScript 的特性。相反,虚拟机(VM)嵌入的环境,如 Node.js 或浏览器,通过特定于环境的 API 提供这些功能。

大多数 JavaScript 代码不使用线程作为并发原语,而是以事件驱动的方式在单个执行线程上操作。各种事件如用户交互或 I/O 发生时,它们会触发事先设置为在这些事件上运行的函数的执行。这些函数通常称为回调函数,是 Node.js 和浏览器中异步编程的核心。即使在承诺或async/await语法中,回调函数也是底层原语。重要的是要认识到回调函数不是并行运行或与任何其他代码并行运行。当回调函数中的代码正在运行时,这是当前唯一正在运行的代码。换句话说,任何给定时间只有一个调用堆栈是活动的。

通常很容易认为操作是并行进行的,实际上它们是同时进行的。例如,想象一下你想要打开包含数字的三个文件,分别命名为1.txt2.txt3.txt,然后将结果相加并打印出来。在 Node.js 中,你可以像这样做示例 1-3。

示例 1-3. 在 Node.js 中并发读取文件
import fs from 'fs/promises';

async function getNum(filename) {
  return parseInt(await fs.readFile(filename, 'utf8'), 10);
}

try {
  const numberPromises = [1, 2, 3].map(i => getNum(`${i}.txt`));
  const numbers = await Promise.all(numberPromises);
  console.log(numbers[0] + numbers[1] + numbers[2]);
} catch (err) {
  console.error('Something went wrong:');
  console.error(err);
}

要运行此代码,请将其保存在名为reader.js的文件中。确保你有名为1.txt2.txt3.txt的文本文件,每个文件包含整数,然后使用node reader.js运行程序。

由于我们正在使用Promise.all(),我们正在等待所有三个文件被读取和解析。如果你仔细看,这甚至可能看起来与本章后面的 C 示例中的pthread_join()类似。然而,仅仅因为承诺是一起创建和等待的,并不意味着解析它们的代码同时运行,只是它们的时间框架是重叠的。仍然只有一个指令指针,并且一次只执行一个指令。

在没有线程的情况下,只有一个 JavaScript 环境可供使用。这意味着一个 VM 实例,一个指令指针和一个垃圾收集器实例。所谓的一个指令指针,意味着 JavaScript 解释器在任何给定时间只执行一个指令。这并不意味着我们受限于一个全局对象。在浏览器和 Node.js 中,我们都可以使用realms

可以将 realms 视为 JavaScript 环境的实例,如提供给 JavaScript 代码的。这意味着每个 realm 都有自己的全局对象,以及全局对象的所有相关属性,例如内置类如Date和其他对象如Math。在 Node.js 中全局对象被称为global,在浏览器中被称为window,但在现代版本中,你可以使用globalThis来引用全局对象。

在浏览器中,网页中的每个框架都有一个用于其中所有 JavaScript 的领域。因为每个框架都有自己的 Object 和其他原语的副本,您会注意到它们有自己的继承树,当在不同领域的对象上操作时,instanceof 可能不会按您期望的方式工作。这在 示例 1-4 中有所展示。

示例 1-4. 浏览器中来自不同框架的对象
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const FrameObject = iframe.contentWindow.Object; ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)

console.log(Object === FrameObject); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
console.log(new Object() instanceof FrameObject); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
console.log(FrameObject.name); ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/4.png)

1

iframe 内部的全局对象可通过 contentWindow 属性访问。

2

这将返回 false,因此帧内的 Object 与主帧中的不同。

3

instanceof 求值为 false,预料之中,因为它们不是同一个 Object

4

尽管如此,构造函数具有相同的 name 属性。

在 Node.js 中,可以使用 vm.createContext() 函数构建领域,如 示例 1-5 中所示。在 Node.js 的术语中,领域被称为上下文。适用于浏览器框架的所有相同规则和属性也适用于上下文,但在上下文中,您无法访问任何全局属性或在 Node.js 文件中可能在范围内的任何其他内容。如果要使用这些功能,必须手动传递到上下文中。

示例 1-5. Node.js 中来自新上下文的对象
const vm = require('vm');
const ContextObject = vm.runInNewContext('Object'); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)

console.log(Object === ContextObject); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
console.log(new Object() instanceof ContextObject); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
console.log(ContextObject.name); ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/4.png)

1

我们可以使用 runInNewContext 从新的上下文获取对象。

2

这将返回 false,因此与浏览器中的 iframe 一样,上下文中的 Object 与主上下文中的不同。

3

类似地,instanceof 求值为 false

4

构造函数再次具有相同的 name 属性。

在任何这些领域情况下,重要的是注意,我们仍然只有一个指令指针,并且一次只有一个领域的代码在运行,因为我们仍然只谈论单线程执行。

隐藏线程

尽管您的 JavaScript 代码可能默认情况下在单线程环境中运行,但这并不意味着运行代码的进程是单线程的。事实上,可能会使用许多线程来使代码平稳高效地运行。许多人错误地认为 Node.js 是单线程进程。

现代 JavaScript 引擎如 V8 使用单独的线程处理垃圾回收和其他不需要与 JavaScript 执行同步发生的功能。此外,平台运行时本身可能使用额外的线程提供其他功能。

在 Node.js 中,libuv 被用作操作系统无关的异步 I/O 接口。由于并非所有系统提供的 I/O 接口都是异步的,它使用一个工作线程池来避免在使用阻塞 API(如文件系统 API)时阻塞程序代码。默认情况下,会生成四个这样的线程,但可以通过 UV_THREADPOOL_SIZE 环境变量进行配置,最多可达 1,024 个。

在 Linux 系统上,您可以使用 top -H 命令查看给定进程的额外线程。在 示例 1-6 中,启动了一个简单的 Node.js Web 服务器,并记录了 PID,然后传递给 top 命令。您可以看到各种 V8 和 libuv 线程总共达到七个线程,包括 JavaScript 代码运行的线程。您可以尝试在自己的 Node.js 程序中进行这些操作,甚至尝试更改 UV_THREADPOOL_SIZE 环境变量以查看线程数的变化。

示例 1-6. top 命令的输出,显示 Node.js 进程中的线程
$ top -H -p 81862
top - 14:18:49 up 1 day, 23:18,  1 user,  load average: 0.59, 0.82, 0.83
Threads:   7 total,   0 running,   7 sleeping,   0 stopped,   0 zombie
%Cpu(s):  2.2 us,  0.0 sy,  0.0 ni, 97.8 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :  15455.1 total,   2727.9 free,   5520.4 used,   7206.8 buff/cache
MiB Swap:   2048.0 total,   2048.0 free,      0.0 used.   8717.3 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
  81862 bengl     20   0  577084  29272  25064 S   0.0   0.2   0:00.03 node
  81863 bengl     20   0  577084  29272  25064 S   0.0   0.2   0:00.00 node
  81864 bengl     20   0  577084  29272  25064 S   0.0   0.2   0:00.00 node
  81865 bengl     20   0  577084  29272  25064 S   0.0   0.2   0:00.00 node
  81866 bengl     20   0  577084  29272  25064 S   0.0   0.2   0:00.00 node
  81867 bengl     20   0  577084  29272  25064 S   0.0   0.2   0:00.00 node
  81868 bengl     20   0  577084  29272  25064 S   0.0   0.2   0:00.00 node

浏览器同样会在执行文档对象模型(DOM)渲染等许多任务时使用与 JavaScript 执行线程不同的线程。像我们在 Node.js 中使用 top -H 进行的实验,现代浏览器也会产生类似的几个线程。现代浏览器通过使用多个进程甚至进一步加强了安全性,通过隔离层增加了安全性。

在进行应用程序资源规划时,考虑这些额外的线程非常重要。您不应该假设仅因为 JavaScript 是单线程的,您的 JavaScript 应用程序只会使用一个线程。例如,在生产环境中的 Node.js 应用程序中,应该测量应用程序使用的线程数并据此进行规划。还要注意,Node.js 生态系统中许多本地附加组件也会生成自己的线程,因此在逐个应用程序基础上进行这些操作非常重要。

C 语言中的线程:与 Happycoin 一起致富

显然,线程不仅限于 JavaScript。它们是操作系统层面上长期存在的概念,独立于语言。让我们探讨一个使用 C 语言编写的多线程程序是如何看待的。在这里选择 C 语言是显而易见的,因为 C 语言的线程接口是大多数高级语言中线程实现的基础,即使可能有不同的语义。

让我们从一个例子开始。想象一个用于一个简单而不切实际的加密货币 Happycoin 的工作证明算法,如下所示:

  1. 生成一个随机的无符号 64 位整数。

  2. 判断整数是否是快乐数。

  3. 如果它不快乐,那它就不是 Happycoin。

  4. 如果不能被 10,000 整除,那就不是 Happycoin。

  5. 否则,它就不是 Happycoin。

如果一个数字最终变成 1,当将其替换为其数字平方的和并循环,或者之前看到的数字出现时,它是快乐的。维基百科清晰地定义了它,并指出如果出现任何以前看到的数字,那么 4 将出现,反之亦然。您可能会注意到,我们的算法过于昂贵,因为在检查快乐之前我们可以检查可分性。这是有意为之,因为我们试图展示一个繁重的工作负载。

让我们构建一个简单的 C 程序,运行 10,000,000 次工作量证明算法,打印找到的任何 Happycoin,以及它们的数量。

注意

这里的编译步骤中的cc可以替换为gccclang,具体取决于您可用哪个。在大多数系统上,ccgccclang的别名,因此我们将在这里使用它。

Windows 用户可能需要在 Visual Studio 中做一些额外的工作,因为线程示例默认情况下在 Windows 上无法正常工作,这是因为它使用了与 Windows 线程不同的可移植操作系统接口(POSIX)线程。为了简化在 Windows 上尝试此操作,建议使用 Windows 子系统来获取一个与 POSIX 兼容的环境。

仅使用主线程

创建一个名为happycoin.c的文件,在名为ch1-c-threads/的目录中。我们将在本节中逐步构建这个文件。要开始,按照示例 1-7 中显示的代码添加。

示例 1-7. ch1-c-threads/happycoin.c
#include <inttypes.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

uint64_t random64(uint32_t * seed) {
  uint64_t result;
  uint8_t * result8 = (uint8_t *)&result; ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  for (size_t i = 0; i < sizeof(result); i++) {
    result8[i] = rand_r(seed);
  }
  return result;
}

1

如果您主要使用 JavaScript,这一行可能会让您感到陌生,因为它使用了指针。简短地说,这里发生的事情是,result8是一个包含八个 8 位无符号整数的数组,支持与result相同的内存,后者是一个 64 位无符号整数。

我们添加了一堆includes,这些includes提供了方便的类型、I/O 函数以及我们需要的时间和随机数函数。由于算法需要生成一个随机的 64 位无符号整数(即uint64_t),我们需要八个随机字节,random64()通过调用rand_r()来获取这些字节,直到我们有足够的字节。由于rand_r()还需要一个种子的引用,我们将它作为参数传递给random64()

现在让我们按照示例 1-8 中显示的方式添加我们的快乐数字计算。

示例 1-8. ch1-c-threads/happycoin.c
uint64_t sum_digits_squared(uint64_t num) {
  uint64_t total = 0;
  while (num > 0) {
    uint64_t num_mod_base = num % 10;
    total += num_mod_base * num_mod_base;
    num = num / 10;
  }
  return total;
}

bool is_happy(uint64_t num) {
  while (num != 1 && num != 4) {
    num = sum_digits_squared(num);
  }
  return num == 1;
}

bool is_happycoin(uint64_t num) {
  return is_happy(num) && num % 10000 == 0;
}

要获取sum_digits_squared中数字的平方和,我们使用模运算符%,从右到左获取每个数字,平方它,并将其添加到我们的运行总数中。然后我们在is_happy中使用这个函数,在数字为 1 或 4 时停止循环。我们停止在 1,因为这表示数字是快乐的。我们还在 4 时停止,因为这表明一个无限循环,我们永远不会结束在 1。最后,在is_happycoin()中,我们做的是检查一个数字是否快乐并且是否可被 10000 整除。

让我们将所有这些内容都包含在我们的main()函数中,如示例 1-9 所示。

示例 1-9. ch1-c-threads/happycoin.c
int main() {
  uint32_t seed = time(NULL);
  int count = 0;
  for (int i = 1; i < 10000000; i++) {
    uint64_t random_num = random64(&seed);
    if (is_happycoin(random_num)) {
      printf("%" PRIu64 " ", random_num);
      count++;
    }
  }
  printf("\ncount %d\n", count);
  return 0;
}

首先,我们需要一个随机数生成器的种子。当前时间是一个合适的种子,所以我们将通过time()来使用它。然后,我们将循环 1000 万次,首先从random64()获取一个随机数,然后检查它是否是一个 Happycoin。如果是,我们将增加计数并打印出该数字。在printf()调用中奇怪的PRIu64语法是为了正确打印 64 位无符号整数。循环完成后,我们打印出计数并退出程序。

要编译和运行此程序,请在您的ch1-c-threads目录中使用以下命令。

$ cc -o happycoin happycoin.c
$ ./happycoin

您将在一行上得到一个找到的 Happycoin 列表,下一行上是它们的计数。对于程序的一次运行,它可能看起来像这样:

11023541197304510000 ...  [ 167 more entries ] ... 770541398378840000
count 169

运行这个程序需要相当长的时间;在普通计算机上大约需要 2 秒。这是一个适合使用线程加速的情况,因为多次运行同样的大部分是数学运算的操作。

让我们继续将这个示例转换为一个多线程程序。

使用四个工作线程

我们将设置四个线程,每个线程将运行一个四分之一的迭代循环,生成一个随机数并测试它是否是 Happycoin。

在 POSIX C 中,线程通过pthread_*系列函数进行管理。pthread_create()函数用于创建一个线程。传入一个将在该线程上执行的函数。程序流在主线程上继续。程序可以通过在其上调用pthread_join()来等待线程的完成。您可以通过pthread_create()向在线程上运行的函数传递参数,并从pthread_join()获取返回值。

在我们的程序中,我们将 Happycoin 的生成隔离在一个名为get_happycoins()的函数中,这将在我们的线程中运行。我们将创建四个线程,然后立即等待它们完成。每当我们从一个线程那里得到结果时,我们将输出它们并存储计数,以便在最后打印总数。为了帮助传递结果回来,我们将创建一个简单的名为happy_resultstruct

复制现有的happycoin.c并将其命名为happycoin-threads.c。然后在新文件中,在文件中的最后一个#include之后插入示例 1-10 中的代码。

示例 1-10. ch1-c-threads/happycoin-threads.c
#include <pthread.h>

struct happy_result {
  size_t count;
  uint64_t * nums;
};

第一行包括pthread.h,它给了我们访问各种线程函数所需的权限。接着定义了struct happy_result,这将作为我们后面线程函数get_happycoins()的返回值。它存储了一个表示找到的 happycoins 的数组(这里用指针表示),以及它们的数量。

现在,继续删除整个main()函数,因为我们将要替换它。首先,让我们在示例 1-11 中添加我们的get_happycoins()函数,这是将在我们的工作线程上运行的代码。

示例 1-11. ch1-c-threads/happycoin-threads.c
void * get_happycoins(void * arg) {
  int attempts = *(int *)arg; ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  int limit = attempts/10000;
  uint32_t seed = time(NULL);
  uint64_t * nums = malloc(limit * sizeof(uint64_t));
  struct happy_result * result = malloc(sizeof(struct happy_result));
  result->nums = nums;
  result->count = 0;
  for (int i = 1; i < attempts; i++) {
    if (result->count == limit) {
      break;
    }
    uint64_t random_num = random64(&seed);
    if (is_happycoin(random_num)) {
      result->nums[result->count++] = random_num;
    }
  }
  return (void *)result;
}

1

这个奇怪的指针强制转换实际上是说:“把这个任意指针视为int的指针,然后获取该int的值。”

你会注意到,这个函数接受一个void *并返回一个void *。这是pthread_create()期望的函数签名,所以我们在这里没有选择。这意味着我们必须将我们的参数转换为我们想要的类型。我们将传入尝试的次数,所以我们将参数转换为int。然后,我们设置种子,就像在之前的示例中所做的那样,但这次是在我们的线程函数中进行的,所以我们为每个线程得到一个不同的种子。

分配足够的空间给我们的数组和struct happy_result后,我们继续进入与单线程示例中main()相同的循环,只是这次我们将结果放入struct而不是打印它们。一旦循环完成,我们将struct作为指针返回,然后将其强制转换为void *以满足函数签名。这就是信息如何传递回主线程,并且这是有意义的。

这展示了线程的一个关键特性,这是我们从进程中无法获得的,即共享内存空间。例如,如果我们使用进程而不是线程,并且使用某种进程间通信(IPC)机制来传输结果回来,我们将无法简单地将内存地址返回给主进程,因为主进程无法访问工作进程的内存。由于虚拟内存的原因,内存地址可能在主进程中指代完全不同的东西。而不是传递指针,我们将不得不通过 IPC 通道传递整个值回来,这可能会引入性能开销。由于我们使用线程而不是进程,我们可以直接使用指针,这样主线程可以同样使用它。

然而,共享内存并非没有其权衡之处。在我们的情况下,工作线程无需使用它现在传递给主线程的内存。但这并非所有情况下都如此。在许多情况下,必须通过同步正确管理线程访问共享内存;否则,可能会出现一些不可预测的结果。我们将在第四章 4 和第五章 5 中详细介绍 JavaScript 中如何处理这些情况。

现在,让我们在示例 1-12 中的main()函数中结束这一切。

示例 1-12。ch1-c-threads/happycoin-threads.c
#define THREAD_COUNT 4

int main() {
  pthread_t thread [THREAD_COUNT];

  int attempts = 10000000/THREAD_COUNT;
  int count = 0;
  for (int i = 0; i < THREAD_COUNT; i++) {
    pthread_create(&thread[i], NULL, get_happycoins, &attempts);
  }
  for (int j = 0; j < THREAD_COUNT; j++) {
    struct happy_result * result;
    pthread_join(thread[j], (void **)&result);
    count += result->count;
    for (int k = 0; k < result->count; k++) {
      printf("%" PRIu64 " ", result->nums[k]);
    }
  }
  printf("\ncount %d\n", count);
  return 0;
}

首先,我们将把四个线程声明为堆栈上的数组。然后,我们将我们想要的尝试次数(10,000,000)除以线程数。这将作为参数传递给get_happycoins(),我们在第一个循环内看到,在该循环内,使用pthread_create()创建每个线程,并将每个线程的尝试次数作为参数传递。在下一个循环中,我们使用pthread_join()等待每个线程完成执行。然后,我们可以打印结果以及所有线程的总和,就像在单线程示例中那样。

注意

本程序存在内存泄漏问题。在 C 语言和其他一些语言中,多线程编程的一个难点是很容易丢失内存分配和释放的位置和时间。请尝试修改此处的代码,以确保程序在退出时释放所有堆分配的内存。

完成更改后,您可以在您的ch1-c-threads目录下使用以下命令编译和运行此程序。

$ cc -pthread -o happycoin-threads happycoin-threads.c
$ ./happycoin-threads

输出应该看起来像这样:

2466431682927540000 ... [ 154 more entries ] ... 15764177621931310000
count 156

您将会注意到类似于单线程示例的输出。^(1) 您还会注意到它稍微快一些。在普通计算机上,它大约在 0.8 秒内完成。这并不完全是四倍速度快,因为主线程有一些初始开销,并且还有打印结果的成本。我们可以在执行工作的线程上尽快打印结果,但如果这样做,结果可能会互相覆盖,因为两个线程可以同时向输出流打印。通过将结果发送到主线程,我们可以协调在那里打印结果,以避免互相覆盖。

这说明了线程代码的主要优势和一个缺点。一方面,它有助于将计算昂贵的任务分割开来,以便可以并行运行。另一方面,我们需要确保某些事件得到适当的同步,以防止出现奇怪的错误。在任何语言中为您的代码添加线程时,值得确保使用是合适的。此外,与尝试使程序更快的任何练习一样,始终要进行测量。如果没有给您带来实际好处,您就不希望在应用程序中使用线程代码的复杂性。

任何支持线程的编程语言都将提供一些机制来创建和销毁线程,在线程之间传递消息,并与线程间共享的数据进行交互。这在每种语言中可能看起来都不一样,因为语言及其编程模型在不同情况下是不同的。既然我们已经探索了在像 C 这样的低级语言中线程程序的外观,让我们深入了解 JavaScript。事情看起来会有所不同,但正如你将看到的那样,原则仍然是相同的。

^(1) 多线程示例中的总计数与单线程示例不同的事实并不重要,因为计数取决于随机数中有多少是 Happycoins。在两次不同运行中,结果将完全不同。

第二章:浏览器

JavaScript 并不像大多数其他编程语言那样有一个独特的、定制的实现。例如,使用 Python 时,你可能会运行语言维护者提供的 Python 二进制文件。而 JavaScript 则有许多不同的实现。这包括随不同网络浏览器一起提供的 JavaScript 引擎,如 Chrome 中的 V8,Firefox 中的 SpiderMonkey,以及 Safari 中的 JavaScriptCore。V8 引擎也被 Node.js 在服务器端使用。

这些独立的实现每个都从实现 ECMAScript 规范的某种近似开始。正如我们经常需要参考的兼容性表所示,不是每个引擎都以相同的方式实现 JavaScript。当然,浏览器供应商尝试以相同的方式实现 JavaScript 功能,但是错误确实会发生。在语言层面上,已经提供了一些并发原语,详细内容请参见第四章和第五章。

在每种实现中还添加了其他 API,以使可运行的 JavaScript 更加强大。本章完全侧重于现代网络浏览器提供的多线程 API,其中最易于使用的是 Web Worker。

使用这些工作者线程有许多好处,特别适用于浏览器的一个好处是,通过将 CPU 密集型工作分配到单独的线程中,主线程可以更多地专注于渲染 UI。这有助于实现比传统方式更流畅、更用户友好的体验。

专用工作者

Web Worker 允许你生成一个新的执行 JavaScript 的环境。以这种方式执行的 JavaScript 可以在与生成它的 JavaScript 不同的线程中运行。这两个环境之间通过称为消息传递的模式进行通信。请记住,JavaScript 是单线程的。Web Worker 与这种特性很好地协作,并通过事件循环触发函数运行。

JavaScript 环境有可能生成多个 Web Worker,并且给定的 Web Worker 可以自由生成更多的 Web Worker。不过,如果你发现自己在生成大量 Web Worker 层次结构时,可能需要重新评估你的应用程序。

Web Worker 有多种类型,其中最简单的是专用工作者。

专用工作者 Hello World

学习新技术的最佳方法是实际应用它。你正在构建的页面与工作者之间的关系显示在图 2-1 中。在这种情况下,你将只创建一个工作者,但也可以实现工作者层次结构。

专用工作者仅有一个父进程

图 2-1 专用工作者关系

首先,创建一个名为 ch2-web-workers/ 的目录。你将在其中保留这个项目所需的三个示例文件。接下来,在该目录中创建一个 index.html 文件。在浏览器中运行的 JavaScript 需要先被网页加载,而这个文件代表了该网页的基础。将 Example 2-1 的内容添加到此文件以启动项目。

示例 2-1. ch2-web-workers/index.html
<html>
  <head>
    <title>Web Workers Hello World</title>
    <script src="main.js"></script>
  </head>
</html>

如您所见,这个文件非常基础。它所做的只是设置一个标题并加载一个名为 main.js 的单个 JavaScript 文件。本章的其余部分遵循类似的模式。更有趣的部分在于 main.js 文件的内容。

实际上,现在创建 main.js 文件,并将 Example 2-2 的内容添加到其中。

示例 2-2. ch2-web-workers/main.js
console.log('hello from main.js');

const worker = new Worker('worker.js'); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)

worker.onmessage = (msg) => { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
  console.log('message received from worker', msg.data);
};

worker.postMessage('message sent to worker'); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)

console.log('hello from end of main.js');

1

实例化一个新的专用 worker。

2

为 worker 附加一个消息处理程序。

3

向 worker 传递消息。

此文件的第一步是调用console.log()。这是为了明确文件执行的顺序。接下来要做的是实例化一个新的专用 worker。通过调用new Worker(*filename*)来完成。一旦调用,JavaScript 引擎将在后台开始下载(或缓存查找)适当的文件。

接下来,为 worker 的 message 事件附加一个处理程序。通过将函数分配给专用 worker 的 .onmessage 属性来完成。当接收到消息时,该函数将被调用。提供给函数的参数是 MessageEvent 的一个实例。它带有许多属性,但最有趣的是 .data 属性。这代表了从专用 worker 返回的对象。

最后,调用专用 worker 的 .postMessage() 方法。这是实例化专用 worker 的 JavaScript 环境与专用 worker 通信的方式。在本例中,传递了一个基本字符串到专用 worker。对于可以传递到此方法的数据类型有所限制;请参阅附录(app01.xhtml#app_sca)获取更多详情。

现在,您的主 JavaScript 文件已经完成,可以创建在专用 worker 内执行的文件。创建一个名为 worker.js 的新文件,并将 Example 2-3 的内容添加到其中。

示例 2-3. ch2-web-workers/worker.js
console.log('hello from worker.js');

self.onmessage = (msg) => {
  console.log('message from main', msg.data);

  postMessage('message sent from worker');
};

在这个文件中,定义了一个名为onmessage的全局函数,并将一个函数分配给它。在专用 worker 内部,当从专用 worker 外部调用worker.postMessage()方法时,会调用此onmessage函数。此赋值也可以写为onmessage =或甚至var onmessage =,但使用const onmessage =let onmessage =,甚至声明function onmessage都不起作用。self标识符是在专用 worker 内部的globalThis的别名,在那里通常的window不可用。

onmessage函数内部,代码首先打印从专用 worker 外部接收到的消息。之后,它调用postMessage()全局函数。此方法接受一个参数,然后通过触发专用 worker 的onmessage()方法将参数提供给调用环境。关于消息传递和对象克隆的规则在这里也适用。同样,示例现在只是使用一个简单的字符串。

当加载专用 worker 脚本文件时还有一些额外的规则。加载的文件必须与主 JavaScript 环境运行的同一起源。此外,浏览器不允许在使用file://协议运行 JavaScript 时运行专用 worker,这是一种说法,简单地说,你不能简单地双击index.html文件查看应用程序运行。相反,你需要从 Web 服务器运行你的应用程序。幸运的是,如果你安装了最新的 Node.js,你可以运行以下命令在本地启动一个非常基本的 Web 服务器:

$ npx serve .

执行此命令后,将启动一个服务器,用于托管来自本地文件系统的文件。它还显示服务器可用的 URL。通常情况下,该命令输出以下 URL,假设端口是空闲的:

http://localhost:5000

将提供给你的任何 URL 复制并在 Web 浏览器中打开。当页面首次打开时,你可能会看到一个普通的白屏。但这并不是问题,因为所有的输出都显示在 Web 开发者控制台中。不同的浏览器以不同的方式提供控制台,但通常你可以右键点击背景的某个地方并选择检查菜单选项,或者按下 Ctrl+Shift+I(或 Cmd-Shift-I)打开检查器。一旦进入检查器,点击控制台选项卡,然后刷新页面,以防万一未捕获到任何控制台消息。完成这些步骤后,你应该看到显示在 Table 2-1 中的消息。

Table 2-1. 示例控制台输出

日志 位置
来自 main.js 的 hello main.js:1:9
来自 main.js 末尾的 hello main.js:11:9
来自 worker.js 的 hello worker.js:1:9
来自主程序的消息,发送给 worker 的消息 worker.js:4:11
来自 worker 的消息,发送给 worker 的消息 main.js:6:11

这个输出确认了消息执行的顺序,尽管它并不完全是确定性的。首先,加载main.js文件,并打印其输出。然后实例化和配置工作者,调用其postMessage()方法,最后打印最后一条消息。接下来,运行worker.js文件,并调用其消息处理程序,打印一条消息。然后调用postMessage()将消息发送回main.js。最后,在main.js中调用专用工作者的onmessage处理程序,并打印最后的消息。

高级专用工作者用法

现在您已经熟悉专用工作者的基础知识,可以开始使用一些更复杂的功能了。

当您处理不涉及专用工作者的 JavaScript 时,您最终加载的所有代码都可在同一领域中使用。加载新的 JavaScript 代码可以通过使用<script>标签加载脚本,或通过发出 XHR 请求并使用带有表示代码的字符串的eval()函数来完成。在涉及专用工作者时,您无法将<script>标签注入 DOM,因为与工作者关联的 DOM 不存在。

相反,您可以使用importScripts()函数,该函数仅在 Web 工作者中可用。此函数接受一个或多个参数,表示要加载的脚本路径。这些脚本将从与网页相同的源加载。这些脚本以同步方式加载,因此在函数调用后运行的代码将在脚本加载后运行。

Worker的实例继承自EventTarget,并具有一些用于处理事件的通用方法。但是,Worker类提供了实例上最重要的方法。以下是这些方法的列表,其中一些您已经使用过,一些是新的:

worker.postMessage(msg)

这会向工作者发送一条消息,在调用self.onmessage函数之前由事件循环处理,传递msg参数。

worker.onmessage

如果分配了,它将在工作者内部调用self.postMessage函数时调用。

worker.onerror

如果分配了,当工作者内部抛出错误时将调用它。将提供一个单一的ErrorEvent参数,具有.colno.lineno.filename.message属性。此错误将冒泡,除非您调用err.preventDefault()

worker.onmessageerror

如果分配了,当工作者接收到无法反序列化的消息时将调用此函数。

worker.terminate()

如果被调用,工作者将立即终止。将来对worker.postMessage()的调用将静默失败。

在专用工作线程内部,全局变量selfWorkerGlobalScope的一个实例。最显著的新增功能是importScripts()函数,用于注入新的 JavaScript 文件。一些高级通信 API,如XMLHttpRequestWebSocketfetch()可用。一些有用的函数,虽然不一定是 JavaScript 的一部分,但由每个主要引擎重新构建,如setTimeout()setInterval()atob()btoa(),也是可用的。两个数据存储 API,localStorageindexedDB,同样可用。

至于缺失的 API,您需要实验并查看您能访问的内容。通常情况下,修改全局状态的 API 在专用工作线程中是不可用的。在主 JavaScript 领域中,全局的location可用,是Location的一个实例。在专用工作线程内部,location也是可用的,但是是WorkerLocation的一个实例,有些不同,显著缺少了可以引发页面刷新的.reload()方法。全局的document也是不可用的,这是访问页面 DOM 的 API。

在实例化专用工作线程时,可以选择性地使用第二个参数来指定工作线程的选项。实例化的签名如下:

const worker = new Worker(filename, options);

options参数是一个对象,可以包含以下列出的属性:

type

可以是classic(默认),表示经典的 JavaScript 文件,或者module,表示 ECMAScript 模块(ESM)。

credentials

这个值确定了是否将 HTTP 凭据发送到获取工作线程文件的请求中。该值可以是omit(排除凭据),same-origin(发送凭据,但仅当来源匹配时),或include(始终发送凭据)。

name

这个名称是指一个专用的工作线程,通常用于调试。在工作线程中作为全局命名的值为name

共享工作线程

共享工作线程是另一种 Web 工作线程类型,但其特殊之处在于可以被不同的浏览器环境访问,例如不同的窗口(标签页)、跨 iframe 甚至来自不同 Web 工作线程。它们在工作线程内部还有一个不同的self,是SharedWorkerGlobalScope的一个实例。共享工作线程只能被同源的 JavaScript 访问。例如,运行在http://localhost:5000上的窗口无法访问运行在http://google.com:80上的共享工作线程。

警告

Safari 当前已禁用共享工作线程,至少从 2013 年起如此,这无疑会影响该技术的采用。

在深入编码之前,考虑几个要点是很重要的。一个让共享工作者有些难以理解的因素是,它们并不一定附属于特定的窗口(环境)。当然,它们最初是由特定窗口生成的,但之后它们可能“属于”多个窗口。这意味着当第一个窗口关闭时,共享工作者仍然存在。

提示

由于共享工作者不属于特定的窗口,一个有趣的问题是console.log输出应该去哪里?截至 Firefox v85,输出与生成共享工作者的第一个窗口关联。打开另一个窗口,第一个窗口仍然接收日志。关闭第一个窗口,日志现在不可见。打开另一个窗口,历史日志将显示在最新的窗口中。另一方面,Chrome v87 不显示共享工作者的日志。调试时请记住这一点。

共享工作者可用于保存半持久状态,在其他窗口连接到它时保持状态。例如,如果 Window 1 告诉共享工作者写入一个值,那么 Window 2 可以要求共享工作者将该值读回。刷新 Window 1,该值仍然保持。刷新 Window 2,它也保持不变。关闭 Window 1,它仍然保持。然而,一旦关闭或刷新仍在使用共享工作者的最后一个窗口,状态将丢失,并且共享工作者脚本将再次被评估。

警告

一个共享工作者的 JavaScript 文件在多个窗口使用时会被缓存;刷新页面不一定会重新加载您的更改。相反,您需要关闭其他打开的浏览器窗口,然后刷新剩余的窗口,以便让浏览器运行您的新代码。

记住这些注意事项后,您现在可以构建一个使用共享工作者的简单应用程序了。

共享工作者你好世界

一个共享工作者基于其在当前源中的位置进行“键控”。例如,在本示例中,您将使用的共享工作者位于类似http://localhost:5000/shared-worker.js的某个位置。无论工作者是从位于/red.html/blue.html或甚至/foo/index.html的 HTML 文件加载,共享工作者实例始终保持不变。有一种方法可以使用相同的 JavaScript 文件创建不同的共享工作者实例,这在“高级共享工作者用法”中有所介绍。

您正在构建的页面与工作者之间的关系显示在 Figure 2-2 中。

Shared workers can be owned by more than one page.

Figure 2-2. 共享工作者关系

现在,是时候创建一些文件了。例如,创建一个名为ch2-shared-workers/的目录,并且所有必需的文件都将驻留在这个目录中。完成这些步骤后,创建一个包含内容的 HTML 文件,内容在 Example 2-4 中。

Example 2-4. ch2-shared-workers/red.html
<html>
  <head>
    <title>Shared Workers Red</title>
    <script src="red.js"></script>
  </head>
</html>

与您在前一节中创建的 HTML 文件非常类似,这个文件只设置了一个标题并加载了一个 JavaScript 文件。完成后,创建另一个 HTML 文件,其中包含示例 2-5 中的内容。

示例 2-5. ch2-shared-workers/blue.html
<html>
  <head>
    <title>Shared Workers Blue</title>
    <script src="blue.js"></script>
  </head>
</html>

对于本例子,您将使用两个单独的 HTML 文件进行操作,每个文件代表一个新的 JavaScript 环境,这些环境将在同一个源上可用。从技术上讲,您可以在两个窗口中重复使用同一个 HTML 文件,但我们希望非常明确地指出,状态不会与 HTML 文件或red/blue JavaScript 文件相关联。

接下来,您准备好创建第一个 JavaScript 文件,直接由 HTML 文件加载。创建一个包含示例 2-6 内容的文件。

示例 2-6. ch2-shared-workers/red.js
console.log('red.js');

const worker = new SharedWorker('shared-worker.js'); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)

worker.port.onmessage = (event) => { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
  console.log('EVENT', event.data);
};

1

实例化共享工作者。

2

注意通信的worker.port属性。

这个 JavaScript 文件相当基础。它的作用是通过调用new SharedWorker()来实例化一个共享工作者实例。然后,它添加了一个处理来自共享工作者发出的消息事件的处理程序。当接收到消息时,它简单地将其打印到控制台上。

与调用.onmessage直接与Worker实例不同,您将利用.port属性与SharedWorker实例进行通信。

接下来,复制粘贴您在示例 2-6 中创建的red.js文件,并将其命名为blue.js。更新console.log()调用以打印blue.js;否则,内容将保持不变。

最后,创建一个shared-worker.js文件,其中包含示例 2-7 中的内容。这是大部分魔法发生的地方。

示例 2-7. ch2-shared-workers/shared-worker.js
const ID = Math.floor(Math.random() * 999999); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
console.log('shared-worker.js', ID);

const ports = new Set(); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)

self.onconnect = (event) => { ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
  const port = event.ports[0];
  ports.add(port);
  console.log('CONN', ID, ports.size);

  port.onmessage = (event) => { ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/4.png)
    console.log('MESSAGE', ID, event.data);

    for (let p of ports) { ![5](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/5.png)
      p.postMessage([ID, event.data]);
    }
  };
};

1

用于调试的随机 ID。

2

端口的单例列表。

3

连接事件处理程序。

4

收到新消息时的回调。

5

消息被分派到每个窗口。

该文件中的第一件事是生成一个随机的 ID 值。这个值被打印在控制台上,稍后传递给调用 JavaScript 环境。在实际应用中,这并不特别有用,但它很好地证明了状态的保留和处理共享工作者时状态的丢失。

接下来,创建一个名为ports的单例Set。^(1) 这将包含所有向工作者提供的端口的列表。窗口中可用的worker.port和服务工作者中提供的port都是MessagePort类的实例。

最终发生在这个共享工作者文件的外部作用域的事情是建立了一个connect事件的监听器。每当 JavaScript 环境创建一个引用这个共享工作者的SharedWorker实例时,就会调用这个函数。当这个监听器被调用时,会传入一个MessageEvent实例作为参数。

connect事件有几个可用的属性,但最重要的是ports属性。这个属性是一个包含一个元素的数组,这个元素是一个引用了允许与调用 JavaScript 环境通信的MessagePort实例。这个特定的端口然后被添加到ports集合中。

也会为端口附加一个message事件的事件监听器。就像你之前在Worker实例中使用的onmessage方法一样,当外部 JavaScript 环境之一调用适用的.postMessage()方法时,会调用这个方法。当接收到消息时,代码会打印出 ID 值和接收到的数据。

事件监听器还会将消息分发回调用环境。它通过迭代ports集合来实现这一点,对遇到的每个端口调用.postMessage()方法。由于这个方法只接受一个参数,因此传入一个数组以模拟多个参数。这个数组的第一个元素再次是 ID 值,第二个元素是传入的数据。

如果你之前使用过 Node.js 来处理 WebSocket,那么这种代码模式可能会感觉很熟悉。在大多数流行的 WebSocket 包中,当建立连接时会触发一个事件,然后可以给连接参数附加一个消息监听器。

此时,你已经准备好再次测试你的应用程序了。首先,在你的ch2-shared-workers/目录下运行以下命令,然后复制粘贴显示的 URL:

$ npx serve .

再次,在我们的情况下,我们得到的 URL 是http://localhost:5000。不过这次,你不会直接打开这个 URL,而是首先打开浏览器中的 Web 检查器,然后打开修改过的 URL 版本。

切换到你的浏览器并打开一个新标签页。如果这会打开你的主页、空白标签页或者你的默认页面,都没关系。然后再次打开 Web 检查器并导航到控制台选项卡。完成这些操作后,粘贴给你的 URL,但修改它以打开/red.html页面。你输入的 URL 可能看起来像这样:

http://localhost:5000/red.html

按 Enter 键打开页面。serve包可能会将你的浏览器从/red.html重定向到/red,但这没关系。

页面加载完成后,你应该会看到 Table 2-2 中列出的消息显示在你的控制台中。如果在加载页面后打开检查器,可能看不到任何日志,不过这样做后刷新页面应该可以显示日志。请注意,目前只有 Firefox 能显示 shared-worker.js 生成的消息。

Table 2-2. 第一个窗口控制台输出

日志 位置
red.js red.js:1:9
shared-worker.js 278794 shared-worker.js:2:9
CONN 278794 1 shared-worker.js:9:11

在我们的情况下,我们可以看到 red.js 文件已执行,此特定 shared-worker.js 实例生成了 ID 278794,并且当前只有一个窗口连接到该共享 Worker。

接下来,打开另一个浏览器窗口。同样,先打开 Web 检查器,切换到控制台选项卡,粘贴由 serve 命令提供的基本 URL,然后在 URL 的末尾添加 /blue.html。在我们的情况下,URL 看起来是这样的:

http://localhost:5000/blue.html

按 Enter 键打开该网址。页面加载后,你应该只会在控制台输出中看到一条消息,说明 blue.js 文件已执行。此时还不太有趣。但是切换回你之前打开的 red.html 页面的窗口,你应该会看到 Table 2-3 中新增加的日志。

Table 2-3. 第一个窗口控制台输出,继续

日志 位置
CONN 278794 2 shared-worker.js:9:11

现在事情开始变得有些令人兴奋。共享 Worker 环境现在有两个指向两个独立窗口的 MessagePort 实例的引用。同时,两个窗口都有指向同一个共享 Worker 的 MessagePort 实例的引用。

现在你已准备好从一个窗口向共享 Worker 发送消息了。切换到控制台窗口,并输入以下命令:

worker.port.postMessage('hello, world');

按 Enter 键执行该行 JavaScript。你应该会在第一个控制台中看到来自共享 Worker 的消息,来自 red.js 的第一个控制台消息,以及第二个窗口控制台中来自 blue.js 的消息。在我们的情况下,我们看到的输出列在 Table 2-4 中。

Table 2-4. 第一个和第二个窗口控制台输出

日志 位置 控制台
MESSAGE 278794 你好,世界 shared-worker.js:12:13 1
EVENT Array [ 278794, “你好,世界” ] red.js:6:11 1
EVENT Array [ 278794, “你好,世界” ] blue.js:6:11 2

在此时,你已成功地从一个窗口中的 JavaScript 环境发送了一条消息到共享 Worker 中的 JavaScript 环境,并且从 Worker 中传递了一条消息到两个单独的窗口。

高级共享 Worker 使用

共享工作器遵循与附录中描述的相同对象克隆规则。而且,与专用工作器类似,共享工作器也可以使用importScripts()函数来加载外部 JavaScript 文件。截至 Firefox v85/Chrome v87 版本,你可能会发现 Firefox 更方便调试共享工作器,因为共享工作器中的console.log()输出是可用的。

共享工作器实例确实可以访问connect事件,可以使用self.onconnect()方法处理。值得注意的是,如果你熟悉 WebSocket,可能会错过disconnectclose事件。

当涉及创建port实例的单例集合时,就像本节示例代码中的情况一样,很容易造成内存泄漏。在这种情况下,只需不断刷新其中一个窗口,每次刷新都会向集合添加一个新条目。

这远非理想。为了解决这个问题,你可以在主要的 JavaScript 环境(例如,red.jsblue.js)中添加事件监听器,当页面被卸载时触发。让这个事件监听器向共享工作器传递特殊消息。在共享工作器内部,当接收到消息时,将端口从端口列表中移除。以下是如何实现的示例:

// main JavaScript file
window.addEventListener('beforeunload', () => {
  worker.port.postMessage('close');
});

// shared worker
port.onmessage = (event) => {
  if (event.data === 'close') {
    ports.delete(port);
    return;
  }
};

不幸的是,仍然存在端口仍然保留的情况。如果beforeunload事件未触发,或者在触发时发生错误,或者页面以意外方式崩溃,这可能导致共享工作器中的过期端口引用保留。

更健壮的系统还需要共享工作器定期“ping”调用环境,通过port.postMessage()发送特殊消息,并让调用环境回复。通过这种方法,如果在一定时间内未收到回复,共享工作器可以删除端口实例。但即使是这种方法也不完美,因为慢速的 JavaScript 环境可能导致长时间的响应时间。幸运的是,与不再具有有效 JavaScript 关联的端口交互没有太多副作用。

SharedWorker类的完整构造函数如下所示:

const worker = new SharedWorker(filename, nameOrOptions);

签名与实例化Worker实例时略有不同,特别是第二个参数可以是一个选项对象,也可以是工作器的名称。与Worker实例类似,工作器的名称在工作器内部作为self.name可用。

此时你可能想知道它是如何工作的。例如,可以在red.js中声明共享工作器,命名为“红色工作器”,在blue.js中命名为“蓝色工作器”?在这种情况下,将创建两个独立的工作器,每个都有不同的全局环境、不同的 ID 值和适当的self.name

你可以将这些共享的工作实例视为不仅仅由它们的 URL,还由它们的名称“键控”的。这可能是为什么在 WorkerSharedWorker 之间签名变化如此大的原因。

除了能够用字符串名称替换选项参数之外,SharedWorker 的选项参数与 Worker 完全相同。

在这个例子中,你只创建了一个 SharedWorker 实例并分配给 worker,但是并没有阻止你创建多个实例。事实上,你甚至可以创建多个指向同一实例的共享工作者,只要 URL 和名称匹配。当发生这种情况时,两个 SharedWorker 实例的 .port 属性都能接收消息。

这些 SharedWorker 实例确实能够在页面加载之间保持状态。你已经这样做了,ID 变量保存了一个唯一的数字,ports 包含了一个端口列表。即使通过刷新,只要一个窗口保持打开,这种状态也会持续存在,就像你先刷新 blue.html 页面,然后再刷新 red.html 页面一样。但是,如果同时刷新两个页面,关闭一个并刷新另一个,或者两个页面都关闭,这种状态将会丢失。在下一节中,你将使用一种技术,即使连接的窗口关闭,也能继续保持状态和运行代码。

Service Workers

Service worker 作为一种类似代理的功能存在于运行在浏览器中的一个或多个网页和服务器之间。因为一个服务工作者不仅与单个网页关联,而是可能与多个页面相关,它更类似于共享工作者而不是专用工作者。它们甚至以与共享工作者相同的方式“键控”。但是,服务工作者可以存在并在后台运行,即使页面并不一定还在打开状态。因此,你可以将专用工作者视为与一个页面关联,将共享工作者视为与一个或多个页面关联,但将服务工作者视为与零个或多个页面关联。但共享工作者并不会奇迹般地自动生成。相反,它确实需要首先打开一个网页来安装共享工作者。

服务工作者主要用于执行网站或单页面应用程序的缓存管理。 当网络请求发送到服务器时,它们最常被调用,其中服务工作者内的事件处理程序拦截网络请求。 服务工作者的闻名之处在于,当浏览器显示网页但运行它的计算机无法访问网络时,它可以用于返回缓存的资产。 当服务工作者接收到请求时,它可能会查询缓存以找到缓存的资源,向服务器发出请求以检索资源的某种形式,甚至执行重型计算并返回结果。 尽管最后一种选项使其类似于您查看过的其他网络工作者,但您确实不应仅仅为了将 CPU 密集型工作转移到另一个线程而使用服务工作者。

Service workers 暴露的 API 比其他网络工作者更多,尽管它们的主要用例不是为了从主线程卸载重型计算。 Service workers 绝对足够复杂,以至于有专门讲述它们的整本书籍。 话虽如此,因为本书的主要目标是教你关于 JavaScript 多线程能力的知识,我们不会完全覆盖它们。 例如,有一个完整的 Push API 可用于接收从服务器推送到浏览器的消息,但这完全不会被覆盖。

与其他网络工作者类似,服务工作者无法访问 DOM。 它们也不能发出阻塞请求。 例如,将 XMLHttpRequest#open() 的第三个参数设置为 false,这将阻止代码执行直到请求成功或超时,是不允许的。 浏览器只允许在使用 HTTPS 协议提供的网页上运行服务工作者。 幸运的是,对我们来说有一个显著的例外,即 localhost 可以使用 HTTP 加载服务工作者,这样可以简化本地开发。 Firefox 在使用其私密浏览功能时不允许服务工作者。 然而,Chrome 在使用其隐身功能时允许服务工作者。 也就是说,服务工作者实例无法在普通窗口和隐身窗口之间通信。

Firefox 和 Chrome 的检查器中都有一个包含 Service Workers 部分的应用程序面板。 您可以使用此功能查看与当前页面关联的任何服务工作者,并执行一个非常重要的开发操作:取消注册它们,这基本上允许您将浏览器状态重置到注册工作者之前的状态。 不幸的是,到目前为止的浏览器版本,这些浏览器面板并不提供进入服务工作者的 JavaScript 检查器的方式。

现在您已经了解了一些服务工作者的要点,您可以准备好开始构建一个了。

服务工作者 Hello World

在本节中,您将构建一个非常基本的服务工作者,该工作者拦截从基本网页发送的所有 HTTP 请求。大多数请求将不经过修改地传递到服务器。但是,对特定资源的请求将返回由服务工作者自身计算的值。大多数服务工作者将进行大量的缓存查找,但是再次强调,目标是展示多线程角度的服务工作者。

你将再次需要的第一个文件是 HTML 文件。创建一个名为ch2-service-workers/的新目录。然后,在这个目录中,创建一个文件,其内容来自示例 2-8。

示例 2-8. ch2-service-workers/index.html
<html>
  <head>
    <title>Service Workers Example</title>
    <script src="main.js"></script>
  </head>
</html>

这是一个非常基本的文件,只是加载应用程序的 JavaScript 文件,接下来是它。创建一个名为main.js的文件,并将内容添加到示例 2-9 中。

示例 2-9. ch2-service-workers/main.js
navigator.serviceWorker.register('/sw.js', { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  scope: '/'
});

navigator.serviceWorker.oncontrollerchange = () => { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
  console.log('controller change');
};

async function makeRequest() { ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
  const result = await fetch('/data.json');
  const payload = await result.json();
  console.log(payload);
}

1

注册服务工作者并定义范围。

2

监听controllerchange事件。

3

初始化请求的功能。

现在事情开始变得有趣起来了。在这个文件中,首先创建了服务工作者。与你之前接触的其他网络工作者不同,你没有使用构造函数的new关键字。相反,这段代码依赖于navigator.serviceWorker对象来创建工作者。第一个参数是作为服务工作者的 JavaScript 文件的路径。第二个参数是一个可选的配置对象,支持一个名为scope的属性。

scope表示当前起源目录的目录,在其中加载的任何 HTML 页面都将通过服务工作者的请求传递。默认情况下,scope值与加载服务工作者的目录相同。在这种情况下,*/ 值相对于 index.html 目录,并且因为 sw.js *位于相同目录中,我们可以省略范围,并且它将表现得完全相同。

一旦服务工作者已安装到页面上,所有外发的 HTTP 请求都将通过服务工作者发送。这包括发送到不同源的请求。由于此页面的范围设置为起始目录,此起源中打开的任何 HTML 页面都必须通过服务工作者来获取资源。如果scope设置为/foo,那么在/bar.html打开的页面将不受服务工作者的影响,但在/foo/baz.html打开的页面将受到影响。

接下来发生的事情是将一个监听器添加到navigator.serviceWorker对象的controllerchange事件上。当此监听器触发时,将在控制台打印一条消息。这条消息仅用于调试,用于当服务工作者控制已加载的页面并且该页面在工作者的范围内时。

最后,定义了一个名为makeRequest()的函数。此函数向/data.json路径发出GET请求,将响应解码为 JavaScript 对象表示法(JSON),并打印结果。正如你可能已经注意到的,这个函数没有任何引用。相反,稍后您将手动在控制台中运行它以测试功能性。

有了这个文件,现在你可以准备创建服务工作者本身了。创建第三个名为sw.js的文件,并将 Example 2-10 中的内容添加到其中。

示例 2-10. ch2-service-workers/sw.js
let counter = 0;

self.oninstall = (event) => {
  console.log('service worker install');
};

self.onactivate = (event) => {
  console.log('service worker activate');
  event.waitUntil(self.clients.claim()); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
};

self.onfetch = (event) => {
  console.log('fetch', event.request.url);

  if (event.request.url.endsWith('/data.json')) {
    counter++;
    event.respondWith( ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
      new Response(JSON.stringify({counter}), {
        headers: {
          'Content-Type': 'application/json'
        }
      })
    );
    return;
  }

  // fallback to normal HTTP request
  event.respondWith(fetch(event.request)); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
};

1

允许服务工作者声明已打开的index.html页面。

2

重写了对/data.json请求的处理。

3

其他的 URL 将回退到正常的网络请求。

在这个文件中发生的第一件事是将一个全局变量counter初始化为零。稍后,当拦截某些类型的请求时,该数字将增加。这只是一个示例,证明服务工作者正在运行;在真实的应用程序中,您永远不应该以这种方式存储旨在持久存在的状态。事实上,预期任何服务工作者会以难以预测且因浏览器实现而异的方式频繁启动和停止。

之后,我们通过将函数分配给self.oninstall来创建install事件的处理程序。当这个版本的服务工作者在浏览器中第一次安装时,此函数将运行。大多数实际应用程序将在此阶段执行实例化工作。例如,self.caches中有一个可用于配置存储网络请求结果的缓存的对象。但是,由于这个基本应用程序在实例化方面没有太多要做的事情,它只是打印一条消息并完成。

接下来是处理activate事件的函数。当引入服务工作者的新版本时,此事件非常有用,用于执行清理工作。对于真实世界的应用程序,它可能会执行类似于拆除旧缓存版本的工作。

在这种情况下,activate 处理程序函数正在调用 self.clients.claim() 方法。调用这个方法允许最初创建 service worker 的页面实例(即首次打开的 index.html 页面)受 service worker 控制。如果没有这行代码,第一次加载页面时页面将不受 service worker 控制。然而,刷新页面或在另一个标签中打开 index.html 将允许该页面受到控制。

调用 self.clients.claim() 的返回一个 promise。遗憾的是,在 service worker 中使用的事件处理函数不是异步函数,无法 await promise。然而,event 参数是一个带有 .waitUntil() 方法的对象,它可以与 promise 一起使用。一旦提供给该方法的 promise 解析完成,它将允许 oninstallonactivate(以及后来的 onfetch)处理程序完成。如果不调用该方法,就像在 oninstall 处理程序中一样,一旦函数退出,这一步被认为已经完成。

最后一个事件处理程序是 onfetch 函数。这是最复杂的处理程序,也是 service worker 生命周期中被调用最频繁的一个。每当由 service worker 控制的网页进行网络请求时,都会调用该处理程序。它被称为 onfetch 是为了表明它与浏览器中的 fetch() 函数相关,尽管这几乎是一个误称,因为任何网络请求都将通过它。例如,如果稍后向页面添加图像标签,则该请求也会触发 onfetch

这个函数首先记录一条消息以确认它正在运行,并打印正在请求的 URL。还可以获取有关请求资源的其他信息,例如标头和 HTTP 方法。在实际应用程序中,这些信息可以用于与缓存进行查询,看看资源是否已经存在。例如,可以从缓存中提供当前源内资源的 GET 请求,但如果不存在,可以使用 fetch() 函数请求,然后将其插入到缓存中,最后返回给浏览器。

这个基本示例只是获取 URL 并检查它是否为以 /data.json 结尾的 URL。如果不是,将跳过 if 语句体,并调用函数的最后一行。这行代码只是将请求对象(即 Request 的一个实例)传递给 fetch() 方法,该方法返回一个 promise,并将该 promise 传递给 event.respondWith()fetch() 方法将解析一个对象,该对象将用于表示响应,并提供给浏览器。这本质上是一个非常基本的 HTTP 代理。

然而,回到/data.json URL 检查,如果通过了,那么会发生更复杂的情况。在这种情况下,counter变量递增,并且从头开始生成新的响应(这是Response的一个实例)。在这种情况下,构造了包含counter值的 JSON 字符串。这作为Response的第一个参数提供,表示响应主体。第二个参数包含有关响应的元信息。在这种情况下,Content-Type头部设置为application/json,这表明响应是 JSON 负载。

现在,您的文件已经创建,请使用控制台导航到创建它们的目录,并运行以下命令以启动另一个 Web 服务器:

$ npx serve .

然后,复制提供的网址,在新的网页浏览器窗口中打开检查器,然后粘贴网址以访问页面。您应该在控制台中看到此消息打印出来(可能还有其他消息):

controller change              main.js:6:11

接下来,使用上述技术浏览到您的浏览器中安装的服务工作者列表。在检查器中,您应该看到先前记录的消息;具体来说,您应该看到以下两条消息:

service worker install         sw.js:4:11
service worker activate        sw.js:8:11

接下来,切换回浏览器窗口。在检查器的控制台选项卡中,运行以下代码行:

makeRequest();

这将运行makeRequest()函数,该函数触发当前起源的 HTTP GET请求到/data.json。完成后,您应该在控制台中看到消息Object { counter: 1 }显示出来。该消息是使用服务工作者生成的,并且该请求从未发送到 Web 服务器。如果您切换到检查器的网络选项卡,您应该看到看似正常的请求以获取资源。如果您单击请求,您应该看到它以 200 状态代码回复,并且Content-Type头部应设置为application/json。就网页而言,它确实执行了一个正常的 HTTP 请求。但您知道更多。

切换回服务工作者检查器控制台。在这里,您应该看到已打印出包含请求详细信息的第三条消息。在我们的机器上,我们得到以下内容:

fetch http://localhost:5000/data.json   sw.js:13:11

到目前为止,您已成功拦截了来自一个 JavaScript 环境的 HTTP 请求,在另一个环境中执行了一些计算,并将结果返回到主环境。就像其他网络工作者一样,此计算是在单独的线程中完成的,可以并行运行代码。如果服务工作者进行了一些非常耗时和缓慢的计算,那么在等待响应时,网页将可以执行其他操作。

提示

在您的第一个浏览器窗口中,您可能注意到尝试下载favicon.ico文件但失败的错误。您可能还想知道为什么共享工作者控制台没有提到此文件。这是因为在窗口首次打开时,它尚未受到服务工作者的控制,因此请求直接通过网络进行,绕过了工作者。调试服务工作者可能会令人困惑,这是需要记住的一个注意事项之一。

现在您已经建立了一个可工作的服务工作者,可以学习一些更高级的功能。

高级服务工作者概念

服务工作者只用于执行异步操作。因此,技术上会阻塞读写的localStorage API 不可用。然而,异步的indexedDB API 是可用的。服务工作者中也禁用了顶层await

在跟踪状态方面,您将主要使用self.cachesindexedDB。再次强调,将数据存储在全局变量中不会可靠。事实上,在调试服务工作者时,您可能会发现它们偶尔停止运行,此时您无法进入检查器。浏览器有一个按钮允许您重新启动工作者,使您可以再次进入检查器。这种停止和启动会清除全局状态。

浏览器会非常积极地缓存服务工作者脚本。当重新加载页面时,浏览器可能会请求脚本,但除非脚本已更改,否则不会被视为需要替换。Chrome 浏览器确实提供了在重新加载页面时触发脚本更新的功能;要做到这一点,请导航到检查器中的应用程序选项卡,然后点击“服务工作者”,然后点击“重新加载时更新”复选框。

每个服务工作者从其创建之时到可以使用之时都会经历状态变化。通过读取self.serviceWorker.state属性,在服务工作者内部可以获得此状态。以下是它经历的各个阶段的列表:

已解析

这是服务工作者的第一个状态。此时文件的 JavaScript 内容已被解析。这更像是一个您在应用程序中可能永远不会遇到的内部状态。

正在安装

安装已经开始但尚未完成。每个工作者版本只会发生一次。这种状态在调用oninstall之后,在event.respondWith()承诺解决之前处于活动状态。

安装中

此时安装已完成。接下来将调用onactivate处理程序。在我的测试中,我发现服务工作者从安装中状态跳转到激活中状态如此之快,以至于我从未看到已安装状态。

激活中

当调用onactivateevent.respondWith()承诺尚未解决时,会发生这种状态。

已激活

激活已完成,工作者已准备就绪,此时fetch事件将被拦截。

冗余

现在,已加载了脚本的更新版本,先前的脚本不再需要。如果工作脚本下载失败、包含语法错误或抛出错误,也会触发这种情况。

从哲学上讲,Service Worker 应被视为一种渐进增强的形式。这意味着如果根本不使用 Service Worker,任何使用它们的网页应仍然正常工作。这一点很重要,因为你可能会遇到不支持 Service Worker 的浏览器,或者安装阶段可能会失败,或者注重隐私的用户可能会完全禁用它们。换句话说,如果你只是想在应用程序中添加多线程功能,则选择其他 Web Worker 之一。

Service Worker 内部使用的全局self对象是ServiceWorkerGlobalScope的一个实例。其他 Web Worker 中可用的importScripts()函数在这个环境中也是可用的。像其他工作者一样,还可以将消息传递到 Service Worker 中,并从中接收消息。同样的self.onmessage处理程序可以被分配。也许可以用这种方式向 Service Worker 发出信号,告诉它应执行某种缓存失效操作。再次提醒,通过这种方式传递的消息也受到我们在附录中讨论的相同克隆算法的约束。

在调试 Service Worker 及从浏览器发出的请求时,需要牢记缓存。Service Worker 不仅可以通过编程方式实现你控制的缓存,而且浏览器本身仍然必须处理常规的网络缓存。这意味着从 Service Worker 发送到服务器的请求可能并不总是被服务器接收。因此,请记住Cache-ControlExpires头,并确保设置有意义的值。

Service Worker 具有比本节介绍的更多功能。Mozilla,Firefox 背后的公司,很友好地建立了一个充满常见策略的菜谱网站,用于构建 Service Worker。如果你考虑在下一个 Web 应用程序中实现 Service Worker,我们建议你查看该网站,地址为https://serviceworke.rs

Service Worker 以及你看过的其他 Web Worker,确实带来了一些复杂性。幸运的是,有一些方便的库可用,并且可以实现通信模式,使它们的管理变得更加容易。

消息传递抽象

本章涵盖的每个 Web Worker 都公开了一个接口,用于将消息传递到另一个 JavaScript 环境,并从中接收消息。这使你能够构建能够在多个核心上同时运行 JavaScript 的应用程序。

然而,到目前为止,你只使用了简单的、刻意构造的示例,传递简单字符串并调用简单函数。当涉及构建更大型应用程序时,传递可扩展的消息并在能够扩展的 Worker 中运行代码将变得至关重要,并简化与 Worker 一起工作时的接口也会减少潜在的错误。

RPC 模式

到目前为止,你只传递了基本的字符串给 Worker。虽然这对于了解 Web Worker 的功能是可以的,但对于完整的应用程序来说,这并不是一个良好的扩展方式。

例如,假设你有一个 Web Worker,它执行一项单一任务,比如计算从 1 到 1,000,000 的所有平方根的总和。那么,你可以仅调用postMessage()给 Worker,不传递参数,然后在onmessage处理程序中运行慢速逻辑,并使用 Worker 的postMessage()函数发送消息回来。但是如果 Worker 还需要计算斐波那契数列呢?在这种情况下,你可以传入一个字符串,一个是square_sum,一个是fibonacci。但是如果你需要参数呢?那么,你可以传入square_sum|1000000。但如果需要参数类型呢?也许你会得到类似square_sum|num:1000000的内容。你可能已经看出我们要说什么了。

RPC(远程过程调用)模式是一种将函数及其参数的表示形式序列化并传递到远程目的地以执行的方法。字符串square_sum|num:1000000实际上是我们意外重现的一种 RPC 形式。也许它最终可以转换为类似squareNum(1000000)的函数调用,这在“命令调度器模式”中有所考虑。

还有另一个复杂性,应用程序还需要担心。如果主线程一次只向 Web Worker 发送一个消息,那么当从 Web Worker 返回消息时,你知道它是该消息的响应。但如果同时向 Web Worker 发送多条消息,则很难将响应与消息对应起来。例如,想象一个应用程序同时向 Web Worker 发送两条消息并收到两条响应:

worker.postMessage('square_sum|num:4');
worker.postMessage('fibonacci|num:33');

worker.onmessage = (result) => {
  // Which result belongs to which message?
  // '3524578'
  // 4.1462643
};

幸运的是,存在一种标准用于传递消息并实现 RPC 模式的方式,可以从中获得灵感。这个标准称为JSON-RPC,实现起来相当简单。该标准定义了请求和响应对象的 JSON 表示形式作为“通知”对象,一种定义请求中调用方法和参数以及响应中结果的方式,以及关联请求和响应的机制。它甚至支持错误值和请求的批处理。在这个例子中,您将只使用请求和响应。

从我们的示例中获取的两个函数调用,JSON-RPC 版本的请求和响应可能如下所示:

// worker.postMessage
{"jsonrpc": "2.0", "method": "square_sum", "params": [4], "id": 1}
{"jsonrpc": "2.0", "method": "fibonacci", "params": [33], "id": 2}

// worker.onmessage
{"jsonrpc": "2.0", "result": "3524578", "id": 2}
{"jsonrpc": "2.0", "result": 4.1462643, "id": 1}

在这种情况下,响应消息现在与其请求之间有了明确的关联。

JSON-RPC 旨在使用 JSON 作为消息序列化时的编码,特别是在通过网络发送消息时。事实上,这些jsonrpc字段定义了消息所遵循的 JSON-RPC 版本,在网络设置中非常重要。然而,由于 Web 工作者使用结构化克隆算法(在附录中介绍),允许直接传递兼容 JSON 的对象,应用程序可以直接传递对象,而不必支付 JSON 序列化和反序列化的成本。此外,在浏览器中,通信通道的两端都有更严格的控制,因此jsonrpc字段可能不那么重要。

有了这些id属性,可以关联请求和响应对象,从而可以关联哪个消息与哪个消息相关联。您将在“将所有内容放在一起”中构建一个解决方案来关联这两者。但是,现在,您需要首先确定在收到消息时要调用哪个函数。

命令调度模式

虽然 RPC 模式在定义协议方面很有用,但并不一定提供确定接收端执行代码路径的机制。命令调度模式解决了这个问题,提供了一种方式来接收序列化命令,找到适当的函数,然后执行它,可选择传递参数。

这种模式实现起来非常直接,不需要太多的魔法。首先,我们可以假设有两个包含有关代码需要运行的方法或命令的相关信息的变量。第一个变量称为method,是一个字符串。第二个变量称为args,是一个要传递给方法的值数组。假设这些信息已从应用程序的 RPC 层中提取出来。

最终需要运行的代码可能存在于应用程序的不同部分。例如,可能求平方和的代码存放在第三方库中,而斐波那契数列的代码则更为本地化声明。无论代码存放在何处,都希望建立一个单一的存储库,将这些命令映射到需要运行的代码。有几种方法可以实现这一点,例如使用Map对象,但由于命令可能相对静态,一个简单的 JavaScript 对象就足够了。

另一个重要概念是,只有已定义的命令才能被执行。如果调用者想要调用一个不存在的方法,应该优雅地生成一个错误,并将其返回给调用者,而不会使 Web Worker 崩溃。虽然参数可以作为数组传递到方法中,但如果参数数组展开为普通函数参数,则接口会更友好。

示例 2-11 展示了一个命令调度器的示例实现,你可以在自己的应用程序中使用。

示例 2-11. 示例命令调度器
const commands = { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  square_sum(max) {
    let sum = 0;
    for (let i = 0; i < max; i++) sum += Math.sqrt(i);
    return sum;
  },
  fibonacci(limit) {
    let prev = 1n, next = 0n, swap;
    while (limit) {
      swap = prev; prev = prev + next;
      next = swap; limit--;
    }
    return String(next);
  }
};
function dispatch(method, args) {
  if (commands.hasOwnProperty(method)) { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
    return commandsmethod; ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
  }
  throw new TypeError(`Command ${method} not defined!`);
}

1

所有支持命令的定义。

2

检查命令是否存在。

3

参数展开并调用方法。

此代码定义了一个名为commands的对象,其中包含命令调度器支持的全部命令集合。在本例中,代码是内联的,但可以完全正常地(甚至是鼓励地)访问其他地方的代码。

dispatch()函数接受两个参数,第一个是方法名,第二个是参数数组。当 Web Worker 接收到代表命令的 RPC 消息时,可以调用此函数。在此函数中,第一步是检查方法是否存在。可以使用commands.hasOwnProperty()来实现此目的。这比调用method in commands或甚至commands[method]更安全,因为你不希望调用非命令属性如__proto__

如果确定命令存在,则将命令参数展开,第一个数组元素为第一个参数,依此类推。然后调用函数并返回调用结果。但如果命令不存在,则会抛出TypeError

这就是你可以创建的最基本的命令调度器。更高级的调度器可能会进行诸如类型检查之类的操作,验证参数是否符合某种基本类型或对象是否符合适当的形状,并在命令方法代码无需执行此类操作时,以通用方式抛出错误。

这两种模式肯定会帮助优化你的应用程序,但接口还可以进一步简化。

将所有内容整合在一起

在 JavaScript 应用程序中,我们经常考虑与外部服务进行工作。例如,可能我们会调用数据库或进行 HTTP 请求。当这些操作发生时,我们需要等待响应。理想情况下,我们可以提供回调函数或将此查找视为一个 promise。尽管 Web Worker 消息传递接口并不直接支持这一点,但我们绝对可以手动构建它。

在 Web Worker 内部也希望有一个更对称的接口,或许可以利用异步函数,其中解析的值会自动发送回调用环境,无需在代码中手动调用 postMessage()

在这一节中,您将做到这一点。您将结合 RPC 模式和命令调度模式,最终得到一个界面,使得与 Web Workers 的工作方式与您可能更熟悉的其他外部库类似。这个示例使用了专用 worker,但是同样的事情也可以使用共享 worker 或 service worker 来构建。

首先,在此处创建一个名为 ch2-patterns/ 的新目录,用于存放您即将创建的文件。在这里,首先创建另一个基本的 HTML 文件,命名为 index.html,其中包含 Example 2-12 的内容。

Example 2-12. ch2-patterns/index.html
<html>
  <head>
    <title>Worker Patterns</title>
    <script src="rpc-worker.js"></script>
    <script src="main.js"></script>
  </head>
</html>

这次文件加载了两个 JavaScript 文件。第一个是一个新的库,第二个是主 JavaScript 文件,您现在将创建它。创建一个名为 main.js 的文件,并将 Example 2-13 的内容添加到其中。

Example 2-13. ch2-patterns/main.js
const worker = new RpcWorker('worker.js');

Promise.allSettled([
  worker.exec('square_sum', 1_000_000),
  worker.exec('fibonacci', 1_000),
  worker.exec('fake_method'),
  worker.exec('bad'),
]).then(([square_sum, fibonacci, fake, bad]) => {
  console.log('square sum', square_sum);
  console.log('fibonacci', fibonacci);
  console.log('fake', fake);
  console.log('bad', bad);
});

这个文件代表使用这些新设计模式的应用程序代码。首先创建了一个 worker 实例,但不是通过调用到目前为止您一直在使用的 Web Worker 类之一。相反,代码实例化了一个新的 RpcWorker 类。这个类即将定义。

之后,通过调用 worker.exec 进行四个不同的 RPC 方法调用。第一个是调用 square_sum 方法,第二个是调用 fibonacci 方法,第三个是调用一个不存在的方法 fake_method,第四个是调用一个失败的方法 bad。第一个参数是方法的名称,所有后续的参数最终都将作为传递给方法的参数。

exec 方法返回一个 promise,如果操作成功则解析,如果操作失败则拒绝。考虑到这一点,每个 promise 都被包装在单独的 Promise.allSettled() 调用中。这将运行它们所有,并且一旦每个操作完成(无论成功与否)就继续执行。之后打印每个操作的结果。allSettled() 的结果包括一个带有 status 字符串属性的对象数组,以及根据成功或失败而有的 valuereason 属性。

接下来,创建一个名为 rpc-worker.js 的文件,并将 Example 2-14 的内容添加到其中。

示例 2-14. ch2-patterns/rpc-worker.js(第一部分)
class RpcWorker {
  constructor(path) {
    this.next_command_id = 0;
    this.in_flight_commands = new Map();
    this.worker = new Worker(path);
    this.worker.onmessage = this.onMessageHandler.bind(this);
  }

文件的第一部分开始了RpcWorker类并定义了构造函数。在构造函数中初始化了一些属性。首先,next_command_id设置为零。这个值被用作 JSON-RPC 风格的递增消息标识符。这用于关联请求和响应对象。

接下来,一个名为in_flight_commands的属性被初始化为一个空的Map。这包含以命令 ID 为键的条目,其值包含一个 promise 的 resolve 和 reject 函数。这个映射的大小随着发送到工作线程的并行消息数量的增加而增长,并随着它们对应的消息返回而缩小。

然后,一个专用的工作线程被实例化并分配给worker属性。这个类有效地封装了一个Worker实例。之后,配置工作线程的onmessage处理程序,以调用该类的onMessageHandler(在下一段代码中定义)。RpcWorker类不扩展Worker,因为它实际上不想暴露底层 web worker 的功能,而是创建一个全新的接口。

继续修改文件,将内容从示例 2-15 添加到其中。

示例 2-15. ch2-patterns/rpc-worker.js(第二部分)
  onMessageHandler(msg) {
    const { result, error, id } = msg.data;
    const { resolve, reject } = this.in_flight_commands.get(id);
    this.in_flight_commands.delete(id);
    if (error) reject(error);
    else resolve(result);
  }

该文件的这一部分定义了onMessageHandler方法,当专用工作线程发布消息时运行。这段代码假定从 web worker 传递了类似 JSON-RPC 的消息到调用环境,因此,它首先从响应中提取resulterrorid值。

接下来,它查询in_flight_commands映射以找到匹配的id值,检索适当的拒绝和解析函数,并在此过程中从列表中删除条目。如果提供了error值,则认为操作失败,并调用带有错误值的reject()函数。否则,使用操作的结果调用resolve()函数。请注意,这不支持抛出假值。

对于这个库的生产版本,您还希望为这些操作支持一个超时值。从理论上讲,错误可能以这种方式抛出,或者承诺永远不会在工作线程中解决,调用环境将希望拒绝承诺并清除地图中的数据。否则,应用程序可能会出现内存泄漏。

最后,通过将剩余内容从示例 2-16 添加到其中来完成这个文件。

示例 2-16. ch2-patterns/rpc-worker.js(第三部分)
  exec(method, ...args) {
    const id = ++this.next_command_id;
    let resolve, reject;
    const promise = new Promise((res, rej) => {
      resolve = res;
      reject = rej;
    });
    this.in_flight_commands.set(id, { resolve, reject });
    this.worker.postMessage({ method, params: args, id });
    return promise;
  }
}

这个文件的最后一部分定义了exec()方法,当应用程序想要在 Web Worker 中执行方法时调用该方法。首先发生的是生成一个新的id值。接下来,创建了一个 promise,稍后该方法将返回该 promise。将 promise 的rejectresolve函数从中提取出来,并将它们与id值关联添加到in_flight_commands映射中。

之后,向 worker 发送了一条消息。传递给 worker 的对象大致遵循 JSON-RPC 的形状。它包含method属性,一个params属性,该属性是数组中剩余的参数,并包含为此特定命令执行生成的id值。

这是一种相当常见的模式,用于将出站异步消息与入站异步消息关联起来。如果需要的话,您可能会发现自己实现类似的模式,比如将消息放入网络队列并稍后接收消息。但是,它确实会有内存影响。

将 RPC 工作文件放在一边后,您可以准备创建最后一个文件。创建一个名为worker.js的文件,并将 Example 2-17 的内容添加到其中。

示例 2-17. ch2-patterns/worker.js
const sleep = (ms) => new Promise((res) => setTimeout(res, ms)); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)

function asyncOnMessageWrap(fn) { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
  return async function(msg) {
    postMessage(await fn(msg.data));
  }
}

const commands = {
  async square_sum(max) {
    await sleep(Math.random() * 100); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
    let sum = 0; for (let i = 0; i < max; i++) sum += Math.sqrt(i);
    return sum;
  },
  async fibonacci(limit) {
    await sleep(Math.random() * 100);
    let prev = 1n, next = 0n, swap;
    while (limit) { swap = prev; prev = prev + next; next = swap; limit--; }
    return String(next); ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/4.png)
  },
  async bad() {
    await sleep(Math.random() * 10);
    throw new Error('oh no');
  }
};

self.onmessage = asyncOnMessageWrap(async (rpc) => { ![5](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/5.png)
  const { method, params, id } = rpc;

  if (commands.hasOwnProperty(method)) {
    try {
      const result = await commandsmethod;
      return { id, result }; ![6](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/6.png)
    } catch (err) {
      return { id, error: { code: -32000, message: err.message }};
    }
  } else {
    return { ![7](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/7.png)
      id, error: {
        code: -32601,
        message: `method ${method} not found`
      }
    };
  }
});

1

向方法添加人为减速。

2

一个基本的包装器,将onmessage转换为异步函数。

3

人为随机减速添加到命令中。

4

BigInt结果被强制转换为 JSON 友好的字符串值。

5

注入了onmessage包装器。

6

一个成功的类 JSON-RPC 消息在成功时被解析。

7

如果方法不存在,则会拒绝错误的类 JSON-RPC 消息。

这个文件有很多内容。首先,sleep函数只是setTimeout()的一个等价的 promise 版本。asyncOnMessageWrap()是一个函数,可以包装一个async函数并分配onmessage处理程序。这是一个便利的功能,可以提取传入消息的数据属性,将其传递给函数,等待结果,然后将结果传递给postMessage()

之后,之前的commands对象已经回来了。不过,这一次添加了人为的超时,并且将函数改为了async函数。这使得这些方法能够模拟一个否则缓慢的异步过程。

最后,使用包装函数分配 onmessage 处理程序。其中的代码获取传入的类似 JSON-RPC 的消息,并提取 methodparamsid 属性。与之前类似,会查阅命令集合以查看是否存在该方法。如果不存在,则返回类似 JSON-RPC 的错误。值 -32601 是 JSON-RPC 定义的魔数,表示不存在的方法。当命令存在时,执行命令方法,然后将解析的值强制转换为类似 JSON-RPC 的成功消息并返回。如果命令抛出异常,则返回不同的错误,使用另一个 JSON-RPC 魔数 -32000

创建文件后,切换到浏览器并打开检查器。然后,从 ch2-patterns/ 目录中使用以下命令再次启动 Web 服务器:

$ npx serve .

接下来,切换回浏览器并粘贴来自输出的 URL。页面上看不到有趣的内容,但在控制台中,您应该看到以下消息:

square sum    { status: "fulfilled", value: 666666166.4588418 }
fibonacci     { status: "fulfilled", value: "4346655768..." }
fake          { status: "rejected", reason: { code: -32601,
                message: "method fake_method not found" } }
bad           { status: "rejected", reason: { code: -32000,
                message: "oh no" } }

在此示例中,可以看到 square_sumfibonacci 调用均成功完成,而 fake_method 命令导致失败。更重要的是,在内部,方法调用由于增加的 id 值而总是与其请求正确关联。

^(1) 从 Firefox v85 开始,无论 ports 集合中有多少条目,调用 console.log(ports) 都将始终显示单个条目。目前,要调试集合大小,请改为调用 console.log(ports.size)

第三章:Node.js

在浏览器之外,只有一个值得注意的 JavaScript 运行时,那就是 Node.js。^(1) 虽然它起初是一个强调单线程并发的平台,使用 continuation-passing style 回调在服务器中,但也投入了大量精力使其成为通用编程平台。

许多由 Node.js 程序执行的任务不适合其传统用例,如提供 Web 请求或处理网络连接。相反,许多较新的 Node.js 程序是作为构建系统的命令行工具,或其部分,用于 JavaScript。这些程序通常在 I/O 操作上很重,就像服务器一样,但它们也通常做大量的数据处理。

例如,像BabelTypeScript这样的工具将把您的代码从一种语言(或语言版本)转换为另一种。像WebpackRollupParcel这样的工具将捆绑和缩小您的代码,以分发到您的 Web 前端或其他负载时间至关重要的环境,如无服务器环境。在这些情况下,虽然会进行大量的文件系统 I/O,但也会进行大量的数据处理,通常是同步进行的。这些都是并行性非常方便且可能更快完成任务的情况。

并行性在 Node.js 的原始用例中也可能很有用,即服务器。数据处理可能会频繁发生,这取决于您的应用程序。例如,服务器端渲染(SSR)涉及大量的字符串操作,其中源数据已知。这是我们可能希望在解决方案中添加并行性的众多示例之一。“何时使用”探讨了并行性提高模板渲染时间的情况。

今天,我们拥有worker_threads用于并行化我们的代码。这并不总是这样,但这并不意味着我们局限于单线程并发。

在拥有线程之前

在 Node.js 中可用线程之前,如果想利用 CPU 核心,就需要使用进程。正如在第一章中讨论的那样,如果使用进程,我们无法从线程中获得一些好处。话虽如此,如果共享内存不重要(在许多情况下确实如此!),那么进程完全能够解决这些问题。

参考第一章中的图 1-1。在这种情况下,我们有线程响应从主线程发送到它们的 HTTP 请求,而主线程则监听端口。虽然这个概念对于处理来自几个 CPU 核心的流量很有用,但我们也可以使用进程来实现类似的效果。这可能看起来像图 3-1。

一个 Web 服务器系统可能会按循环轮询的方式将工作分配给进程。

图 3-1. 进程在 HTTP 服务器中的使用示意图

虽然我们可以使用 Node.js 中的child_process API 来做类似的事情,但最好使用cluster,因为它专门为这种用例构建。这个模块的目的是将网络流量分散到几个工作进程中。让我们在一个简单的“Hello, World”示例中使用它。

示例 3-1 中的代码是 Node.js 中的标准 HTTP 服务器。它只是响应任何请求,无论路径或方法如何,都会返回“Hello, World!”后跟一个换行符。

示例 3-1. Node.js 中的“Hello, World”服务器
const http = require('http');

http.createServer((req, res) => {
  res.end('Hello, World!\n');
}).listen(3000);

现在,让我们使用cluster添加四个进程。使用cluster模块时,常见的方法是使用if块来检测我们是在主监听进程还是工作进程之一。如果我们在主进程中,那么我们必须执行生成工作进程的工作。否则,在每个工作进程中,我们只需像以前一样设置普通的 Web 服务器。这应该看起来像是示例 3-2。

示例 3-2. 使用cluster的 Node.js 中的“Hello, World”服务器
const http = require('http');
const cluster = require('cluster'); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)

if (cluster.isPrimary) { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
  cluster.fork(); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
  cluster.fork();
  cluster.fork();
  cluster.fork();
} else {
  http.createServer((req, res) => {
    res.end('Hello, World!\n');
  }).listen(3000); ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/4.png)
}

1

需要引入cluster模块。

2

根据我们是否在主进程中,更改代码路径。

3

在主进程中,创建四个工作进程。

4

在工作进程中,创建一个 Web 服务器并进行监听,就像示例 3-1 中一样。

您可能注意到我们在四个不同的进程中创建监听同一端口的 Web 服务器。这看起来像是一个错误。毕竟,如果我们尝试绑定到已经使用的端口,通常会收到错误。不要担心!我们实际上并没有四次监听同一个端口。事实上,Node.js 在cluster中为我们做了一些魔法。

当工作进程在集群中设置时,对listen()的任何调用实际上会导致 Node.js 在主进程上而不是在工作进程上进行监听。然后,一旦主进程接收到连接,它会通过 IPC 传递给工作进程。在大多数系统上,这是按照循环轮询的方式进行的。这种有些复杂的系统是每个工作进程可以看起来在同一个端口上监听,但实际上只是主进程在那个端口上监听,并将连接传递给所有工作进程。

注意

从历史上看,cluster中的isPrimary属性曾被称为isMaster,出于兼容性的考虑,在撰写本文时它仍然作为别名存在。这个更改是在 Node.js v16.0.0 中引入的。

这一变更旨在减少 Node.js 中潜在有害语言的使用。该项目旨在建立一个友好的社区,具有一定历史背景的特定用词与这一目标背道而驰。

进程比线程多一些额外的开销,并且我们也没有共享内存,这有助于更快地传输数据。为此,我们需要 worker_threads 模块。

worker_threads 模块

Node.js 对线程的支持是通过内置模块 worker_threads 实现的。它提供了一种接口,模仿了在 Web 浏览器中为 Web Worker 找到的许多内容。由于 Node.js 不是一个 Web 浏览器,因此并非所有的 API 都相同,而这些工作线程内部的环境也不同于 Web Worker 内部的环境。

在 Node.js 工作线程内部,您会发现通过 require 可用的常规 Node.js API,或者如果您使用 ESM,则通过 import。不过,与主线程相比,API 中存在一些差异:

  • 你不能用 process.exit() 退出程序。相反,这将仅退出线程。

  • 你不能用 process.chdir() 改变工作目录。事实上,这个函数甚至不可用。

  • 你不能用 process.on() 处理信号。

还有一件重要的事情需要注意,即 libuv 工作线程池在工作线程之间是共享的。回顾“隐藏线程”一节,可以注意到 libuv 线程池由默认的四个线程组成,用于创建对低级阻塞 API 的非阻塞接口。如果发现自己受制于该线程池的大小(例如,大量的文件系统 I/O),您会发现通过 worker_threads 添加更多线程并不会减轻负载。相反,除了考虑各种缓存解决方案和其他优化之外,考虑增加 UV_THREADPOOL_SIZE。同样,当通过 worker_threads 模块添加 JavaScript 线程时,您可能发现没有其他选择,只能增加这一大小,因为它们使用 libuv 线程池。

还有其他注意事项,请参阅Node.js 文档以获取特定版本 Node.js 的完整差异列表。

您可以通过使用 Worker 构造函数来创建一个新的工作线程,就像在 示例 3-3 中一样。

示例 3-3. 在 Node.js 中生成一个新的工作线程
const { Worker } = require('worker_threads');

const worker = new Worker('/path/to/worker-file-name.js'); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)

1

此处的文件名是我们希望在工作线程内运行的入口文件。这类似于在主文件中指定作为 node 命令行参数的入口点。

workerData

仅仅创建工作线程是不够的,我们需要与其进行交互!Worker 构造函数接受第二个参数,即一个 options 对象,其中允许我们立即指定一组数据传递给工作线程。options 对象的属性称为 workerData,其内容将通过附录中描述的方式复制到工作线程中。在线程内部,我们可以通过 worker_threads 模块的 workerData 属性访问克隆的数据。你可以在示例 3-4 中看到其工作原理。

示例 3-4. 通过 workerData 向工作线程传递数据
const {
  Worker,
  isMainThread,
  workerData
} = require('worker_threads');
const assert = require('assert');

if (isMainThread) { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  const worker = new Worker(__filename, { workerData: { num: 42 } });
} else {
  assert.strictEqual(workerData.num, 42);
}

1

不必为工作线程使用单独的文件,我们可以使用当前文件的 __filename 并根据 isMainThread 切换行为。

需要注意的是,workerData 对象的属性是克隆而不是在线程之间共享的。与 C 语言不同,在 JavaScript 线程中的共享内存并不意味着所有变量都是可见的。这意味着你对该对象所做的任何更改在另一个线程中是不可见的。它们是独立的对象。话虽如此,你可以通过 SharedArrayBuffer 实现线程之间的共享内存。这些可以通过 workerData 或通过 MessagePort 发送,下一节将介绍这部分内容。此外,SharedArrayBuffer 在第四章中有详细介绍。

MessagePort

MessagePort 是双向数据流的一端。默认情况下,每个工作线程都提供一个 MessagePort,用于与主线程之间的通信通道。在工作线程中,它作为 worker_threads 模块的 parentPort 属性而可用。

要通过端口发送消息,需要调用 postMessage() 方法。第一个参数可以是任何可以传递的对象,如附录所述,它最终将成为传递到端口另一端的消息数据。当在端口上接收到消息时,将触发 message 事件,消息数据将作为事件处理函数的第一个参数。在主线程中,事件和 postMessage() 方法都在工作实例本身上,而不是必须从 MessagePort 实例中获取。示例 3-5 展示了一个简单的例子,其中将消息发送到主线程并被回送到工作线程。

示例 3-5. 通过默认的 MessagePort 进行双向通信
const {
  Worker,
  isMainThread,
  parentPort
} = require('worker_threads');

if (isMainThread) {
  const worker = new Worker(__filename);
  worker.on('message', msg => {
    worker.postMessage(msg);
  });
} else {
  parentPort.on('message', msg => {
    console.log('We got a message from the main thread:', msg);
  });
  parentPort.postMessage('Hello, World!');
}

您还可以创建一对通过 MessageChannel 构造函数连接的 MessagePort 实例。然后可以通过现有的消息端口(例如默认的消息端口)或通过 workerData 传递其中一个端口。在两个需要通信的线程都不是主线程,或者仅仅是为了组织目的时,您可能会这样做。示例 3-6 与前一个示例相同,只是使用了通过 MessageChannel 创建并通过 workerData 传递的端口。

示例 3-6. 通过 MessageChannel 创建的双向通信的 MessagePort
const {
  Worker,
  isMainThread,
  MessageChannel,
  workerData
} = require('worker_threads');

if (isMainThread) {
  const { port1, port2 } = new MessageChannel();
  const worker = new Worker(__filename, {
    workerData: {
      port: port2
    },
    transferList: [port2]
  });
  port1.on('message', msg => {
    port1.postMessage(msg);
  });
} else {
  const { port } = workerData;
  port.on('message', msg => {
    console.log('We got a message from the main thread:', msg);
  });
  port.postMessage('Hello, World!');
}

您会注意到我们在实例化 Worker 时使用了 transferList 选项。这是将对象从一个线程传递到另一个线程的一种方式。当通过 workerDatapostMessage 发送任何 MessagePortArrayBufferFileHandle 对象时,这是必需的。一旦这些对象被传输,它们就不能在发送方使用了。

提示

在 Node.js 的更新版本中,Web Hypertext Application Technology Working Group (WHATWG) 的 ReadableStreamWritableStream 可用。您可以在 Node.js 文档 中了解更多信息,并在某些 API 中使用它们。它们可以通过 MessagePort 上的 transferList 被转移,以启用跨线程的另一种通信方式。在底层,这些是使用 MessagePort 实现的,用于跨线程发送数据。

Happycoin:重访

现在我们已经了解了在 Node.js 中生成线程并使它们互相通信的基础知识,我们有足够的内容来在 Node.js 中重建我们的示例,来自于 “在 C 语言中使用线程:用 Happycoin 致富”。

记住,Happycoin 是我们想象中的加密货币,其完全荒谬的工作证明算法如下:

  1. 生成一个随机的无符号 64 位整数。

  2. 确定整数是否为快乐数。

  3. 如果不快乐,那就不是 Happycoin。

  4. 如果不能被 10,000 整除,那就不是 Happycoin。

  5. 否则,它就是一个 Happycoin。

就像我们在 C 语言中做的那样,我们首先创建一个单线程版本,然后再将代码适配为多线程运行。

仅使用主线程

让我们从生成随机数开始。首先,创建一个名为 happycoin.js 的文件,在名为 ch3-happycoin/ 的目录中。将其填充为 示例 3-7 的内容。

示例 3-7. ch3-happycoin/happycoin.js
const crypto = require('crypto');

const big64arr = new BigUint64Array(1)
function random64() {
  crypto.randomFillSync(big64arr);
  return big64arr[0];
}

Node.js 中的 crypto 模块为我们提供了一些便捷的函数,用于获取加密安全的随机数。毕竟我们正在构建一个加密货币,这些函数对我们来说非常重要!幸运的是,在 Node.js 中这比在 C 语言中要简单。

randomFillSync 函数会用随机数据填充给定的 TypedArray。因为我们只需要一个 64 位无符号整数,我们可以使用 BigUint64Array。这个特定的 TypedArray 和它的兄弟 BigInt64Array,是 JavaScript 的最新补充,依赖于新的 bigint 类型,用于存储任意大的整数。在用随机数据填充完毕后,返回这个数组的第一个(也是唯一的)元素,即得到了我们要找的随机 64 位无符号整数。

现在让我们添加我们的 Happycoin 计算。将 示例 3-8 的内容添加到您的文件中。

示例 3-8. ch3-happycoin/happycoin.js
function sumDigitsSquared(num) {
  let total = 0n;
  while (num > 0) {
    const numModBase = num % 10n;
    total += numModBase ** 2n;
    num = num / 10n;
  }
  return total;
}

function isHappy(num) {
  while (num != 1n && num != 4n) {
    num = sumDigitsSquared(num);
  }
  return num === 1n;
}

function isHappycoin(num) {
  return isHappy(num) && num % 10000n === 0n;
}

这三个函数,sumDigitsSquaredisHappyisHappycoin,是直接从其 C 语言对应物翻译而来,见于 “C 语言中的线程:用 Happycoin 致富”。如果你对 bigint 不熟悉,你可能会注意到代码中所有数字文字都带有 n 后缀。这个后缀告诉 JavaScript 这些数字应该被视为 bigint 值,而不是 number 类型的值。这一点很重要,因为虽然这两种类型都支持像 +-** 等数学运算符,但它们不能互操作,除非进行显式转换。例如,1 + 1n 是无效的,因为它试图将 number 1 和 bigint 1 相加。

最后,让我们通过实现我们的 Happycoin 挖掘循环并输出找到的 Happycoin 数量来完成文件。将 示例 3-9 添加到您的文件中。

示例 3-9. ch3-happycoin/happycoin.js
let count = 0;
for (let i = 1; i < 10_000_000; i++) {
  const randomNum = random64();
  if (isHappycoin(randomNum)) {
    process.stdout.write(randomNum.toString() + ' ');
    count++;
  }
}

process.stdout.write('\ncount ' + count + '\n');

这里的代码与我们在 C 语言中做的非常相似。我们循环 10,000,000 次,获取一个随机数并检查它是否是 Happycoin。如果是,我们将其输出。请注意,这里我们不使用 console.log(),因为我们不希望在每个找到的数字后插入换行符。相反,我们想要空格,所以我们直接写入输出流。在循环结束后输出计数时,我们需要在输出的开头加一个额外的换行符,以便与上面的数字分隔开来。

要运行此程序,请在您的 ch3-happycoin 目录中使用以下命令:

$ node happycoin.js

输出结果应该与 C 语言示例完全一致。也就是说,输出应该看起来像这样:

5503819098300300000 ...  [ 125 more entries ] ... 5273033273820010000
count 127

这比 C 语言示例要慢得多。在一台普通机器上,使用 Node.js v16.0.0 大约需要 1 分钟 45 秒。

有许多原因导致这个过程耗时如此之长。在构建应用程序并优化性能时,重要的是找出性能开销的来源。是的,一般来说,JavaScript 往往比 C 语言“慢”,但这种巨大的差距不仅仅可以用这个来解释。是的,在下一节中,当我们将其拆分为多个工作线程时,性能会得到改善,但正如你所看到的,与 C 语言示例相比,这种实现远非引人注目。

说到这一点,让我们看看当我们使用 worker_threads 来分担负载时会发生什么。

使用四个工作线程

要添加工作线程,我们将从原有的代码开始。将 happycoin.js 的内容复制到 happycoin-threads.js。然后在文件的开头插入 示例 3-10 的内容,放在现有内容之前。

示例 3-10. ch3-happycoin/happycoin-threads.js
const {
  Worker,
  isMainThread,
  parentPort
} = require('worker_threads');

我们需要 worker_threads 模块的这些部分,所以我们在开头进行 require。现在,将从 let count = 0; 到文件末尾的所有内容替换为 示例 3-11。

示例 3-11. ch3-happycoin/happycoin-threads.js
const THREAD_COUNT = 4;

if (isMainThread) {
  let inFlight = THREAD_COUNT;
  let count = 0;
  for (let i = 0; i < THREAD_COUNT; i++) {
    const worker = new Worker(__filename);
    worker.on('message', msg => {
      if (msg === 'done') {
        if (--inFlight === 0) {
          process.stdout.write('\ncount ' + count + '\n');
        }
      } else if (typeof msg === 'bigint') {
        process.stdout.write(msg.toString() + ' ');
        count++;
      }
    })
  }
} else {
  for (let i = 1; i < 10_000_000/THREAD_COUNT; i++) {
    const randomNum = random64();
    if (isHappycoin(randomNum)) {
      parentPort.postMessage(randomNum);
    }
  }
  parentPort.postMessage('done');
}

我们在这里通过一个 if 块来分割行为。如果我们在主线程上,我们使用当前文件启动四个工作线程。请记住,__filename 是一个包含当前文件路径和名称的字符串。然后我们为该工作线程添加一个消息处理程序。在消息处理程序中,如果消息仅为 done,则表示工作线程已完成其工作;如果所有其他工作线程都完成了,我们将输出计数。如果消息是一个数字,或者更确切地说是一个 bigint,则假定它是一个 Happycoin,并将其打印出来并添加到计数中,就像在单线程示例中所做的那样。

if 块的 else 部分,我们在其中一个工作线程中运行。在这里,我们将执行与单线程示例中相同类型的循环,但是我们只循环之前的 1/4 次,因为我们正在四个线程间共享同样的工作。此外,我们不直接向输出流写入,而是通过给予我们的 parentPort 发送找到的 Happycoins 返回主线程。我们已经为此在主线程上设置了处理程序。当循环退出时,我们在 parentPort 上发送 done,以指示主线程在此线程上将不再发现任何更多的 Happycoins。

我们本可以立即将 Happycoins 打印到输出中,但与 C 示例一样,我们不希望不同的线程在输出中互相干扰,因此我们需要同步。章节 4 和 5 讨论了更高级的同步技术,但目前只需将数据通过 parentPort 发送回主线程,让主线程处理输出就足够了。

现在我们已经添加了线程到这个例子中,您可以在您的 ch3-happycoin 目录下使用以下命令运行它:

$ node happycoin-threads.js

您应该看到类似以下的输出:

17241719184686550000 ... [ 137 more entries ] ... 17618203841507830000
count 139

就像 C 示例一样,这段代码运行速度相当快。在与单线程示例相同的计算机和 Node.js 版本上进行的测试中,运行时间约为 33 秒。这比单线程示例有了显著改进,线程再次大获成功!

注意

这并不是将这种类型的问题拆分为基于线程的计算的唯一方法。例如,可以使用其他同步技术来避免在线程之间传递数据,或者可以对消息进行批处理。一定要进行测试和比较,以确定线程是否是理想的解决方案,以及哪些线程技术最适合您的问题,以及最有效的方法。

使用 Piscina 的工作池

许多类型的工作负载自然倾向于使用线程。在 Node.js 中,大多数工作负载涉及处理 HTTP 请求。如果在该代码中发现自己正在进行大量的数学或同步数据处理,将这些工作分配给一个或多个线程可能是有意义的。这些类型的操作涉及将单个任务提交给一个线程,并等待其结果。与多线程 Web 服务器工作方式类似,维护一个可以从主线程发送各种任务的工作线程池是有道理的。

本节仅对线程池进行浅显的讨论,使用熟悉的 Happycoins 应用程序,并通过一个包抽象出池化机制。“线程池”广泛涵盖了线程池的内容,并从头开始实现一个实现。

注意

池化资源的概念并不局限于线程。例如,Web 浏览器通常会创建到 Web 服务器的套接字连接池,以便可以通过这些连接复用渲染网页所需的各种 HTTP 请求。数据库客户端库通常也会对连接到数据库服务器的套接字执行类似操作。

Node.js 中有一个方便的模块称为generic-pool,它是一个处理任意池化资源的辅助模块。这些资源可以是任何东西,比如数据库连接、其他套接字、本地缓存、线程或几乎任何需要多个实例但一次只访问一个实例的东西。

对于将离散任务发送到一组工作线程的用例,我们可以利用piscina模块。该模块封装了设置一堆工作线程并将任务分配给它们的工作。该模块的名称源自意大利语中“pool”的词。

基本用法很简单。您创建一个Piscina类的实例,传入一个filename,这将在工作线程中使用。在幕后,创建了一个工作线程池,并设置了一个队列来处理传入的任务。您可以通过调用.run()来排入任务,传入包含完成此任务所需数据的值,并注意这些值将被克隆,就像使用postMessage()一样。这将返回一个承诺,一旦任务由工作线程完成,就会解析出结果值。在要在工作线程中运行的文件中,必须导出一个函数,该函数接受传递给.run()的任何内容,并返回结果值。这个函数也可以是一个async函数,这样您就可以在工作线程中执行异步任务(如果需要的话)。在示例 3-12 中找到了一个在工作线程中计算平方根的基本示例。

示例 3-12。使用piscina计算平方根
const Piscina = require('piscina');

if (!Piscina.isWorkerThread) { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  const piscina = new Piscina({ filename: __filename }); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
  piscina.run(9).then(squareRootOfNine => { ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
    console.log('The square root of nine is', squareRootOfNine);
  });
}

module.exports = num => Math.sqrt(num); ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/4.png)

1

就像clusterworker_threads一样,piscina提供了一个方便的布尔值,用于确定我们是否在主线程还是工作线程中。

2

我们将使用与 Happycoin 示例相同的文件使用相同的技术。

3

由于.run()返回一个承诺,因此我们可以直接在其上调用.then()

4

导出的函数在工作线程中用于执行实际工作。在这种情况下,它只是计算一个平方根。

尽管在池中运行一个任务很好,但我们需要能够在池中运行多个任务。假设我们想计算小于一千万的每个数字的平方根。让我们继续循环一千万次。我们还将用一个断言替换日志记录,以确保我们得到了一个数值结果,因为日志记录会非常嘈杂。请看示例 3-13。

示例 3-13。使用piscina计算一千万个平方根
const Piscina = require('piscina');
const assert = require('assert');

if (!Piscina.isWorkerThread) {
  const piscina = new Piscina({ filename: __filename });
  for (let i = 0; i < 10_000_000; i++) {
    piscina.run(i).then(squareRootOfI => {
      assert.ok(typeof squareRootOfI === 'number');
    });
  }
}

module.exports = num => Math.sqrt(num);

看起来这应该可以工作。我们将提交一千万个数字供工作线程池处理。然而,如果您运行此代码,您将得到一个不可恢复的 JavaScript 内存分配错误。在使用 Node.js v16.0.0 的一个试验中,观察到了以下输出。

FATAL ERROR: Reached heap limit Allocation failed
    - JavaScript heap out of memory
 1: 0xb12b00 node::Abort() [node]
 2: 0xa2fe25 node::FatalError(char const*, char const*) [node]
 3: 0xcf8a9e v8::Utils::ReportOOMFailure(v8::internal::Isolate*,
    char const*, bool) [node]
 4: 0xcf8e17 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*,
    char const*, bool) [node]
 5: 0xee2d65  [node]
[ ... 13 more lines of a not-particularly-useful C++ stacktrace ... ]
Aborted (core dumped)

这里发生了什么?原来底层任务队列不是无限的。默认情况下,任务队列将继续增长,直到我们遇到类似这样的分配错误。为了避免发生这种情况,我们需要设置一个合理的限制。piscina模块允许您通过在其构造函数中使用maxQueue选项来设置限制,该选项可以设置为任何正整数。通过实验,piscina的维护者们发现,一个理想的maxQueue值是它使用的工作线程数量的平方。方便的是,您可以通过将maxQueue设置为auto来使用此数字,而无需知道它是多少。

一旦我们为队列大小建立了一个限制,我们需要能够处理队列已满的情况。检测队列已满有两种方法:

  1. 比较piscina.queueSizepiscina.options.maxQueue的值。如果它们相等,则队列已满。在调用piscina.run()之前进行此检查可以避免在队列已满时尝试入队操作。这是推荐的检查方法。

  2. 如果在队列已满时调用piscina.run(),返回的 Promise 将会因队列已满而被拒绝,显示队列已满的错误。这并不理想,因为在此时我们已经进入事件循环的更进一步刻度,并且可能已经尝试了许多其他入队操作。

当我们知道队列已满时,我们需要一种方法来知道何时它会准备好接收新任务。幸运的是,piscina池在队列为空时会触发drain事件,这绝对是开始添加新任务的理想时机。在示例 3-14 中,我们围绕提交任务的循环放置了一个async函数。

示例 3-14. 使用piscina计算一千万个平方根,无崩溃
const Piscina = require('piscina');
const assert = require('assert');
const { once } = require('events');

if (!Piscina.isWorkerThread) {
  const piscina = new Piscina({
    filename: __filename,
    maxQueue: 'auto' ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  });
  (async () => { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
    for (let i = 0; i < 10_000_000; i++) {
      if (piscina.queueSize === piscina.options.maxQueue) { ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
        await once(piscina, 'drain'); ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/4.png)
      }
      piscina.run(i).then(squareRootOfI => {
        assert.ok(typeof squareRootOfI === 'number');
      });
    }
  })();
}

module.exports = num => Math.sqrt(num);

1

maxQueue选项设置为auto,将队列大小限制为piscina使用线程数的平方。

2

for循环包装在一个立即调用的async函数表达式(IIFE)中,以便在其中使用await

3

当此检查为真时,队列已满。

4

然后,我们等待drain事件,在提交任何新任务到队列之前。

运行此代码不会像以前一样导致内存不足崩溃。它需要相当长的时间才能完成,但最终会顺利退出。

如下所示,很容易陷入陷阱,使用工具看似是最明智的方式,但并不是最佳方法。在构建多线程应用程序时,充分理解像piscina这样的工具是非常重要的。

顺便说一句,让我们看看当我们尝试使用piscina来挖掘 Happycoins 时会发生什么。

一池满的 Happycoins

要使用piscina来生成 Happycoins,我们将使用与原始worker_threads实现略有不同的方法。我们不再在每次获得 Happycoin 时都返回一个消息,而是在完成时将它们批量发送。这种折衷方案节省了我们设置MessageChannel来将数据发送回主线程的工作;副作用是我们只能批量获取结果,而不是在准备好时立即获取。主线程仍将负责生成适当的线程并检索所有结果。

首先,将您的happycoin-threads.js文件复制到一个名为happycoin-piscina.js的新文件中。我们将在之前的worker_threads示例的基础上构建。现在用示例 3-15 替换require('crypto')行之前的所有内容。

示例 3-15. ch3-happycoin/happycoin-piscina.js
const Piscina = require('piscina');

是的,就是这样!现在我们将进入更实质性的内容。用示例 3-16 中isHappycoin()函数声明后的内容替换一切。

示例 3-16. ch3-happycoin/happycoin-piscina.js
const THREAD_COUNT = 4;

if (!Piscina.isWorkerThread) { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  const piscina = new Piscina({
    filename: __filename, ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
    minThreads: THREAD_COUNT, ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
    maxThreads: THREAD_COUNT
  });
  let done = 0;
  let count = 0;
  for (let i = 0; i < THREAD_COUNT; i++) { ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/4.png)
    (async () => {
      const { total, happycoins } = await piscina.run(); ![5](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/5.png)
      process.stdout.write(happycoins);
      count += total;
      if (++done === THREAD_COUNT) { ![6](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/6.png)
        console.log('\ncount', count);
      }
    })();
  }
}

1

我们将使用isWorkerThread属性来检查我们是否在主线程中。

2

我们正在使用与之前相同的技术来创建使用同一文件的工作线程。

3

我们希望将线程数限制为恰好四个,以匹配我们之前的示例。我们将计时并查看发生了什么,因此保持四个线程可以减少这里的变量数量。

4

我们知道有四个线程,因此我们将任务排队四次。每个线程在检查其随机数块是否有 Happycoin 后会完成任务。

5

我们将任务提交到队列中的这个async IIFE 中,以便它们都在同一事件循环迭代中排队。不用担心,我们不会像以前那样因为内存不足而出错,因为我们知道我们确实有四个线程,并且只排队了四个任务。后面我们会看到,任务会返回输出字符串和线程找到的 Happycoin 的总数。

6

就像我们在之前的 Happycoin 实现中所做的那样,我们将检查所有线程是否完成其任务,然后输出我们找到的 Happycoin 的总数。

接下来我们将添加来自示例 3-17 的代码,该示例添加了在piscina的工作线程中使用的导出函数。

示例 3-17. ch3-happycoin/happycoin-piscina.js
module.exports = () => {
  let happycoins = '';
  let total = 0;
  for (let i = 0; i < 10_000_000/THREAD_COUNT; i++) { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
    const randomNum = random64();
    if (isHappycoin(randomNum)) {
      happycoins += randomNum.toString() + ' ';
      total++;
    }
  }
  return { total, happycoins }; ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
}

1

在这里我们正在进行典型的 Happycoin 搜索循环,但与其他并行性示例一样,我们将总搜索空间分割成几个线程。

2

我们通过从此函数返回一个值来将找到的 Happycoin 的字符串和它们的总数传回主线程。

要运行这个示例,如果你之前还没有安装piscina,你需要在你的ch3-happycoin目录中使用以下两个命令设置 Node.js 项目并添加piscina依赖项。然后可以使用第三行命令来运行代码:

$ npm init -y
$ npm install piscina
$ node happycoin-piscina.js

你应该看到与先前示例相同的输出,但有一点小变化。与其看到每个 Happycoin 一个接一个地到来,你将看到它们大致同时到达,或者以四个大组的形式到达。这是我们通过返回整个字符串而不是一个接一个地返回 Happycoins 所做的权衡。这段代码应该在大致相同的时间内运行,就像 happycoin-threads.js 一样,因为它使用了piscina提供的相同原理的抽象层。

你可以看到,我们并没有按照典型的方式使用piscina。我们没有传递大量的独立任务,最终需要仔细排队。这样做的主要原因是性能。

例如,如果在主线程中进行了一千万次迭代的循环,每次都向队列添加另一个任务并等待其响应,那么最终速度将和同步运行所有代码在主线程上一样慢。我们也可以不等待回复,尽快将事物添加到队列中,但事实证明,传递消息两千万次的开销比仅仅传递八个消息要大得多。

当处理原始数据,如数字或字节流时,通常可以使用SharedArrayBuffers在线程之间更快地传输数据,我们将在下一章节详细了解这些内容。

^(1) 是的,其他非浏览器 JavaScript 运行时也存在,比如 Deno,但截至撰写本文时,Node.js 的流行度和市场份额如此之大,以至于在这里只值得讨论它。希望在您阅读本文时情况可能已有所改变,这对 JavaScript 的世界来说是件好事!希望本书的新版本覆盖了您所选择的非浏览器 JavaScript 运行时。

第四章:共享内存

到目前为止,您已经接触过用于浏览器的 Web Workers API,这在第二章中有所涵盖,并且 Node.js 的工作线程模块,在“worker_threads 模块”中有所涵盖。这两个工具对于在 JavaScript 中处理并发非常有用,使开发人员能够以以前无法实现的方式并行运行代码。

然而,您迄今为止与它们的互动还相当浅显。虽然它们确实允许您并行运行代码,但您只是使用了消息传递 API,最终仍依赖于熟悉的事件循环来处理消息的接收。这比您在“C 语言中的线程:与 Happycoin 一起致富”中使用的线程代码要低效得多,那里这些不同的线程能够访问相同的共享内存。

本章介绍了 JavaScript 应用程序可用的两个强大工具:Atomics对象和SharedArrayBuffer类。这些工具允许您在两个线程之间共享内存,而无需依赖消息传递。但在深入讨论这些对象的完整技术说明之前,我们先来看一个快速的介绍性示例。

这些工具如果被滥用可能会很危险,在开发过程中引入逻辑上的错误到您的应用程序中,这些错误在生产环境中可能会显露出来。但如果经过磨练并正确使用,这些工具可以让您的应用程序在硬件上表现出前所未见的高性能水平。

共享内存介绍

对于这个示例,您将构建一个非常基本的应用程序,能够在两个 Web Worker 之间进行通信。虽然这需要一些初始的样板代码使用postMessage()onmessage,但后续的更新将不依赖于这样的功能。

此共享内存示例将在浏览器以及 Node.js 中运行,尽管两者所需的设置工作有些不同。现在,您将构建一个在浏览器中工作的示例,并提供了大量的描述。稍后,一旦您更加熟悉,您将构建一个使用 Node.js 的示例。

浏览器中的共享内存

要开始,请创建另一个目录以存放名为ch4-web-workers/的项目,然后创建一个名为index.html的 HTML 文件,并将内容从示例 4-1 添加到其中。

示例 4-1. ch4-web-workers/index.html
<html>
  <head>
    <title>Shared Memory Hello World</title>
    <script src="main.js"></script>
  </head>
</html>

完成文件后,您可以开始应用程序的更复杂部分。创建一个名为main.js的文件,其中包含来自示例 4-2 的内容。

示例 4-2. ch4-web-workers/main.js
if (!crossOriginIsolated) { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  throw new Error('Cannot use SharedArrayBuffer');
}

const worker = new Worker('worker.js');

const buffer = new SharedArrayBuffer(1024); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
const view = new Uint8Array(buffer); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)

console.log('now', view[0]);

worker.postMessage(buffer);

setTimeout(() => {
  console.log('later', view[0]);
  console.log('prop', buffer.foo); ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/4.png)
}, 500);

1

crossOriginIsolated为 true 时,可以使用SharedArrayBuffer

2

实例化一个 1 KB 缓冲区。

3

创建了一个对缓冲区的视图。

4

读取了修改后的属性。

此文件类似于您之前创建的文件。实际上,它仍在使用专用工作者。但是增加了一些复杂性。第一件新事是检查crossOriginIsolated值,这是现代浏览器中可用的全局变量之一。此值告诉您当前运行的 JavaScript 代码是否能够实例化SharedArrayBuffer实例。

出于与 Spectre CPU 攻击相关的安全原因,SharedArrayBuffer对象并非始终可用于实例化。事实上,几年前浏览器完全禁用了这个功能。现在,Chrome 和 Firefox 都支持该对象,并要求在文档服务之前设置额外的 HTTP 标头,才能允许实例化SharedArrayBuffer。Node.js 没有相同的限制。以下是所需的标头:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

您将自动运行的测试服务器设置这些标头。任何时候构建一个使用SharedArrayBuffer实例的生产就绪应用程序,都需要记住设置这些标头。

实例化专用工作者后,还会实例化一个SharedArrayBuffer的实例。此处的参数 1,024 是分配给缓冲区的字节数。与您熟悉的其他数组或缓冲对象不同,这些缓冲区在创建后无法收缩或增长。^(1)

还创建了一个用于处理名为view的缓冲区的视图。此类视图在“SharedArrayBuffer and TypedArrays”中有详细介绍,但现在,请将其视为一种读取和写入缓冲区的方法。

通过这个对缓冲区的视图,我们可以使用数组索引语法从中读取。在本例中,我们通过记录调用view[0]来检查缓冲区中的第 0 字节。之后,使用worker.postMessage()方法将缓冲区实例传递给工作者。在这种情况下,传递的唯一内容是缓冲区。然而,也可以传递更复杂的对象,其中缓冲区是其中一个属性。虽然附录中讨论的算法主要破坏复杂对象,但SharedArrayBuffer的实例是一个明确的例外。

一旦脚本完成设置工作,它会安排一个函数在 500 毫秒后运行。此脚本再次打印缓冲区的第 0 字节,并尝试打印一个名为.foo的附加到缓冲区的属性。请注意,此文件中否则没有定义worker.onmessage处理程序。

现在,您已经完成了主 JavaScript 文件,准备好创建工作者。创建一个名为worker.js的文件,并将内容从示例 4-3 添加到其中。

示例 4-3. ch4-web-workers/worker.js
self.onmessage = ({data: buffer}) => {
  buffer.foo = 42; ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  const view = new Uint8Array(buffer);
  view[0] = 2; ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
  console.log('updated in worker');
};

1

缓冲对象上的一个属性已被写入。

2

第 0 索引被设置为数字 2。

此文件附加了一个处理程序,用于在 main.js 中的 .postMessage() 方法触发后运行的 onmessage 事件。一旦调用,将获取缓冲区参数。处理程序中的第一件事就是将 .foo 属性附加到 SharedArrayBuffer 实例上。接下来,为缓冲区创建了另一个视图。之后,通过视图更新了缓冲区。完成这些后,将打印一条消息,以便你可以看到发生了什么。

现在,你的文件已经完成,可以准备运行新的应用程序了。打开一个终端窗口并运行以下命令。它与之前运行的 serve 命令有些不同,因为它需要提供安全头信息:

$ npx MultithreadedJSBook/serve .

与之前一样,在你的终端中打开显示的链接。接下来,打开网页检查器并访问“控制台”选项卡。你可能看不到任何输出;如果是这样,请刷新页面以重新执行代码。你应该能看到应用程序打印的日志。输出的示例已在 表 4-1 中重现。

表 4-1. 示例控制台输出

日志 位置
现在 0 main.js:10:9
更新于 worker worker.js:5:11
稍后 2 main.js:15:11
属性未定义 main.js:16:11

第一行打印的是在 main.js 中看到的缓冲区的初始值。在本例中,该值为 0。接下来,运行 worker.js 中的代码,不过其具体时序大多是不确定的。大约半秒钟后,再次打印在 main.js 中看到的值,并且该值现在设置为 2。再次注意,除了初始设置工作外,没有在运行 main.js 文件的线程与运行 worker.js 文件的线程之间进行消息传递。

这是一个非常简单的示例,虽然它能运行,但并不是你通常编写多线程代码的方式。不能保证在 worker.js 中更新的值会在 main.js 中可见。例如,聪明的 JavaScript 引擎可能会将该值视为常量,尽管你很难找到一个不会这样处理的浏览器。

在打印缓冲区值之后,也会打印 .foo 属性,并显示值 undefined。这可能是为什么呢?好吧,虽然确实存在一个引用内存位置的引用,该内存位置存储在缓冲区中的二进制数据,但实际对象本身并没有被共享。如果共享了,这将违反结构化克隆算法的约束,该算法规定对象引用不能在线程之间共享。

Node.js 中的共享内存

这个应用程序的 Node.js 等效版本大部分是相似的;然而,浏览器提供的Worker全局变量不可用,并且工作线程不会使用self.onmessage。相反,必须要求工作线程模块以获取这个功能。由于 Node.js 不是浏览器,因此index.html文件不适用。

要创建一个 Node.js 等效版本,你只需要两个文件,它们可以放在相同的ch4-web-workers/文件夹中。首先,创建一个main-node.js脚本,并将内容从示例 4-4 添加到其中。

示例 4-4. ch4-web-workers/main-node.js
#!/usr/bin/env node

const { Worker } = require('worker_threads');
const worker = new Worker(__dirname + '/worker-node.js');

const buffer = new SharedArrayBuffer(1024);
const view = new Uint8Array(buffer);

console.log('now', view[0]);

worker.postMessage(buffer);

setTimeout(() => {
  console.log('later', view[0]);
  console.log('prop', buffer.foo);
  worker.unref();
}, 500);

代码有些许不同,但大体上应该感觉上是熟悉的。因为全局的Worker不可用,所以必须从需要的worker_threads模块中获取.Worker属性来访问它。在实例化工作线程时,必须提供比浏览器接受的更明确的工作线程路径。在这种情况下,尽管浏览器只需worker.js,但需要提供更明确的路径./worker-node.js。除此之外,与浏览器等效的主 JavaScript 文件在这个 Node.js 示例中基本保持不变。最后的worker.unref()调用是为了防止工作线程永远保持进程运行状态。

接下来,创建一个名为worker-node.js的文件,其中包含与浏览器工作线程示例 4-5 相对应的 Node.js 版本。将内容添加到这个文件中。

示例 4-5. ch4-web-workers/worker-node.js
const { parentPort } = require('worker_threads');

parentPort.on('message', (buffer) => {
  buffer.foo = 42;
  const view = new Uint8Array(buffer);
  view[0] = 2;
  console.log('updated in worker');
});

在这种情况下,self.onmessage值对于工作线程是不可用的。相反,再次需要worker_threads模块,并使用模块中的.parentPort属性。这用于表示与调用 JavaScript 环境的端口的连接。

.onmessage处理程序可以分配给parentPort对象,并调用.on('message', cb)方法。如果同时使用这两种方法,它们将按照它们被使用的顺序调用。message事件的回调函数直接作为参数接收传入的对象(在这种情况下是buffer),而onmessage处理程序则提供一个包含buffer.data属性的MessageEvent实例。大多数情况下,使用哪种方法取决于个人偏好。

除此之外,Node.js 和浏览器之间的代码完全相同,像SharedArrayBuffer这样的全局变量仍然可用,并且在这个示例中仍然起作用。

现在这些文件已经完整,你可以使用以下命令来运行它们:

$ node main-node.js

这个命令的输出应当等同于浏览器中显示的表 4-1 中的输出。同样,相同的结构克隆算法允许SharedArrayBuffer的实例被传递,但只传递底层的二进制缓冲区数据,而不是对象本身的直接引用。

SharedArrayBuffer 和 TypedArrays

传统上,JavaScript 语言并不真正支持与二进制数据的交互。当然,有字符串,但它们实际上抽象了底层数据存储机制。还有数组,但这些数组可以包含任何类型的值,并不适合表示二进制缓冲区。多年来,这种状态差不多算是“够用了”,尤其是在 Node.js 出现之前和在不涉及网页上下文的情况下运行 JavaScript 受欢迎之前。

Node.js 运行时除了其他功能外,还能读写文件系统、在网络中进行数据流传输等。这些交互不仅限于基于 ASCII 的文本文件,还可以包括管道传输二进制数据。由于没有现成的便捷缓冲数据结构,作者们创建了自己的。因此,Node.js 的 Buffer 诞生了。

随着 JavaScript 语言本身边界的推动,API 和语言与浏览器窗口外部互动的能力也得到了增强。最终创建了 ArrayBuffer 对象,稍后又创建了 SharedArrayBuffer 对象,它们现在是语言的核心组成部分。很可能,如果今天创建 Node.js,它就不会创建自己的 Buffer 实现了。

ArrayBufferSharedArrayBuffer 的实例表示固定长度且不能调整大小的二进制数据缓冲区。虽然两者相似,但后者将是本节的重点,因为它允许应用程序在线程之间共享内存。二进制数据在许多传统编程语言(如 C 语言)中是一个普遍存在且重要的概念,但对于使用高级语言如 JavaScript 的开发人员来说,理解起来可能并不容易。

如果你还没有使用过,二进制 是一种基于 2 的计数系统,最低级别上表示为 1 和 0。每个数字称为一个 十进制 是人类主要用于计数的系统,基数为 10,用数字 0 到 9 表示。8 个位的组合称为一个字节,通常是内存中最小可寻址的值,因为与单个位相比处理起来更容易。基本上,这意味着 CPU(和程序员)处理字节而不是单个位。

这些字节通常以两个 十六进制 字符表示,这是一种基于 16 的计数系统,使用数字 0–9 和字母 A–F。实际上,在 Node.js 中记录 ArrayBuffer 的实例时,结果输出显示的是用十六进制表示的缓冲区值。

假设存储在磁盘上或甚至计算机内存中的一组任意字节,数据意义有点模糊。例如,十六进制值0x54(JavaScript 中的0x前缀表示值是十六进制的)代表什么?如果它是字符串的一部分,可能表示大写字母T。但如果它代表一个整数,可能是十进制数 84。它甚至可能是指内存位置,JPEG 图像中的像素的一部分,或者其他任意数量的事物。这里的上下文非常重要。相同的数字,在二进制中表示为0b010101000b前缀表示二进制)。

要记住这种模糊性,还要提到,无法直接修改ArrayBuffer(和SharedArrayBuffer)的内容。相反,必须先创建对缓冲区的“视图”。此外,不同于其他语言可能提供对废弃内存的访问,JavaScript 中实例化ArrayBuffer时缓冲区的内容被初始化为 0。考虑到这些缓冲区对象仅存储数值数据,它们确实是数据存储的非常基础的工具,通常用于构建更复杂的系统。

ArrayBufferSharedArrayBuffer都继承自Object,并具有相关的方法。除此之外,它们各自还有两个属性。第一个是只读的值.byteLength,表示缓冲区的字节长度;第二个是.slice(begin, end)方法,根据提供的范围返回缓冲区的副本。

.slice()begin值是包含的,而end值是排除的,这显然与String#substr(begin, length)不同,后者的第二个参数是长度。如果省略了begin值,则默认为第一个元素;如果省略了end值,则默认为最后一个元素。负数表示从缓冲区末尾计数。

下面是与ArrayBuffer基本交互的示例:

const ab = new ArrayBuffer(8);
const view = new Uint8Array(ab)
for (i = 0; i < 8; i++) view[i] = i;
console.log(view);
// Uint8Array(8) [
//   0, 1, 2, 3,
//   4, 5, 6, 7
// ]
ab.byteLength; // 8
ab.slice(); // 0, 1, 2, 3, 4, 5, 6, 7
ab.slice(4, 6); // 4, 5
ab.slice(-3, -2); // 5

不同的 JavaScript 环境以不同方式显示ArrayBuffer实例的内容。Node.js 显示一列十六进制对,就像数据将被视为Uint8Array。Chrome v88 显示一个可展开的对象,具有几种不同的视图。然而,Firefox 不会显示数据,需要首先通过视图传递。

术语视图已经在几个地方提到过,现在是定义它的好时机。由于二进制数据的含义可能存在歧义,我们需要使用视图来读取和写入底层缓冲区。在 JavaScript 中有几种这样的视图可用。每个视图都是从名为TypedArray的基类扩展而来。这个类不能直接实例化,也不作为全局变量提供,但可以通过从实例化的子类中获取.prototype属性来访问。

表 4-2 包含一组扩展自TypedArray的视图类列表。

表 4-2。扩展了TypedArray的类

Class Bytes Minimum Value Maximum Value
Int8Array 1 –128 127
Uint8Array 1 0 255
Uint8ClampedArray 1 0 255
Int16Array 2 –32,768 32,767
Uint16Array 2 0 65,535
Int32Array 4 –2,147,483,648 2,147,483,647
Uint32Array 4 0 4294967295
Float32Array 4 1.4012984643e-45 3.4028235e38
Float64Array 8 5e–324 1.7976931348623157e308
BigInt64Array 8 –9,223,372,036,854,775,808 9,223,372,036,854,775,807
BigUint64Array 8 0 18,446,744,073,709,551,615

类(Class)列是可用于实例化的类名称。这些类是全局的,在任何现代 JavaScript 引擎中都可以访问。字节(Bytes)列是用于表示每个单独元素的字节数。最小值(Minimum Value)和最大值(Maximum Value)列显示了可以用来表示缓冲区中元素的有效数值范围。

创建其中一个视图时,ArrayBuffer实例被传递到视图的构造函数中。缓冲区的字节长度必须是特定视图使用的元素字节长度的倍数。例如,如果创建了一个由 6 个字节组成的ArrayBuffer,则可以将其传递给Int16Array(字节长度为 2),因为这将表示三个Int16元素。然而,相同的 6 字节缓冲区不能传递给Int32Array,因为这将表示一个半元素,这是无效的。

如果您曾使用过低级语言如 C 或 Rust,这些视图的名称可能会很熟悉。

U前缀用于这些类的一半,表示只能表示正数。没有U前缀的类是有符号的,因此可以表示负数和正数,尽管最大值只有一半。这是因为有符号数使用第一位表示“符号”,传达数字是正数还是负数。

数值范围的限制来自于可以存储在单个字节中的数据量,以唯一标识一个数字。就像十进制一样,数字从零开始计数,直到基数,然后转到左边的数字。因此,对于Uint8数,或称为“由 8 位表示的无符号整数”,最大值(0b11111111)等于 255。

JavaScript 没有整数数据类型,只有其Number类型,这是IEEE 754 浮点数的实现。它相当于Float64数据类型。否则,任何时候将 JavaScriptNumber写入其中一个视图时,都需要进行某种转换过程。

当值被写入Float64Array时,它几乎可以保持不变。最小允许值与Number.MIN_VALUE相同,而最大值是Number.MAX_VALUE。当值被写入Float32Array时,不仅最小和最大值范围缩小,而且小数精度也会被截断。

例如,考虑以下代码:

const buffer = new ArrayBuffer(16);

const view64 = new Float64Array(buffer);
view64[0] = 1.1234567890123456789; // bytes 0 - 7
console.log(view64[0]); // 1.1234567890123457

const view32 = new Float32Array(buffer);
view32[2] = 1.1234567890123456789; // bytes 8 - 11
console.log(view32[2]); // 1.1234568357467651

在这种情况下,float64数的小数精度精确到第 15 位小数,而float32数的精度仅精确到第 6 位小数。

此代码展示了另一个有趣的事情。在这种情况下,有一个名为buffer的单个ArrayBuffer实例,但是有两个不同的TypedArray实例指向此缓冲区数据。你能想到其中有什么奇怪的地方吗?图 4-1 可能会给你一些提示。

指向单个 ArrayBuffer 的两个 TypedArray 视图

图 4-1. 单个ArrayBuffer和多个TypeArray视图

如果你读取view64[1]view32[0]view32[1],你认为会返回什么?在这种情况下,用于存储一种类型数据的内存的截断版本将被组合或分割,以表示另一种类型的数据。返回的值被错误地解释,是不合理的,尽管它们应该是确定性和一致性的。

当超出TypedArray非浮点数支持范围的数值被写入时,它们需要经过某种转换过程以适应目标数据类型。首先,该数字必须被转换为整数,就像传递给Math.trunc()一样。如果值超出可接受范围,则像使用模运算符(%)一样环绕并重置为0。以下是使用Uint8Array(最大元素值为 255 的TypedArray)时的一些示例:

const buffer = new ArrayBuffer(8);
const view = new Uint8Array(buffer);
view[0] = 255;    view[1] = 256;
view[2] = 257;    view[3] = -1;
view[4] = 1.1;    view[5] = 1.999;
view[6] = -1.1;   view[7] = -1.9;
console.log(view);

表 4-3 包含第二行输出的值及其对应的第一行值的列表。

表 4-3. TypedArray转换

输入 255 256 257 –1 1.1 1.999 –1.1 –1.9
输出 255 0 1 255 1 1 255 255

这种行为对于Uint8ClampedArray有些不同。当写入负值时,它转换为0。当写入大于 255 的值时,它转换为 255。当提供非整数值时,它会被传递给Math.round()。根据你的使用情况,使用这个视图可能更合适。

最后,BigInt64ArrayBigUint64Array 条目也值得特别关注。与其他TypedArray视图不同,这两个变体使用BigInt类型工作(1Number1nBigInt)。这是因为可以使用 64 字节表示的数值超出了 JavaScript 的 Number 可表示的范围。因此,使用这些视图设置值必须使用 BigInt,检索的值也将是 BigInt 类型。

一般来说,使用多个TypedArray视图,尤其是不同大小的视图来查看同一缓冲区实例是一件危险的事情,应尽可能避免。在执行不同操作时,可能会意外覆盖一些数据。可以在线程之间传递多个SharedArrayBuffer,因此,如果发现自己需要混合类型,则可能会受益于拥有多个缓冲区。

现在,您已经熟悉了ArrayBufferSharedArrayBuffer的基础知识,可以使用更复杂的 API 与它们进行交互了。

数据操作的原子方法

原子性 这个术语你可能之前听过,特别是在数据库方面,它是 ACID(原子性、一致性、隔离性、持久性)首字母缩略词中的第一个词。基本上,如果一个操作是原子的,那么虽然整体操作可能由多个较小的步骤组成,但整体操作保证要么完全成功,要么完全失败。例如,发送到数据库的单个查询是原子的,但三个单独的查询不是原子的。

另一方面,如果这三个查询包含在数据库事务中,则整个事务变得原子化;要么所有三个查询成功运行,要么一个也不成功运行。此外,重要的是操作按特定顺序执行,假设它们操作相同状态或以其他方式具有可能相互影响的副作用。隔离性 部分意味着其他操作不能在中间运行;例如,当只应用了一些操作时,不能进行读取。

原子操作在计算机领域非常重要,特别是在分布式计算方面。数据库可能有许多客户端连接,需要支持原子操作。分布式系统中,网络上的多个节点进行通信,同样需要支持原子操作。稍微推广一下,即使在单个计算机上,数据访问在多个线程之间共享时,原子性也很重要。

JavaScript 提供了一个名为Atomics的全局对象,其中包含几个静态方法。这个全局对象遵循与熟悉的Math全局对象相同的模式。在任何情况下,都不能使用new操作符创建新实例,并且这些方法是无状态的,不影响全局对象本身。而是通过传递要修改的数据的引用来使用Atomics中的方法。

此部分剩余的方法列出了Atomics对象上除了三个方法之外的所有方法。剩余的方法在“协调用的原子方法”中有介绍。除了Atomics.isLockFree()之外,所有这些方法都将TypedArray实例作为第一个参数,并将要操作的索引作为第二个参数。

Atomics.add()

old = Atomics.add(typedArray, index, value)

这个方法将提供的value添加到位于indextypedArray中的现有值中。返回旧值。这里是非原子版本可能看起来像这样:

const old = typedArray[index];
typedArray[index] = old + value;
return old;

Atomics.and()

old = Atomics.and(typedArray, index, value)

这个方法使用value与位于indextypedArray中的现有值执行位与操作,并返回旧值。这里是非原子版本可能看起来像这样:

const old = typedArray[index];
typedArray[index] = old & value;
return old;

Atomics.compareExchange()

old = Atomics.compareExchange(typedArray, index, oldExpectedValue, value)

这个方法检查typedArray,看看oldExpectedValue是否位于index。如果是,则用value替换该值。如果不是,则不会发生任何事情。始终返回旧值,因此您可以通过比较oldExpectedValue === old来确定交换是否成功。这里是非原子版本可能看起来像这样:

const old = typedArray[index];
if (old === oldExpectedValue) {
  typedArray[index] = value;
}
return old;

Atomics.exchange()

old = Atomics.exchange(typedArray, index, value)

这个方法将位于indextypedArray中的值设置为value。返回旧值。这里是非原子版本可能看起来像这样:

const old = typedArray[index];
typedArray[index] = value;
return old;

Atomics.isLockFree()

free = Atomics.isLockFree(size)

这个方法在size作为任何TypedArray子类的BYTES_PER_ELEMENT值(通常为 1、2、4、8)出现时返回true,否则返回false^(2)。如果返回true,那么使用Atomics方法在当前系统硬件上将非常快速。如果返回false,则应用程序可能希望使用类似“Mutex: A Basic Lock”中介绍的手动锁定机制,特别是在性能是主要关注点时。

Atomics.load()

value = Atomics.load(typedArray, index)

这个方法返回位于indextypedArray中的值。这里是非原子版本可能看起来像这样:

const old = typedArray[index];
return old;

Atomics.or()

old = Atomics.or(typedArray, index, value)

这个方法使用value与位于indextypedArray中的现有值执行位或操作。返回旧值。这里是非原子版本可能看起来像这样:

const old = typedArray[index];
typedArray[index] = old | value;
return old;

Atomics.store()

value = Atomics.store(typedArray, index, value)

这个方法将提供的value存储在位于indextypedArray中。然后返回传入的value。这里是非原子版本可能看起来像这样:

typedArray[index] = value;
return value;

Atomics.sub()

old = Atomics.sub(typedArray, index, value)

此方法从位于index位置的typedArray中的现有值中减去提供的value。返回旧值。以下是非原子版本可能的样子:

const old = typedArray[index];
typedArray[index] = old - value;
return old;

Atomics.xor()

old = Atomics.xor(typedArray, index, value)

此方法使用valuetypedArray中位于index位置的现有值执行位异或操作。返回旧值。以下是非原子版本可能的样子:

const old = typedArray[index];
typedArray[index] = old ^ value;
return old;

原子性问题

“数据操作的原子方法”中介绍的方法都保证原子执行。例如,考虑Atomics.compareExchange()方法。该方法接受一个oldExpectedValue和一个新的value,仅当现有值等于oldExpectedValue时用新的value替换它。虽然用 JavaScript 表示这个操作需要多个单独的语句,但是保证整个操作总是完全执行。

为了说明这一点,想象一下你有一个名为typedArrayUint8Array,第 0 个元素设置为 7。然后,想象多个线程都可以访问同一个typedArray,并且每个线程执行以下代码的某个变体:

let old1 = Atomics.compareExchange(typedArray, 0, 7, 1); // Thread #1
let old2 = Atomics.compareExchange(typedArray, 0, 7, 2); // Thread #2

完全不确定这三种方法的调用顺序,甚至它们的调用时间。实际上,它们可能同时被调用!然而,通过Atomics对象的原子性保证,确保只有一个线程会得到初始值7的返回,而另一个线程将得到更新后的值12的返回。这些操作的时间线可以在图 4-2 中看到,其中CEX(oldExpectedValue, value)Atomics.compareExchange()的简写。

多次调用 Atomics.compareExchange()是原子的。

图 4-2. Atomics.compareExchange()的原子形式

另一方面,如果使用类似compareExchange()的非原子等效方法,比如直接读取和写入typedArray[0],则完全可能程序会意外破坏一个值。在这种情况下,两个线程几乎同时读取现有值,然后都看到原始值存在,然后它们几乎同时写入。以下是compareExchange()非原子版本的注释版本:

const old = typedArray[0]; // GET()
if (old === oldExpectedValue) {
  typedArray[0] = value;   // SET(value)
}

此代码与共享数据进行多次交互,特别是在检索数据的地方(标记为GET())和稍后设置数据的地方(标记为SET(value))。为了使此代码正常工作,需要确保其他线程在代码运行时无法读取或写入该值。这种保证只允许一个线程独占共享资源的情况称为临界区

图 4-3 展示了在没有独占访问保证的情况下,此代码可能如何运行的时间线。

非原子调用会导致数据丢失。

图 4-3. Atomics.compareExchange() 的非原子形式

在这种情况下,两个线程都认为它们已成功设置了值,但期望的结果仅对第二个线程持续存在。这种错误类型被称为竞争条件,即两个或更多线程竞争执行某些操作。^(3) 这类错误最糟糕的地方在于,它们不会一致发生,极难复现,并且可能仅在一个环境(例如生产服务器)中发生,而在另一个环境(如开发笔记本电脑)中却不会发生。

当与数组缓冲区交互时,如果希望利用Atomics对象的原子属性,则在混合使用Atomics调用和直接数组缓冲区访问时需要小心。如果应用程序的一个线程使用compareExchange()方法,而另一个线程直接读取和写入同一缓冲区位置,则安全机制将被打破,您的应用程序将具有非确定性行为。基本上,使用Atomics调用时,存在一个隐含的锁定机制,以使交互变得方便。

遗憾的是,并非所有需要使用Atomics方法执行的操作都可以表示。当这种情况发生时,您需要设计更多手动锁定机制,允许您自由读写并阻止其他线程这样做。稍后的 “Mutex: A Basic Lock” 将介绍这个概念。

数据序列化

缓冲区是非常强大的工具。尽管如此,从完全数值角度处理它们可能开始变得有些困难。有时,您需要使用缓冲区存储表示非数值数据的内容。当这种情况发生时,您需要以某种方式对数据进行序列化,然后在从缓冲区读取时进行反序列化。

根据您希望表示的数据类型,将有不同的工具可供您用来序列化它。一些工具适用于不同的情况,但每种工具在存储大小和序列化性能方面都有不同的权衡。

布尔值

布尔值易于表示,因为它们只需一个位来存储数据,而位数小于一个字节。因此,您可以创建最小的视图之一,例如Uint8Array,然后将其指向一个字节长度为 1 的ArrayBuffer,然后设置它。当然,这里有趣的是,您可以使用单个字节存储多达八个这些布尔值。实际上,如果您处理大量布尔值,通过将它们存储在缓冲区中,您可能会超越 JavaScript 引擎,因为每个布尔值实例都有额外的元数据开销。图 4-4 显示了以字节表示的布尔值列表。

位从右到左排序

图 4-4. 存储在字节中的布尔值

当像这样存储数据在单独的位中时,最好从最低有效位开始,例如,最右侧标记为 0 的位,然后如果您发现需要将更多的布尔值添加到存储它们的字节中,则转移到更重要的位。这样做的原因很简单:随着您需要存储的布尔值数量的增加,缓冲区的大小也会增加,而现有的位位置应该保持正确。虽然缓冲区本身无法动态增长,但您的应用程序的新版本可能需要实例化更大的缓冲区。

如果存储布尔值的缓冲区今天是 1 字节,明天是 2 字节,通过首先使用最低有效位,数据的十进制表示将始终是 0 或 1。然而,如果使用最高有效位,则今天的值可能是 0 和 128,而明天可能是 32,768 和 0。如果你在版本之间持久化这些值并在它们之间使用,可能会引发问题。

以下是如何存储和检索这些布尔值的示例,以便它们在 ArrayBuffer 中得到支持:

const buffer = new ArrayBuffer(1);
const view = new Uint8Array(buffer);
function setBool(slot, value) {
  view[0] = (view[0] & ~(1 << slot)) | ((value|0) << slot);
}
function getBool(slot) {
  return !((view[0] & (1 << slot)) === 0);
}

此代码创建一个一字节缓冲区 (0b00000000 以二进制表示),然后创建一个指向该缓冲区的视图。要将最低有效位中的值设置为 true,您可以使用调用 setBool(0, true)。要将第二低有效位设置为 false,您将调用 setBool(1, false)。然后,要检索存储在第三低有效位的值,您将调用 getBool(2)

setBool() 函数的工作方式是将布尔值 value 转换为整数 (value|0false 转换为 0,true 转换为 1)。然后根据要存储的 slot,通过“左移值”来添加右侧的零位 (0b1<<0 保持 0b10b1<<1 变为 0b10,依此类推)。它还获取数字 1 并根据 slot 进行移位(如果 slot 是 3,则为 0b1000),然后反转位(使用 ~),并通过与这个新值进行 AND 操作 (&),将现有值与新移位值进行 AND 操作 (view[0] & ~(1 << slot))。最后,修改后的旧值和新移位值通过 OR 操作 (|) 进行合并,并赋值给 view[0]。基本上,它读取现有的位,替换相应的位,然后将位重新写入。

getBool() 函数的工作方式是取数字 1,根据 slot 进行移位,然后使用 & 与现有值进行比较。修改后的值(在 & 的右侧)仅包含一个 1 和七个 0。这个修改后的值与现有值之间的 AND 操作返回一个数字,表示假设位于 view[0]slot 位置的值为真。否则,它返回 0。然后检查此值是否恰好等于 0 (===0),并对其进行否定 (!)。基本上,它返回 slot 处位的值。

这段代码存在一些缺陷,并不一定适用于生产环境。例如,它不适用于处理大于单个字节的缓冲区,并且在读取或写入超过 7 的条目时会遇到未定义行为。一个适用于生产的版本会考虑存储的大小并进行边界检查,但这是留给读者的练习。

字符串

字符串并不像乍看起来那么容易编码。很容易假设字符串中的每个字符可以用单个字节表示,并且字符串的.length属性足以选择存储它的缓冲区的大小。虽然这有时可能有效,特别是对于简单的字符串,但在处理更复杂的数据时很快就会遇到错误。

这将适用于简单字符串的原因是,使用 ASCII 表示的数据确实允许单个字符适合单个字节。实际上,在 C 编程语言中,表示单个数据字节的数据存储类型被称为char

有许多方法可以使用字符串编码单个字符。使用 ASCII,整个字符范围可以用一个字节表示,但在一个拥有许多文化、语言和表情符号的世界中,绝对不可能以这种方式表示所有这些字符。相反,我们使用编码系统,其中可以使用可变数量的字节来表示单个字符。在内部,JavaScript 引擎根据情况使用各种编码格式来表示字符串,这种复杂性对我们的应用程序是隐藏的。一个可能的内部格式是 UTF-16,它使用 2 或 4 个字节来表示一个字符,甚至最多使用 14 个字节来表示某些表情符号。一个更通用的标准是 UTF-8,它使用 1 到 4 个字节的存储空间来表示每个字符,并且与 ASCII 兼容。

以下是一个示例,展示了当使用其.length属性迭代字符串并将结果值映射到Uint8Array实例时会发生什么:

// Warning: Antipattern!
function stringToArrayBuffer(str) {
  const buffer = new ArrayBuffer(str.length);
  const view = new Uint8Array(buffer);
  for (let i = 0; i < str.length; i++) {
    view[i] = str.charCodeAt(i);
  }
  return view;
}

stringToArrayBuffer('foo'); // Uint8Array(3) [ 102, 111, 111 ]
stringToArrayBuffer('€');   // Uint8Array(1) [ 172 ]

在这种情况下,存储基本字符串foo是可以的。然而,字符,实际上代表的值为 8,364,大于Uint8Array支持的最大值 255,因此被截断为 172。将该数字转换回字符会得到错误的值。

现代 JavaScript 提供了一个 API,可以直接将字符串编码和解码为ArrayBuffer实例。这个 API 由全局变量TextEncoderTextDecoder提供,它们都是构造函数,并在现代 JavaScript 环境中(包括浏览器和 Node.js)全局可用。这些 API 使用 UTF-8 编码进行编码和解码,因为它的普遍性。

这是如何使用这个 API 安全地将字符串编码为 UTF-8 编码的示例:

const enc = new TextEncoder();
enc.encode('foo'); // Uint8Array(3) [ 102, 111, 111 ]
enc.encode('€');   // Uint8Array(3) [ 226, 130, 172 ]

这里是如何解码这样的值:

const ab = new ArrayBuffer(3);
const view = new Uint8Array(ab);
view[0] = 226; view[1] = 130; view[2] = 172;
const dec = new TextDecoder();
dec.decode(view); // '€'
dec.decode(ab);   // '€'

注意,TextDecoder#decode()可以与Uint8Array视图或底层的ArrayBuffer实例一起使用。这使得在不需要先将数据包装在视图中的情况下解码可能从网络调用中获取的数据变得方便。

对象

考虑到可以使用 JSON 将对象表示为字符串,您可以选择将要在两个线程之间使用的对象序列化为 JSON 字符串,并使用相同的TextEncoder API 将该字符串写入数组缓冲区。这基本上可以通过运行以下代码来完成:

const enc = new TextEncoder();
return enc.encode(JSON.stringify(obj));

JSON 将 JavaScript 对象转换为字符串表示形式。在这种情况下,输出格式中存在许多冗余。如果您希望进一步减少有效负载的大小,可以使用像MessagePack这样的格式,它能够通过使用二进制数据表示对象元数据来进一步减少序列化对象的大小。这使得像 MessagePack 这样的工具在像电子邮件这样适合使用纯文本的情况下可能不是一个好选择,但在传递二进制缓冲区的情况下可能效果不会太差。msgpack5 npm 包是一个既适用于浏览器又适用于 Node.js 的包,用于执行此操作。

也就是说,在线程间通信时的性能权衡通常不是由传输的有效负载大小决定的,而更有可能是由于序列化和反序列化有效负载的成本。因此,通常最好在线程之间传递更简单的数据表示。即使在传递对象到线程之间时,您可能会发现结构化克隆算法与.onmessage.postMessage方法结合使用比将对象序列化并写入缓冲区更快速且更安全。

如果您发现自己构建了一个将对象序列化并反序列化并将其写入SharedArrayBuffer的应用程序,您可能需要重新考虑应用程序的某些架构。通常最好找到一种方式,使用较低级别的类型对传递的对象进行序列化,并传递这些对象。

^(1) 这种限制可能在未来会改变;请参阅“原地可调整大小和可增长的 ArrayBuffer”的提案。

^(2) 如果在罕见的硬件上运行 JavaScript,可能会导致此方法返回false,对于 1、2 或 8,则可能会返回。也就是说,对于 4,将始终返回true

^(3) 根据代码编译、排序和执行方式,可能会出现一个充满竞争的程序以一种无法通过交错步骤图解释的方式失败。当发生这种情况时,您可能会得到一个超出所有预期的值。

第五章:高级共享内存

第四章介绍了使用SharedArrayBuffer对象直接从不同线程读取和写入共享数据的方法。但这样做是有风险的,因为一个线程可能会破坏另一个线程写入的数据。然而,由于Atomics对象的存在,您可以以一种方式对数据执行非常基本的操作,以防止数据被破坏。

尽管Atomics提供的基本操作非常方便,但通常您会发现自己需要执行更复杂的数据交互。例如,一旦您按照“数据序列化”中描述的方法对数据进行了序列化,如 1 千字节字符串,然后需要将该数据写入SharedArrayBuffer实例中,而现有的Atomics方法均不能一次性设置整个值。

本章介绍了用于协调跨线程共享数据的额外功能,适用于先前讨论的Atomics方法不足以满足需求的情况。

协调的原子方法

这些方法与之前讨论过的方法有些不同,之前讨论的方法可以使用任何类型的TypedArray,并且可以操作SharedArrayBufferArrayBuffer实例。然而,这里列出的方法只适用于Int32ArrayBigInt64Array实例,并且只在与SharedArrayBuffer实例一起使用时才有意义。

如果您尝试在错误类型的TypedArray上使用这些方法,将会收到以下错误之一:

# Firefox v88
Uncaught TypeError: invalid array type for the operation

# Chrome v90 / Node.js v16
Uncaught TypeError: [object Int8Array] is not an int32 or BigInt64 typed array.

至于先前的技术,这些方法是基于 Linux 内核中称为futex的功能设计的。Futexfast userspace mutex的缩写。Mutex本身是mutual exclusion的缩写,即单个执行线程对特定数据的独占访问。Mutex 也可以称为lock,其中一个线程锁定数据访问,执行操作,然后解锁访问,允许另一个线程接触数据。Futex 建立在两种基本操作上,一种是“等待”,另一种是“唤醒”。

Atomics.wait()

status = Atomics.wait(typedArray, index, value, timeout = Infinity)

此方法首先检查typedArray中索引为index的值是否等于value。如果不等,则函数返回值为not-equal。如果相等,则会冻结线程最多timeout毫秒。如果在此期间没有发生任何事情,则函数返回值为timed-out。另一方面,如果另一个线程在此时间段内为相同的index调用Atomics.notify(),则函数将返回值为ok。表 5-1 列出了这些返回值。

表 5-1. Atomics.wait()的返回值

含义
not-equal 提供的value与缓冲区中的值不相等。
timed-out 另一个线程在指定的timeout内没有调用Atomics.notify()
ok 另一个线程在规定时间内调用了Atomics.notify()

你可能会想为什么这种方法在前两个条件下不会抛出错误,而是悄悄地成功而不是返回ok。因为多线程编程是为了性能而使用的,因此合理推断调用这些Atomics方法将在应用程序的热路径中完成,这些路径是应用程序花费大部分时间的地方。在 JavaScript 中,实例化Error对象并生成堆栈跟踪比返回简单字符串的性能要差,因此这种方法的性能非常高。另一个原因是not-equal情况实际上并不代表错误情况,而是你正在等待的事情已经发生了。

这种阻塞行为可能一开始会有点震惊。锁定整个线程听起来有点强烈,在许多情况下确实如此。导致整个 JavaScript 线程锁定的另一个例子是浏览器中的alert()函数。调用该函数时,浏览器会显示对话框,直到对话框关闭后,没有任何背景任务(包括使用事件循环的任何后台任务)都不能运行。类似地,Atomics.wait()方法会冻结线程。

事实上,这种行为非常极端,即“主”线程——在运行 JavaScript 时默认可用的线程,在 Web Worker 之外——至少在浏览器中不允许调用此方法。原因是锁定主线程会导致非常糟糕的用户体验,因此 API 的作者甚至不想允许这样做。如果你试图在浏览器的主线程中调用此方法,你将会收到以下错误之一:

# Firefox
Uncaught TypeError: waiting is not allowed on this thread

# Chrome v90
Uncaught TypeError: Atomics.wait cannot be called in this context

另一方面,Node.js 允许在主线程中调用Atomics.wait()。由于 Node.js 没有 UI,这并不一定是一件坏事。实际上,在编写允许调用fs.readFileSync()的脚本时,这可能会很有用。

如果你是一名 JavaScript 开发人员,曾在一个有移动或桌面开发人员的公司工作过,你可能听到他们谈论“将工作从主线程转移”或“锁定主线程”。这些关注点传统上属于本机应用程序的开发人员,随着语言的进步,我们 JavaScript 工程师将会越来越多地享受到这些好处。在浏览器方面,这个问题通常被称为滚动卡顿,即在滚动时 CPU 太忙无法绘制 UI。

Atomics.notify()

awaken = Atomics.notify(typedArray, index, count = Infinity)

Atomics.notify()^(1) 方法尝试唤醒那些在相同的 typedArray 和相同 index 上调用了 Atomics.wait() 的其他线程。如果有其他线程当前处于冻结状态,它们将被唤醒。多个线程可以同时处于冻结状态,每个线程等待被通知。然后 count 值决定唤醒多少个线程。count 值默认为 Infinity,意味着每个线程都将被唤醒。然而,如果有四个线程在等待并且将值设置为三,则除了一个线程外,其他所有线程都将被唤醒。“时序和非确定性” 探讨了这些唤醒线程的顺序问题。

方法完成后的返回值是已唤醒的线程数。如果传入指向非共享 ArrayBuffer 实例的 TypedArray 实例,则始终返回 0。如果此时没有任何线程在监听,它也会返回 0。由于此方法不会阻塞线程,因此可以始终从主 JavaScript 线程中调用。

Atomics.waitAsync()

promise = Atomics.waitAsync(typedArray, index, value, timeout = Infinity)

这本质上是 Atomics 家族中 Atomics.wait() 的基于 promise 的版本。截至撰写本文时,它在 Node.js v16 和 Chrome v87 中可用,但尚未在 Firefox 或 Safari 中可用。

此方法本质上是 Atomics.wait() 的性能较差的非阻塞版本,它返回一个解析等待操作状态的 promise。由于性能下降(解析的 promise 比暂停线程并返回字符串的开销更大),它并不一定适用于 CPU 密集型算法的热路径。另一方面,在锁定更改更便于通过信号另一个线程而不是通过 postMessage() 执行消息传递操作的情况下,它可能会更有用。由于此方法不会阻塞线程,因此可以在应用程序的主线程中使用。

添加此方法的主要驱动因素之一是,使用 Emscripten 编译的代码(在 “使用 Emscripten 将 C 程序编译为 WebAssembly” 中介绍)可以利用线程而不仅限于工作线程中执行。

时序和非确定性

为了确保应用程序的正确性,通常需要它以确定性方式运行。Atomics.notify() 函数接受一个名为 count 的参数,其中包含要唤醒的线程数。在这种情况下显而易见的问题是哪些线程会被唤醒,以及它们的唤醒顺序是什么?

非确定性示例

线程按 FIFO(先进先出)顺序被唤醒,这意味着调用 Atomics.wait() 的第一个线程将第一个被唤醒,第二个调用的线程将第二个被唤醒,依此类推。然而,测量这一点可能很困难,因为来自不同工作者的日志消息不能保证以真实的执行顺序显示在终端中。理想情况下,您应该构建您的应用程序,使其继续正常工作,而不受唤醒线程的顺序影响。

想要亲自测试这个,你可以创建一个新的应用程序。首先,创建一个名为 ch5-notify-order/ 的新目录。在其中,通过使用来自 示例 5-1 的内容,创建另一个基本的 index.html 文件。

示例 5-1. ch5-notify-order/index.html
<html>
  <head>
    <title>Shared Memory for Coordination</title>
    <script src="main.js"></script>
  </head>
</html>

接下来,创建另一个 main.js 文件,其中包含来自 示例 5-2 的内容。

示例 5-2. ch5-notify-order/main.js
if (!crossOriginIsolated) throw new Error('Cannot use SharedArrayBuffer');

const buffer = new SharedArrayBuffer(4);
const view = new Int32Array(buffer);

for (let i = 0; i < 4; i++) { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  const worker = new Worker('worker.js');
  worker.postMessage({buffer, name: i});
}

setTimeout(() => {
  Atomics.notify(view, 0, 3); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
}, 500); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)

1

四个专用工作者被实例化。

2

共享缓冲器在索引 0 处被通知。

3

通知每半秒发送一次。

此文件首先创建一个 4 字节的缓冲区,这是支持所需 Int32Array 视图的最小缓冲区。接下来,它使用 for 循环创建四个不同的专用工作者。对于每个工作者,它立即调用适当的 postMessage() 调用,传递缓冲区以及线程的标识符。这导致我们关心的五个不同的线程;即主线程和我们命名为 0、1、2 和 3 的线程。

JavaScript 创建这些线程,底层引擎开始组装资源,分配内存,以及在幕后为我们做很多魔法。执行这些任务所需的时间是不确定的,这很不幸。我们无法知道,例如,完成准备工作是否总是需要 100 毫秒。事实上,这个数字将根据机器的核心数量以及代码运行时机器的繁忙程度而大幅变化。幸运的是,postMessage() 调用基本上已经为我们排队了;JavaScript 引擎会在准备好后调用工作者的 onmessage 函数。

之后,主线程完成其工作,然后使用setTimeout等待半秒钟(500 毫秒),最后调用Atomics.notify()。如果setTimeout值过低,比如 10 毫秒,会发生什么?或者甚至在同一堆栈外调用?在这种情况下,线程尚未初始化,工作者没有时间调用Atomics.wait(),调用将立即返回0。如果时间值太高会发生什么?那么应用程序可能会非常缓慢,或者Atomics.wait()使用的任何timeout值可能已超过。

在 Thomas 的笔记本电脑上,就绪阈值似乎在约 120 毫秒左右。此时一些线程已准备好,一些则没有。通常在大约 100 毫秒时,没有任何线程准备好,在 180 毫秒时,通常所有线程都准备好了。但是“通常”是我们在编程中不喜欢使用的词语。确切地知道线程准备好之前的时间很难。通常这只是在首次启动应用程序时出现的问题,而不是应用程序整个生命周期中的问题。

要完成应用程序,创建一个名为worker.js的文件,并将示例 5-3 中的内容添加到其中。

示例 5-3。ch5-notify-order/worker.js
self.onmessage = ({data: {buffer, name}}) => {
  const view = new Int32Array(buffer);
  console.log(`Worker ${name} started`);
  const result = Atomics.wait(view, 0, 0, 1000); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  console.log(`Worker ${name} awoken with ${result}`);
};

1

在缓冲区的第 0 条目上等待,假定初始值为0,最多等待 1 秒钟。

工作者接受共享缓冲区和工作者线程的名称,并存储这些值,并打印线程已初始化的消息。然后,它调用Atomics.wait(),使用缓冲区的第 0 索引。它假设缓冲区中存在初始值0(因为我们尚未修改该值)。该方法调用还使用了timeout值为一秒(1,000 毫秒)。最后,一旦方法调用完成,值将在终端上打印出来。

创建完这些文件后,切换到终端并运行另一个 Web 服务器以查看内容。同样,您可以通过运行以下命令来执行此操作:

$ npx MultithreadedJSBook/serve .

如往常一样,导航到终端中打印的 URL 并打开控制台。如果看不到任何输出,可能需要刷新页面以再次运行应用程序。表 5-2 包含测试运行的输出。

表 5-2。示例非确定性输出

记录 位置
Worker 1 已启动 worker.js:4:11
Worker 0 已启动 worker.js:4:11
Worker 3 已启动 worker.js:4:11
Worker 2 已启动 worker.js:4:11
Worker 0 因成功唤醒 worker.js:7:11
Worker 3 因成功唤醒 worker.js:7:11
Worker 1 因成功唤醒 worker.js:7:11
Worker 2 因超时唤醒 worker.js:7:11

你很可能会得到不同的输出。事实上,如果你再次刷新页面,你可能会再次得到不同的输出。或者,即使在多次运行中,你可能会得到一致的输出。但理想情况下,与“启动”消息一起打印的最后一个工作者名称也将是失败并显示“超时”消息的工作者。

这个输出可能会有些混乱。早些时候我们说过,顺序似乎是先进先出的,但这里的数字并不是从 0 到 3。原因在于顺序并不取决于线程的创建顺序(0, 1, 2, 3),而是取决于线程执行Atomics.wait()调用的顺序(在这种情况下是 1, 0, 3, 2)。即使有了这个认识,被“唤醒”的消息的顺序也很混乱(在这种情况下是 0, 3, 1, 2)。这很可能是 JavaScript 引擎中的竞态条件导致的,不同线程几乎在完全相同的时刻打印消息。

打印后,消息不会直接显示在屏幕上。如果可能的话,这些消息可能会互相覆盖,导致像素出现视觉撕裂。相反,引擎会将消息排队等待打印,并且浏览器内部的某种机制(但我们开发者看不到)决定了从队列中取出和打印消息的顺序。因此,两组消息的顺序不一定会相关联。但是唯一确定任何顺序的方法是超时消息恰好来自最后启动的线程。实际上,在这种情况下,“超时”消息总是来自最后启动的工作者。

检测线程准备就绪

这个实验引发了一个问题:一个应用程序如何确定一个线程何时完成了初始设置并准备好承担工作?

一个简单的方法是在工作者线程中从onmessage()处理程序的某个时间点开始调用postMessage()返回给父线程。这是有效的,因为一旦调用了onmessage()处理程序,工作者线程就完成了初始设置,现在正在运行 JavaScript 代码。

这里有一个快速完成这个任务的例子。首先,复制你创建的ch5-notify-order/目录,粘贴为一个新的ch5-notify-when-ready/目录。在这个目录中,index.html文件保持不变,但是两个 JavaScript 文件将被更新。首先,更新main.js以包含示例 5-4 中的内容。

示例 5-4. ch5-notify-when-ready/main.js
if (!crossOriginIsolated) throw new Error('Cannot use SharedArrayBuffer');

const buffer = new SharedArrayBuffer(4);
const view = new Int32Array(buffer);
const now = Date.now();
let count = 4;

for (let i = 0; i < 4; i++) { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  const worker = new Worker('worker.js');
  worker.postMessage({buffer, name: i}); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
  worker.onmessage = () => {
    console.log(`Ready; id=${i}, count=${--count}, time=${Date.now() - now}ms`);
    if (count === 0) { ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
      Atomics.notify(view, 0);
    }
  };
}

1

实例化四个工作者。

2

立即向工作者发送消息。

3

一旦所有四个工作者回复,就在第 0 个条目上发出通知。

脚本已经修改,以便在四个工作线程中的每个都向主线程发送消息后调用 Atomics.notify()。一旦第四个也是最后一个工作线程发送了消息,就会发送通知。这允许应用程序在准备就绪时立即发送消息,在最好的情况下可能节省数百毫秒,在最坏的情况下(例如在非常慢的单核计算机上运行代码时)则防止失败。

Atomics.notify() 调用也已更新为仅唤醒所有线程,而不仅仅是三个,并且超时已恢复为默认的 Infinity。这样做是为了显示每个线程都将及时接收到消息。

接下来,更新 worker.js 以包含来自 示例 5-5 的内容。

示例 5-5. ch5-notify-when-ready/worker.js
self.onmessage = ({data: {buffer, name}}) => {
  postMessage('ready'); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  const view = new Int32Array(buffer);
  console.log(`Worker ${name} started`);
  const result = Atomics.wait(view, 0, 0); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
  console.log(`Worker ${name} awoken with ${result}`);
};

1

向父线程发送消息以表明准备就绪。

2

等待第 0 个条目的通知。

这次 onmessage 处理程序立即调用 postMessage() 向父线程发送消息。然后不久之后发生等待调用。从技术上讲,如果父线程在 Atomics.wait() 调用之前某种方式接收到消息,则应用程序可能会崩溃。但代码依赖于消息传递比在同步 JavaScript 函数中迭代代码行要慢得多的事实。

有一点需要记住,调用 Atomics.wait() 将暂停线程。这意味着之后不能再调用 postMessage()

运行此代码时,新的日志将输出三条信息:线程名称、倒计时(始终为 3、2、1、0 的顺序)以及线程自脚本启动以来的准备时间。运行与之前相同的命令,并在浏览器中打开生成的 URL。表 5-3 包含了一些示例运行的日志输出。

表 5-3. 线程启动时间

Firefox v88 Chrome v90
T1, 86ms T0, 21ms
T0, 99ms T1, 24ms
T2, 101ms T2, 26ms
T3, 108ms T3, 29ms

在这种情况下,使用 16 核笔记本电脑,Firefox 初始化工作线程似乎要比 Chrome 慢四倍左右。此外,Firefox 给出的线程顺序比 Chrome 更随机。每次刷新页面时,Firefox 的线程顺序都会改变,但 Chrome 的顺序不会。这表明 Chrome 使用的 V8 引擎在启动新的 JavaScript 环境或实例化浏览器 API 方面比 Firefox 使用的 SpiderMonkey 引擎更加优化。

请务必在多个浏览器中测试此代码,以比较所得到的结果。另一件需要注意的事情是,初始化线程所需的速度很可能取决于计算机上可用的核心数。事实上,要通过此程序增添些乐趣,将分配给count变量和for循环的值从4改为更高的数字,然后运行代码并观察结果。将值增加到128后,两个浏览器初始化线程所花费的时间显著增加。这也会在 Chrome 上一贯地破坏线程准备的顺序。通常情况下,使用过多线程会降低性能,这在“低核心计数”中有更详细的分析。

示例应用:康威生命游戏(Conway's Game of Life)

现在我们已经看过Atomics.wait()Atomics.notify(),是时候看一个具体的例子了。我们将使用康威生命游戏,这是一个自然适合并行编程的成熟概念。这个“游戏”实际上是人口增长和衰退的模拟。这个模拟存在于一个网格中,网格中的细胞处于两种状态之一:存活或死亡。模拟是迭代进行的,每次迭代对每个细胞执行以下算法。

  1. 如果细胞死亡:

    1. 如果有 2 或 3 个邻居存活,则细胞保持存活。

    2. 如果有 0 个或 1 个邻居存活,则细胞死亡(这模拟了由于生育不足而导致的死亡)。

    3. 如果有 4 个或更多邻居存活,则细胞死亡(这模拟了由于过度生育而导致的死亡)。

  2. 如果细胞死亡:

    1. 如果恰好有 3 个邻居存活,则细胞变为存活(这模拟了繁殖)。

    2. 在任何其他情况下,细胞保持死亡状态。

当谈到“邻居存活”时,我们指的是任何距离当前细胞至多一单元的细胞,包括对角线方向,并且我们指的是当前迭代之前的状态。我们可以将这些规则简化为以下形式。

  1. 如果恰好有 3 个邻居存活,新细胞状态为存活(无论其起始状态如何)。

  2. 如果细胞存活且恰好有 2 个邻居存活,则细胞保持存活。

  3. 在所有其他情况下,新细胞状态为死亡。

对于我们的实现,我们将做出以下假设:

  • 网格是一个正方形。这是一个轻微的简化,因此少了一个维度的担忧。

  • 网格像一个环面一样环绕自身。这意味着当我们处于边缘时,需要评估超出边界的邻居细胞时,我们会看到另一端的细胞。

我们将编写我们的代码用于 Web 浏览器,因为它们为我们提供了一个便捷的画布元素来绘制生命游戏世界的状态。话虽如此,在其他具有某种图像渲染的环境中适应这个示例也是相对简单的。在 Node.js 中,甚至可以使用 ANSI 转义码向终端写入。

单线程生命游戏

首先,我们将构建一个Grid类,它将我们的生命游戏世界作为一个数组,并处理每次迭代。我们将以一种与前端无关的方式构建它,甚至在多线程示例中也可以使用它而无需进行任何更改。为了正确模拟生命游戏,我们需要一个多维数组来表示我们的单元格网格。我们可以使用数组的数组,但为了稍后简化事务,我们将其存储在一个一维数组中(实际上是Uint8Array),然后对于任何具有坐标xy的单元格,我们将其存储在数组中的位置cells[size * x + y]。我们还需要两个这样的数组,因为一个将用于当前状态,另一个用于先前状态。为了稍后更轻松地简化事务,我们将它们顺序存储在同一个ArrayBuffer中。

创建一个名为ch5-game-of-life/的目录,并将示例 5-6 的内容添加到该目录下的gol.js中。

示例 5-6. ch5-game-of-life/gol.js(第一部分)
class Grid {
  constructor(size, buffer, paint = () => {}) {
    const sizeSquared = size * size;
    this.buffer = buffer;
    this.size = size;
    this.cells = new Uint8Array(this.buffer, 0, sizeSquared);
    this.nextCells = new Uint8Array(this.buffer, sizeSquared, sizeSquared);
    this.paint = paint;
  }

这里我们用构造函数开始了Grid类。它接受一个size,这是我们正方形的宽度,一个名为bufferArrayBuffer,以及一个稍后将要使用的paint函数。然后我们将我们的cellsnextCells作为Uint8Array的实例存储在buffer中的相邻位置。

接下来,当执行迭代时,我们可以添加后面需要的单元格检索方法。将代码添加到示例 5-7 中。

示例 5-7. ch5-game-of-life/gol.js(第二部分)
  getCell(x, y) {
    const size = this.size;
    const sizeM1 = size - 1;
    x = x < 0 ? sizeM1 : x > sizeM1 ? 0 : x;
    y = y < 0 ? sizeM1 : y > sizeM1 ? 0 : y;
    return this.cells[size * x + y];
  }

要根据给定的坐标集检索单元格,我们需要对索引进行规范化。回想一下,我们说过网格是环绕的。我们在这里进行的规范化确保,如果超出范围的上下单位,我们将检索到范围另一端的单元格。

现在,我们将添加在每次迭代时运行的实际算法。将代码添加到示例 5-8 中。

示例 5-8. ch5-game-of-life/gol.js(第三部分)
  static NEIGHBORS =  ![1
    [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]
  ];

  iterate(minX, minY, maxX, maxY) { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
    const size = this.size;

    for (let x = minX; x < maxX; x++) {
      for (let y = minY; y < maxY; y++) {
        const cell = this.cells[size * x + y];
        let alive = 0;
        for (const [i, j] of Grid.NEIGHBORS) {
          alive += this.getCell(x + i, y + j);
        }
        const newCell = alive === 3 || (cell && alive === 2) ? 1 : 0;
        this.nextCells[size * x + y] = newCell;
        this.paint(newCell, x, y);
      }
    }

    const cells = this.nextCells;
    this.nextCells = this.cells;
    this.cells = cells;
  }
}

1

算法中使用的邻居坐标集用于查看八个方向上的相邻单元格。我们会将这个数组放在手边,因为每个单元格都需要使用它。

2

iterate()方法接受一个操作范围,以最小 X 和 Y 值(包含)和最大 X 和 Y 值(不包含)的形式。对于我们的单线程示例,它将始终是(0, 0, size, size),但在我们转移到多线程实现时,将在这里放置一个范围将使其更容易分割整个网格,以便每个线程可以处理的部分。

我们遍历网格中的每个单元格,并计算每个单元格周围存活的邻居数量。我们使用数字 1 表示存活的单元格,使用 0 表示死亡的单元格,因此可以通过累加来计算周围存活的邻居数量。一旦计算出来,我们就可以应用简化的生命游戏算法。我们将新的单元格状态存储在 nextCells 数组中,并将新的单元格状态和坐标提供给 paint 回调函数以进行可视化。然后我们交换 cellsnextCells 数组,以便在后续迭代中使用。这样,在每次迭代中,cells 始终代表前一次迭代的结果,而 newCells 始终代表当前迭代的结果。

到目前为止的所有代码将与我们的多线程实现共享。完成 Grid 类后,我们现在可以继续创建和初始化一个 Grid 实例,并将其与我们的用户界面绑定。添加来自 示例 5-9 的代码。

示例 5-9. ch5-game-of-life/gol.js(第四部分)
const BLACK = 0xFF000000; ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
const WHITE = 0xFFFFFFFF;
const SIZE = 1000;

const iterationCounter = document.getElementById('iteration'); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
const gridCanvas = document.getElementById('gridcanvas');
gridCanvas.height = SIZE;
gridCanvas.width = SIZE;
const ctx = gridCanvas.getContext('2d');
const data = ctx.createImageData(SIZE, SIZE); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
const buf = new Uint32Array(data.data.buffer);

function paint(cell, x, y) { ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/4.png)
  buf[SIZE * x + y] = cell ? BLACK : WHITE;
}

const grid = new Grid(SIZE, new ArrayBuffer(2 * SIZE * SIZE), paint); ![5](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/5.png)
for (let x = 0; x < SIZE; x++) { ![6](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/6.png)
  for (let y = 0; y < SIZE; y++) {
    const cell = Math.random() < 0.5 ? 0 : 1;
    grid.cells[SIZE * x + y] = cell;
    paint(cell, x, y);
  }
}

ctx.putImageData(data, 0, 0); ![7](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/7.png)

1

我们为将绘制到屏幕上的黑白像素分配一些常量,并设置我们使用的网格的大小(实际上是宽度)。可以随意调整大小,以查看不同规模下生命游戏的表现。

2

我们从 HTML 中获取迭代计数器和画布元素(稍后将进行编写)。我们将设置画布的宽度和高度为 SIZE,并从中获取一个 2D 上下文来进行操作。

3

我们将使用一个 ImageData 实例直接修改画布上的像素,通过 Uint32Array

4

这个 paint() 函数将用于初始化网格和在每次迭代中修改支持 ImageData 实例的缓冲区。如果单元格是活着的,它将绘制为黑色;否则,绘制为白色。

5

现在我们创建网格实例,传入大小、足够大以容纳 cellsnextCellsArrayBuffer,以及我们的 paint() 函数。

6

为了初始化网格,我们将遍历所有单元格,并为每个分配一个随机的生死状态。同时,我们将结果传递给我们的 paint() 函数,以确保图像得到更新。

7

每当修改 ImageData 后,我们都需要将其添加回画布中,所以我们现在在这里执行初始化完成后的操作。

最后,我们准备开始运行迭代。添加来自 示例 5-10 的代码。

示例 5-10. ch5-game-of-life/gol.js(第五部分)
let iteration = 0;
function iterate(...args) {
  grid.iterate(...args);
  ctx.putImageData(data, 0, 0);
  iterationCounter.innerHTML = ++iteration;
  window.requestAnimationFrame(() => iterate(...args));
}

iterate(0, 0, SIZE, SIZE);

每次迭代,我们都会调用 grid.iterate() 方法,根据需要修改单元格。请注意,它为每个单元格调用 paint() 函数,一旦发生这种情况,我们的图像数据就已经设置好了,所以我们只需用 putImageData() 将其添加到画布上下文中。然后,我们将在页面上更新迭代计数器,并安排在 requestAnimationFrame() 回调中进行另一次迭代。最后,我们通过初始调用 iterate() 来启动一切。

我们已经完成了 JavaScript 的部分,但现在我们需要支持 HTML。幸运的是,这很简短。将 Example 5-11 的内容添加到同一目录下名为 gol.html 的文件中,然后在浏览器中打开该文件。

Example 5-11. ch5-game-of-life/gol.html
<h3>Iteration: <span id="iteration">0</span></h3>
<canvas id="gridcanvas"></canvas>
<script src="gol.js"></script>

现在,您应该能够看到一个 1,000 x 1,000 的图像,显示康威生命游戏,尽可能快地进行迭代。它应该看起来类似于 Figure 5-1。

根据您的计算机性能,您可能会发现它稍微有些延迟,而不是清晰流畅。在所有这些单元格上进行迭代并对其进行计算需要大量的计算能力。为了加快速度,让我们利用您机器上更多的 CPU 核心,使用 Web Worker 线程。

mtjs 0501

Figure 5-1. 290 次迭代后的康威生命游戏

多线程生命游戏

对于我们的多线程生命游戏实现版本,我们可以重用大部分代码。特别是 HTML 不会更改,Grid 类也不会更改。我们将设置一些工作线程和一个额外的协调线程来修改图像数据。我们需要额外的线程,因为我们不能在主浏览器线程上使用 Atomics.wait()。我们将使用 SharedArrayBuffer,而不是单线程示例中使用的常规 ArrayBuffer。为了协调线程,我们需要 8 字节来进行协调,具体来说是每个方向 4 个字节,因为 Atomics.wait() 至少需要一个 Int32Array。由于我们的协调线程还将生成图像数据,因此我们还需要足够的共享内存来保存这些数据。对于边长为 SIZE 的网格,这意味着一个 SharedArrayBuffer,其内存布局如 Table 5-4 所示。

Table 5-4. 四个工作线程的内存布局

Purpose # of Bytes
Cells (or next cells) SIZE * SIZE
Cells (or next cells) SIZE * SIZE
Image data 4 * SIZE * SIZE
Worker thread wait 4
Coordination thread wait 4

要开始,请将上一个示例中的 .html.js 文件复制到名为 thread-gol.htmlthread-gol.js 的新文件中。分别编辑 thread-gol.html,引用这个新的 JavaScript 文件。

删除 Grid 类定义之后的所有内容。接下来,我们将设置一些常量。在 thread-gol.js 中添加 Example 5-12。

示例 5-12。ch5-game-of-life/thread-gol.js(第一部分)
const BLACK = 0xFF000000;
const WHITE = 0xFFFFFFFF;
const SIZE = 1000;
const THREADS = 5; // must be a divisor of SIZE

const imageOffset = 2 * SIZE * SIZE
const syncOffset = imageOffset + 4 * SIZE * SIZE;

const isMainThread = !!self.window;

BLACKWHITESIZE常量与单线程示例中的目的相同。我们将这个THREADS常量设置为SIZE的任何可以整除的数字,它将代表我们为执行生命游戏计算而生成的工作线程数。我们将网格划分为每个线程可以处理的块。可以随意调整THREADSSIZE变量,只要THREADS能够整除SIZE。我们需要处理图像数据和同步字节存储位置的偏移量,因此在此处处理它们。最后,我们将使用相同的文件在主线程和任何工作线程上运行,因此我们需要一种方法来知道我们当前是否在主线程上。

接下来,我们将开始编写主线程的代码。添加示例 5-13 的内容。

示例 5-13。ch5-game-of-life/thread-gol.js(第二部分)
if (isMainThread) {
  const gridCanvas = document.getElementById('gridcanvas');
  gridCanvas.height = SIZE;
  gridCanvas.width = SIZE;
  const ctx = gridCanvas.getContext('2d');
  const iterationCounter = document.getElementById('iteration');

  const sharedMemory = new SharedArrayBuffer( ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
    syncOffset + // data + imageData
    THREADS * 4 // synchronization
  );
  const imageData = new ImageData(SIZE, SIZE);
  const cells = new Uint8Array(sharedMemory, 0, imageOffset);
  const sharedImageBuf = new Uint32Array(sharedMemory, imageOffset);
  const sharedImageBuf8 =
    new Uint8ClampedArray(sharedMemory, imageOffset, 4 * SIZE * SIZE);

  for (let x = 0; x < SIZE; x++) {
    for (let y = 0; y < SIZE; y++) {
      // 50% chance of cell being alive
      const cell = Math.random() < 0.5 ? 0 : 1;
      cells[SIZE * x + y] = cell;
      sharedImageBuf[SIZE * x + y] = cell ? BLACK : WHITE;
    }
  }

  imageData.data.set(sharedImageBuf8);
  ctx.putImageData(imageData, 0, 0);

1

SharedArrayBuffer的结束比syncOffset晚了 16 个字节,因为我们需要为每个线程的同步分配 4 个字节。

这部分的第一部分与单线程示例中的大致相同。我们只是获取 DOM 元素并设置网格大小。接下来,我们设置了SharedArrayBuffer,我们称之为sharedMemory,并为其cells(我们很快将为其分配值)放置了视图,并获取了图像数据。我们将使用Uint32ArrayUint8ClampedArray两种方式处理图像数据,用于修改和分配到ImageData实例。

然后,我们将随机初始化网格,并同时相应地修改图像数据并将该图像数据填充到画布上下文中。这为网格设置了初始状态。此时,我们可以开始生成工作线程。添加示例 5-14 的内容。

示例 5-14。ch5-game-of-life/thread-gol.js(第三部分)
  const chunkSize = SIZE / THREADS;
  for (let i = 0; i < THREADS; i++) {
    const worker = new Worker('thread-gol.js', { name: `gol-worker-${i}` });
    worker.postMessage({
      range: [0, chunkSize * i, SIZE, chunkSize * (i + 1)],
      sharedMemory,
      i
    });
  }

  const coordWorker = new Worker('thread-gol.js', { name: 'gol-coordination' });
  coordWorker.postMessage({ coord: true, sharedMemory });

  let iteration = 0;
  coordWorker.addEventListener('message', () => {
    imageData.data.set(sharedImageBuf8);
    ctx.putImageData(imageData, 0, 0);
    iterationCounter.innerHTML = ++iteration;
    window.requestAnimationFrame(() => coordWorker.postMessage({}));
  });

我们使用循环设置了一些工作线程。对于每个线程,我们为调试目的给它分配了一个唯一的名称,向其发送了一条消息,告诉它我们希望它操作网格的哪个范围(即边界minXminYmaxXmaxY),并发送了sharedMemory。然后,我们添加了一个协调工作线程,传递了sharedMemory,并通过消息告知它是协调工作线程。

从主浏览器线程开始,我们只与这个协调工作线程交互。我们将设置它,使其在接收到消息后每次都会发出一条消息,但仅在从SharedMemory获取图像数据,进行适当的 UI 更新并请求动画帧之后才这样做。

代码的其余部分在其他线程中运行。添加示例 5-15 的内容。

示例 5-15。ch5-game-of-life/thread-gol.js(第四部分)
} else {
  let sharedMemory;
  let sync;
  let sharedImageBuf;
  let cells;
  let nextCells;

  self.addEventListener('message', initListener);

  function initListener(msg) {
    const opts = msg.data;
    sharedMemory = opts.sharedMemory;
    sync = new Int32Array(sharedMemory, syncOffset);
    self.removeEventListener('message', initListener);
    if (opts.coord) {
      self.addEventListener('message', runCoord);
      cells = new Uint8Array(sharedMemory);
      nextCells = new Uint8Array(sharedMemory, SIZE * SIZE);
      sharedImageBuf = new Uint32Array(sharedMemory, imageOffset);
      runCoord();
    } else {
      runWorker(opts);
    }
  }

我们现在在 isMainThread 条件的另一侧,因此我们知道我们在工作线程或协调线程中。在这里,我们声明一些变量,然后向 message 事件添加一个初始监听器。无论这是协调线程还是工作线程,我们都需要填充 sharedMemorysync 变量,因此我们在监听器中进行分配。然后我们移除初始化监听器,因为我们不再需要它。工作线程根本不依赖消息传递,协调线程将有一个不同的监听器,稍后我们将看到。

如果我们初始化了协调线程,我们将添加一个新的 message 监听器;一个稍后我们将定义的 runCoord 函数。然后我们将获取 cellsnextCells 的引用,因为我们需要将协调线程中正在进行的 Grid 实例与工作线程中的不同步。由于我们在协调线程上生成图像,所以我们也需要它。然后我们运行 runCoord 的第一次迭代。如果我们初始化了工作线程,我们只需将包含操作范围的选项传递给 runWorker()

现在让我们定义 runWorker()。现在就添加 Example 5-16 的内容。

示例 5-16. ch5-game-of-life/thread-gol.js(第五部分)
  function runWorker({ range, i }) {
    const grid = new Grid(SIZE, sharedMemory);
    while (true) {
      Atomics.wait(sync, i, 0);
      grid.iterate(...range);
      Atomics.store(sync, i, 0);
      Atomics.notify(sync, i);
    }
  }

工作线程是唯一需要 Grid 类实例的线程,所以我们首先实例化它,并将 sharedMemory 作为后备缓冲传递进去。这样做的原因是因为我们决定 sharedMemory 的第一部分将是 cellsnextCells,就像单线程示例中一样。

然后我们启动一个无限循环。循环执行以下操作:

  1. sync 数组的第 i 个元素上执行 Atomics.wait()。在协调线程中,我们将执行适当的 Atomics.notify() 来允许这一过程继续。我们在这里等待协调线程,因为否则我们可能在其他线程准备好并且数据已经传递到主浏览器线程之前开始改变数据并交换对 cellsnextCells 的引用。

    然后在 Grid 实例上执行迭代。请记住,我们仅在协调线程通过 range 属性指定的范围上操作。

  2. 完成后,通知主线程已完成此任务。通过使用 Atomics.store()sync 数组的第 i 个元素设置为 1,然后通过 Atomics.notify() 唤醒等待的线程。我们使用从 0 状态转移作为应该执行一些工作的指示器,并在转移回 0 状态时通知我们已完成工作。

我们正在使用Atomics.wait()来阻止协调线程在工作线程修改数据时执行,然后使用Atomics.wait()来阻止工作线程在协调线程执行其工作时执行。在两端,我们使用Atomics.notify()唤醒另一个线程,并立即进入等待状态,等待另一个线程再次通知。因为我们使用原子操作来修改数据并控制何时修改它,所以我们知道所有的数据访问都是顺序一致的。在跨线程的交错程序流中,死锁不会发生,因为我们总是在协调线程和工作线程之间来回切换执行。工作线程永远不会在内存的相同部分执行,因此我们不必单独从工作线程的角度担心这个概念。

工作线程可以无限地运行。我们不必担心那个无限循环,因为它只会在Atomics.wait()返回时继续进行,而这需要另一个线程为相同的数组元素调用Atomics.notify()

让我们通过runCoord()函数来结束这里的代码,该函数是在主浏览器线程初始化消息后通过消息触发的。添加示例 5-17 的内容。

示例 5-17. ch5-game-of-life/thread-gol.js(第六部分)
  function runCoord() {
    for (let i = 0; i < THREADS; i++) {
      Atomics.store(sync, i, 1);
      Atomics.notify(sync, i);
    }
    for (let i = 0; i < THREADS; i++) {
      Atomics.wait(sync, i, 1);
    }
    const oldCells = cells;
    cells = nextCells;
    nextCells = oldCells;
    for (let x = 0; x < SIZE; x++) {
      for (let y = 0; y < SIZE; y++) {
        sharedImageBuf[SIZE * x + y] = cells[SIZE * x + y] ? BLACK : WHITE;
      }
    }
    self.postMessage({});
  }
}

这里发生的第一件事是协调线程通过每个工作线程的sync数组的第i个元素通知工作线程,唤醒它们执行迭代。当它们完成时,它们将通过sync数组的相同元素进行通知,所以我们将在这些元素上等待。每个对Atomics.wait()的调用阻塞线程执行的事实正是我们需要这个协调线程的原因,而不只是在主浏览器线程上执行所有操作。

接下来,我们交换cellsnextCells的引用。工作线程已经在其iterate()方法中为自己完成了这一点,所以我们需要在这里跟随。然后,我们准备遍历所有cells并将它们的值转换为图像数据中的像素。最后,我们向主浏览器线程发送一条消息,表明数据已准备好在 UI 中显示。协调线程在接收到下一条消息之前没有任何操作,此时再次运行runCoord。该方法完成了示例 5-14 中开始的概念循环。

现在我们完成了!要查看 HTML 文件,请记住,为了使用SharedArrayBuffer,我们需要运行一个设置了特定头文件的服务器。要做到这一点,请在您的ch5-game-of-life目录中运行以下内容:

$ npx MultithreadedJSBook/serve .

然后,在提供的 URL 后面添加/thread-gol.html,即可看到我们实现的康威生命游戏的多线程实现运行情况。由于我们没有改变任何 UI 代码,所以它看起来应该与图 5-1 中的单线程示例完全相同。你唯一能看到的区别应该在性能上。迭代之间的过渡可能会显得更加流畅和快速。你没有幻觉!我们已经将计算细胞状态和绘制像素的工作移动到单独的线程中,因此主线程现在可以更流畅地进行动画化,并且由于我们并行使用更多 CPU 核心来进行工作,迭代速度更快。

最重要的是,我们通过仅使用Atomics.notify()来避免大部分线程协调的消息传递开销,让其他线程知道它们可以在使用Atomics.wait()暂停之后继续执行。

Atomics 和事件

JavaScript 的核心在于事件循环,它允许语言创建新的调用栈并处理事件。它一直存在,我们 JavaScript 工程师一直依赖它。无论是运行在浏览器中的 JavaScript,例如在 DOM 中监听点击事件的 jQuery,还是运行在服务器上的 JavaScript,例如等待建立传入 TCP 连接的 Fastify 服务器,这都是真实存在的。

新来的新宠:Atomics.wait()和共享内存。这种模式现在允许应用程序停止执行 JavaScript,从而完全停止事件循环。因此,你不能简单地开始在你的应用程序中随意调用多线程使用的调用,并期望它能够在没有问题的情况下工作。相反,必须遵循某些限制,以使应用程序表现良好。

当涉及到浏览器时,存在这样一种限制:应用程序的主线程不应调用Atomics.wait()。虽然在简单的 Node.js 脚本中可以这样做,但在更大的应用程序中,你确实应该避免这样做。例如,如果你的主 Node.js 线程正在处理传入的 HTTP 请求,或者有一个处理接收操作系统信号的处理程序,当事件循环开始等待操作时会发生什么?示例 5-18 就是这样一个程序的示例。

示例 5-18. ch5-node-block/main.js
#!/usr/bin/env node 
const http = require('http');

const view = new Int32Array(new SharedArrayBuffer(4));
setInterval(() => Atomics.wait(view, 0, 0, 1900), 2000); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)

const server = http.createServer((req, res) => {
  res.end('Hello World');
});

server.listen(1337, (err, addr) => {
  if (err) throw err;
  console.log('http://localhost:1337/');
});

1

每 2 秒应用程序暂停 1.9 秒

如果你感兴趣,可以为此文件创建一个目录,并通过运行以下命令来执行服务器:

$ node main.js

一旦程序运行起来,在终端中多次执行以下命令,每次调用之间等待随机时间:

$ time curl http://localhost:1337

该应用程序首先创建 HTTP 服务器并监听请求。然后,每两秒钟调用一次Atomics.wait()。它被配置为应用程序冻结 1.9 秒,以夸大长暂停的效果。你正在运行的curl命令前面带有time命令,显示以下命令运行所需的时间。你的输出将在 0 到 1.9 秒之间随机变化,这对于 Web 请求来说是巨大的暂停时间。即使将该超时值减小到接近 0,你仍会遇到全局影响所有传入请求的微小卡顿。如果 Web 浏览器允许在主线程中调用Atomics.wait(),那么你今天访问的网站肯定会遇到微小卡顿。

另一个问题仍然存在:应用程序生成的每个额外线程都应该施加什么样的限制,考虑到每个线程都有自己的事件循环?

我们建议事先确定每个生成线程的主要目的。每个线程要么成为一个 CPU 密集型线程,大量使用Atomics调用,要么成为一个事件密集型线程,最小限度使用Atomics调用。采用这样的方法,你可能会有一个真正的工作线程,不断执行复杂的计算并将结果写入共享数组缓冲区。你也会有你的主线程,主要通过消息传递进行通信,并进行基于事件循环的工作。因此,拥有简单的中介线程调用Atomics.wait()等待另一个线程完成工作,然后调用postMessage()发送生成的数据回主线程,以便在更高层次处理结果,这样做可能是有意义的。

总结本节中的概念:

  • 不要在主线程中使用Atomics.wait()

  • 指定哪些线程是 CPU 密集型的,并且大量使用Atomics调用,哪些线程是事件驱动的。

  • 考虑在适当的地方使用简单的“桥接”线程来等待和发布消息。

这些是你在设计应用程序时可以遵循的一些非常高级的指南。但有时候一些更具体的模式确实有助于更好地理解。第六章包含一些这样的模式,你可能会发现有益。

^(1) Atomics.notify()最初被称为Atomics.wake(),就像它的 Linux futex 等效物一样,但后来改名以防止“wake”和“wait”方法之间的视觉混淆。

第六章:多线程模式

JavaScript API 本身在提供功能方面确实非常基础。正如你在第四章看到的那样,SharedArrayBuffer的目的是存储数据的原始二进制表示。甚至第五章继续使用Atomics对象,暴露出一些用于协调或逐个修改少量字节的相对原始的方法。

只看这些抽象和低级别的 API 可能会让人难以看清全局,或者这些 API 真正可以用于什么。诚然,将这些概念转化为对应用程序真正有用的东西是困难的。这就是本章的目的所在。

本章包含在应用程序内部实现多线程功能的流行设计模式。这些设计模式灵感来自过去,每一个都在 JavaScript 发明之前就存在。尽管它们的工作示例可能以多种形式存在,比如 C++教科书,但将它们转换用于 JavaScript 并非总是直截了当的。

通过研究这些模式,你将更好地理解你开发的应用程序如何从多线程中受益。

线程池

线程池是一个非常流行的模式,在某种形式上几乎用于大多数多线程应用程序中。本质上,线程池是一组同质的工作线程,每个线程都能执行应用程序可能依赖的 CPU 密集型任务。这与你到目前为止通常使用的单个工作线程或有限数量工作线程的方法有些不同。例如,Node.js 依赖的libuv库提供了一个线程池,默认为四个线程,用于执行低级别的 I/O 操作。

这种模式可能与您过去使用过的分布式系统相似。例如,在容器编排平台上,通常会有一组可以运行应用程序容器的机器。在这样的系统中,每台机器可能具有不同的能力,例如运行不同的操作系统或具有不同的内存和 CPU 资源。当发生这种情况时,编排器可能会根据资源和应用程序为每台机器分配点数,然后消耗这些点数。另一方面,线程池要简单得多,因为每个工作线程都能执行相同的工作,每个线程都和其他线程一样能干,因为它们都在同一台机器上运行。

创建线程池时的第一个问题是池中应有多少线程?

池大小

从本质上讲,有两种类型的程序:那些在后台运行的程序,如系统守护进程,理想情况下不应消耗太多资源,以及那些运行在前台的程序,任何给定用户更有可能意识到的,如桌面应用程序或 Web 服务器。浏览器应用程序通常被限制为前台应用程序运行,而 Node.js 应用程序可以自由地在后台运行——尽管 Node.js 最常用于构建服务器,通常作为容器内唯一的进程。无论哪种情况,JavaScript 应用程序的意图通常是在特定时间点成为主要关注的焦点,并且为实现程序目的所需的任何计算应尽可能快地执行。

为了尽快执行指令,将它们分解并并行运行是有意义的。为了最大化 CPU 使用率,理应尽可能均匀地使用给定 CPU 中的每个核心。因此,机器上可用的 CPU 核心数量应成为决定应用程序应使用的线程(又称工作者)数量的因素。

通常情况下,线程池的大小在应用程序的整个生命周期中不需要动态变化。通常选择工作线程数是有原因的,而且这个原因通常不会改变。这就是为什么在应用程序启动时会使用一个固定大小的线程池。

下面是获取当前运行的 JavaScript 应用程序可用线程数的成语化方法,具体取决于代码是在浏览器中运行还是在 Node.js 进程中运行:

// browser
cores = navigator.hardwareConcurrency;

// Node.js
cores = require('os').cpus().length;

需要记住的一件事是,大多数操作系统中线程与 CPU 核心之间没有直接的对应关系。例如,在具有四个核心的 CPU 上运行一个具有四个线程的应用程序时,并不是第一个核心总是处理第一个线程,第二个核心处理第二个线程,依此类推。相反,操作系统会不断地移动任务,偶尔中断正在运行的程序以处理另一个应用程序的工作。在现代操作系统中,通常有数百个后台进程需要偶尔进行检查。这通常意味着单个 CPU 核心将处理多个线程的工作。

每次 CPU 核心在程序或程序的线程之间切换焦点时,都会产生一些小的上下文切换开销。因此,与 CPU 核心数量相比有太多线程可能会导致性能损失。不断的上下文切换实际上会使应用程序变慢,因此应用程序应尽量减少请求操作系统注意的线程数量。然而,线程太少可能意味着应用程序执行任务的时间太长,从而导致用户体验不佳或浪费硬件资源。

还要记住的一件事是,如果一个应用程序创建了一个有四个工作线程的线程池,那么该应用程序使用的线程的最小数量是五,因为应用程序的主线程也参与其中。还有需要考虑的后台线程,如libuv线程池,如果 JavaScript 引擎使用垃圾回收线程,用于渲染浏览器界面的线程等等。所有这些都会影响应用程序的性能。

提示

应用程序本身的特性也会影响线程池的理想大小。你是在编写一个加密货币挖矿程序,在每个线程中 99.9%的工作都在进行,几乎没有 I/O,主线程也没有工作吗?在这种情况下,使用可用核心数作为线程池的大小可能是可以接受的。或者你正在编写一个视频流和转码服务,需要大量 CPU 和 I/O 吗?在这种情况下,你可能想要使用可用核心数减去两个。你需要对你的应用程序进行基准测试,找到最佳数量,但一个合理的起点可能是使用可用核心数减去一个,然后根据需要进行调整。

一旦确定了要使用的线程数量,就可以确定如何将工作分派给工作线程了。

调度策略

因为线程池的目标是最大化并行处理的工作量,因此理所当然地,没有一个单独的工作线程应该承担太多的工作量,也不应该有线程闲置而没有工作可做。一个天真的方法可能是只是收集待完成的任务,然后一旦待执行任务的数量达到工作线程的数量,就将它们传递进去,并在它们全部完成后继续。然而,并不保证每个任务完成所需的时间相同。有些可能非常快,花费毫秒级的时间,而其他可能很慢,需要几秒甚至更长时间。因此,必须构建一个更加健壮的解决方案。

应用程序通常会采用几种策略来将任务分派给工作线程池中的工作线程。这些策略与反向代理用于将请求发送到后端服务的策略类似。以下是最常见的几种策略的列表:

循环调度

每个任务被分配给池中的下一个工作线程,一旦到达末尾,就会重新从开头开始。因此,对于一个大小为三的线程池,第一个任务分配给工作线程 1,然后是工作线程 2,然后是工作线程 3,然后回到工作线程 1,依此类推。这样做的好处是每个线程获得完全相同数量的任务来执行,但缺点是如果每个任务的复杂性是线程数的倍数(例如每六个任务中的一个需要很长时间才能完成),那么工作分配就不公平。HAProxy 反向代理将此称为roundrobin

随机

每个任务都分配给池中的一个随机工作线程。尽管这是最简单的建立方式,完全无状态,但也可能意味着有些工作线程有时会被分配太多的工作,而其他工作线程有时则会被分配太少的工作。

最空闲

维护每个工作线程执行任务的计数,并在新任务到来时将其分配给最空闲的工作线程。甚至可以推广到每个工作线程一次只执行一个任务。当两个工作线程的工作量相同时,可以随机选择一个。这可能是最健壮的方法,特别是如果每个任务消耗的 CPU 量相同,但实现起来需要更多的努力。如果一些任务使用较少的资源,例如调用setTimeout(),则可能会导致工作线程工作负载的偏差。HAProxy 将其称为leastconn

反向代理使用的其他策略可能具有不明显的实现方式,你也可以在你的应用程序中实现。例如,HAProxy 具有称为source的负载均衡策略,它使用客户端 IP 地址的哈希来一致地将请求路由到单个后端。在工作线程维护数据和路由相关任务的内存缓存时,将路由到同一工作线程可能会导致更多的缓存命中,但这种方法稍微难以泛化。

提示

根据应用程序的性质,你可能会发现其中一种策略比其他策略具有更好的性能。再次强调,针对特定应用程序的性能测量是非常重要的。

示例实现

此示例重新利用了ch2-patterns/中你在“将所有内容整合在一起”中创建的现有文件,但为了简洁起见,已经删除了大部分错误处理,并使代码与 Node.js 兼容。在此部分创建一个名为ch6-thread-pool/的新目录,用于存放你将在其中创建的文件。

首先要创建的文件是main.js。这是应用程序的入口点。之前版本的代码只是使用了一个Promise.allSettled()调用来向池中添加任务,但这并不是很有趣,因为它同时添加了所有任务。相反,该应用程序公开了一个 Web 服务器,每个请求都会为线程池创建一个新任务。通过这种方式,之前的任务可能在查询池时已经完成,这样会产生更有趣的模式,就像一个真实的应用程序一样。

将示例 6-1 的内容添加到main.js,以启动你的应用程序。

示例 6-1. ch6-thread-pool/main.js
#!/usr/bin/env node const http = require('http');
const RpcWorkerPool = require('./rpc-worker.js');
const worker = new RpcWorkerPool('./worker.js',
  Number(process.env.THREADS), ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  process.env.STRATEGY); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)

const server = http.createServer(async (req, res) => {
  const value = Math.floor(Math.random() * 100_000_000);
  const sum = await worker.exec('square_sum', value);
  res.end(JSON.stringify({ sum, value }));
});

server.listen(1337, (err) => {
  if (err) throw err;
  console.log('http://localhost:1337/');
});

1

THREADS环境变量控制池的大小。

2

STRATEGY环境变量设置了调度策略。

该应用程序使用了两个环境变量,以便进行实验。第一个命名为THREADS,用于设置线程池中的线程数。第二个环境变量是STRATEGY,可用于设置线程池调度策略。否则,服务器并不太令人兴奋,因为它只使用内置的http模块。服务器监听 1337 端口,无论路径如何,都会触发处理程序。每个请求都调用工作线程中定义的square_sum命令,同时传入一个介于 0 和 1 亿之间的值。

接下来,创建一个名为worker.js的文件,并将内容从示例 6-2 添加到其中。

示例 6-2. ch6-thread-pool/worker.js
const { parentPort } = require('worker_threads');

function asyncOnMessageWrap(fn) {
  return async function(msg) {
    parentPort.postMessage(await fn(msg));
  }
}

const commands = {
  async square_sum(max) {
    await new Promise((res) => setTimeout(res, 100));
    let sum = 0; for (let i = 0; i < max; i++) sum += Math.sqrt(i);
    return sum;
  }
};

parentPort.on('message', asyncOnMessageWrap(async ({ method, params, id }) => ({
  result: await commandsmethod, id
})));

这个文件并不太有趣,因为它本质上是您之前创建的worker.js文件的简化版本。为了缩短代码长度(如果愿意,可以添加回来),删除了大部分错误处理,并且代码也已修改为与 Node.js API 兼容。在这个示例中,只剩下一个命令,即square_sum

接下来,创建一个名为rpc-worker.js的文件。这个文件将会非常庞大,并已分成较小的部分。首先,将内容从示例 6-3 添加到其中。

示例 6-3. ch6-thread-pool/rpc-worker.js(第一部分)
const { Worker } = require('worker_threads');
const CORES = require('os').cpus().length;
const STRATEGIES = new Set([ 'roundrobin', 'random', 'leastbusy' ]);

module.exports = class RpcWorkerPool {
  constructor(path, size = 0, strategy = 'roundrobin') {
    if (size === 0)     this.size = CORES; ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
    else if (size < 0)  this.size = Math.max(CORES + size, 1);
    else                this.size = size;

    if (!STRATEGIES.has(strategy)) throw new TypeError('invalid strategy');
    this.strategy = strategy; ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
    this.rr_index = -1;

    this.next_command_id = 0;
    this.workers = []; ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
    for (let i = 0; i < this.size; i++) {
      const worker = new Worker(path);
      this.workers.push({ worker, in_flight_commands: new Map() }); ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/4.png)
      worker.on('message', (msg) => {
        this.onMessageHandler(msg, i);
      });
    }
  }

1

线程池大小可高度配置。

2

策略已验证并存储。

3

维护一个工作线程数组而不是仅一个。

4

in_flight_commands列表现在针对每个工作线程进行维护。

该文件首先通过要求worker_threads核心模块来创建工作线程,以及os模块来获取可用 CPU 核心数来启动。之后定义并导出RpcWorkerPool类。接下来提供了该类的构造函数。构造函数有三个参数,第一个是工作文件的路径,第二个是池的大小,第三个是要使用的策略。

池大小可高度配置,允许调用者提供一个数字。如果数字为正数,则用作池大小。默认值为零,如果提供了数字,则使用 CPU 核心数作为池大小。如果提供了负数,则从可用核心数中减去该数字,然后使用该数字。因此,在一个 8 核机器上,传入池大小为-2 将导致池大小为 6。

策略参数可以是roundrobin(默认值)、randomleastbusy之一。在分配给类之前,该值经过验证。rr_index值用作循环遍历的轮询索引,是一个循环遍历下一个可用工作线程 ID 的数字。

next_command_id仍然是全局的,跨所有线程,因此第一个命令将是1,下一个将是2,无论这两个命令是否由同一个工作线程处理。

最后,workers类属性是一个工作线程的数组,而不是以前的单个worker属性。处理它的代码基本相同,但in_flight_commands列表现在是局部的,属于各个工作线程,并且工作线程的 ID 作为额外参数传递给onMessageHandler()方法。这是因为当消息发送回主进程时,需要稍后查找各个工作线程。

继续编辑文件,将内容从示例 6-4 添加到其中。

示例 6-4。ch6-thread-pool/rpc-worker.js(第二部分)
  onMessageHandler(msg, worker_id) {
    const worker = this.workers[worker_id];
    const { result, error, id } = msg;
    const { resolve, reject } = worker.in_flight_commands.get(id);
    worker.in_flight_commands.delete(id);
    if (error) reject(error);
    else resolve(result);
  }

文件的这部分定义了onMessageHandler()方法,当工作线程向主线程发送消息时调用该方法。这与之前大致相同,只是这次它接受了一个额外的参数worker_id,用于查找发送消息的工作线程。一旦查找到工作线程,它处理承诺的拒绝/解决,并从待处理命令列表中移除条目。

继续编辑文件,将内容从示例 6-5 添加到其中。

示例 6-5。ch6-thread-pool/rpc-worker.js(第三部分)
  exec(method, ...args) {
    const id = ++this.next_command_id;
    let resolve, reject;
    const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
    const worker = this.getWorker(); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
    worker.in_flight_commands.set(id, { resolve, reject });
    worker.worker.postMessage({ method, params: args, id });
    return promise;
  }

1

应用的工作线程被查找。

文件的这部分定义了exec()方法,当应用程序想要在其中一个工作线程中执行命令时调用该方法。再次强调,这基本上没有改变,但这次它调用getWorker()方法来获取适当的工作线程来处理下一个命令,而不是与单个默认工作线程一起工作。该方法在下一节中定义。

继续编辑文件,将内容从示例 6-6 添加到其中。

示例 6-6。ch6-thread-pool/rpc-worker.js(第四部分)
  getWorker() {
    let id;
    if (this.strategy === 'random') {
      id = Math.floor(Math.random() * this.size);
    } else if (this.strategy === 'roundrobin') {
      this.rr_index++;
      if (this.rr_index >= this.size) this.rr_index = 0;
      id = this.rr_index;
    } else if (this.strategy === 'leastbusy') {
      let min = Infinity;
      for (let i = 0; i < this.size; i++) {
        let worker = this.workers[i];
        if (worker.in_flight_commands.size < min) {
          min = worker.in_flight_commands.size;
          id = i;
        }
      }
    }
    console.log('Selected Worker:', id);
    return this.workers[id];
  }
};

文件的最后一部分定义了一个名为getWorker()的新方法。该方法在确定下一个要使用的工作线程时考虑了为类实例定义的策略。函数的主体是一个大型的if语句,其中每个分支对应一个策略。

第一个方法,random,不需要任何额外的状态,使其成为最简单的方法。该函数的作用仅仅是随机选择池中的一个条目,然后将其选为候选项。

第二个分支,对于roundrobin,稍微复杂一些。这个分支利用了一个名为rr_index的类属性,增加其值然后返回位于新索引处的工作线程。一旦索引超过工作线程的数量,它会回到零。

最后一个分支,对于leastbusy,具有最复杂性。它通过循环遍历每一个工作线程,通过查看in_flight_commands映射的大小来注意它当前正在进行的命令数量,并确定是否是迄今为止遇到的最小值。如果是,则决定下一个要使用的工作线程。请注意,该实现将停止在具有最低正在进行中命令数量的第一个匹配工作线程;因此,第一次运行时它将总是选择工作线程 0。一个更健壮的实现可能会查看所有具有最低、相等命令的候选者,并随机选择一个。所选择的工作线程 ID 被记录下来,以便您可以知道发生了什么。

现在您的应用程序已经准备就绪,可以执行它了。在两个终端窗口中打开,并在第一个窗口中导航到ch6-thread-pool/目录。在这个终端窗口中执行以下命令:

$ THREADS=3 STRATEGY=leastbusy node main.js

这将启动一个进程,其中包含三个工作线程,使用leastbusy策略。

接下来,在第二个终端窗口中运行以下命令:

$ npx autocannon -c 5 -a 20 http://localhost:1337

这将执行autocannon命令,这是一个用于执行基准测试的 npm 包。在这种情况下,您并不真正运行基准测试,而是只运行了一堆查询。该命令配置为每次打开五个连接并发送总共 20 个请求。基本上,这将使得 5 个请求看似并行,然后在关闭请求时进行剩余的 15 个请求。这类似于您可能构建的生产 Web 服务器。

由于应用程序正在使用leastbusy策略,并且代码编写为选择具有最少命令的第一个进程,则前五个请求应该基本上被视为轮询。在三个工作线程的池大小中,当应用程序首次运行时,每个工作线程都没有任务。所以代码首先选择使用 Worker 0。对于第二个请求,第一个工作线程有一个任务,而第二和第三个工作线程都没有,因此选择第二个工作线程。然后是第三个工作线程。对于第四个请求,每个三个工作线程都会被查询,每个工作线程都有一个任务,因此再次选择第一个工作线程。

分配了前五项任务后,剩余的工作分配基本上是随机的,因为每个命令成功所需的时间基本上是随机的。

接下来,使用 Ctrl+C 来停止服务器,然后使用roundrobin策略再次运行它:

$ THREADS=3 STRATEGY=roundrobin node main.js

在第二个终端中再次运行与之前相同的autocannon命令。这次您应该看到任务总是按照 0、1、2、0 的顺序执行。

最后,再次使用 Ctrl+C 终止服务器,并使用随机策略重新运行:

$ THREADS=3 STRATEGY=random node main.js

最后再次运行 autocannon 命令并注意结果。这次应该完全是随机的。如果注意到同一个工作线程被连续选择多次,那很可能是该工作线程过载了。

表 6-1 显示了此实验之前运行的示例输出。每列对应一个新请求,表中的数字是选择用于服务请求的工作线程的 ID。

表 6-1. 示例线程池策略输出

策略 R1 R2 R3 R4 R5 R6 R7 R8 R9 R10
最少繁忙 0 1 2 0 1 0 1 2 1 0
轮询 0 1 2 0 1 2 0 1 2 0
随机 2 0 1 1 0 0 0 1 1 0

在这次特定的运行中,随机方法几乎没有使用 ID 为 2 的工作线程。

互斥锁:基本锁

互斥锁,或称 mutex,是控制对某些共享数据访问的机制。它确保在任何给定时间只有一个任务可以使用该资源。这里,任务可以是任何类型的并发任务,但通常是在使用多个线程时使用该概念,以避免竞态条件。任务在运行访问共享数据的代码之前 获取 锁,并在完成后 释放 锁。在获取和释放之间的代码称为 临界区。如果一个任务尝试在另一个任务持有锁时获取锁,那么该任务将被阻塞,直到另一个任务释放锁。

当我们通过 Atomics 对象拥有原子操作时,可能不明显为什么我们还需要使用互斥锁(mutex)。毕竟,使用原子操作修改和读取数据更有效率,因为我们仅在较短时间内阻塞其他操作,对吧?然而,事实是代码经常要求数据跨多个操作不被外部修改。换句话说,原子操作提供的原子性单位对于许多算法的关键部分来说太小了。例如,可以从共享内存的几个部分读取两个整数,然后将它们相加以写入另一个部分。如果在两次检索之间更改了值,则总和将反映来自两个不同任务的值,这可能导致程序后续的逻辑错误。

让我们看一个示例程序,它初始化一个包含一堆数字的缓冲区,并在几个线程中对它们进行一些基本数学操作。我们将使每个线程获取唯一索引处的值,然后获取共享索引处的值,将它们相乘,并将结果写入共享索引。然后,我们将从该共享索引读取并检查它是否等于前两次读取的乘积。在两次读取之间,我们将执行一个繁忙循环以模拟执行一些需要一定时间的其他工作。

创建一个名为ch6-mutex的目录,并将 Example 6-7 的内容放入名为thread_product.js的文件中。

Example 6-7. ch6-mutex/thread-product.js
const {
  Worker, isMainThread, workerData
} = require('worker_threads');
const assert = require('assert');

if (isMainThread) {
  const shared = new SharedArrayBuffer(4 * 4); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  const sharedInts = new Int32Array(shared);
  sharedInts.set([2, 3, 5, 7]);
  for (let i = 0; i < 3; i++) {
    new Worker(__filename, { workerData: { i, shared } });
  }
} else {
  const { i, shared } = workerData;
  const sharedInts = new Int32Array(shared);
  const a = Atomics.load(sharedInts, i);
  for (let j = 0; j < 1_000_000; j++) {}
  const b = Atomics.load(sharedInts, 3);
  Atomics.store(sharedInts, 3, a * b);
  assert.strictEqual(Atomics.load(sharedInts, 3), a * b); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
}

1

我们将使用三个线程和一个Int32Array来存储数据,因此我们需要足够大的空间来容纳三个 32 位整数,再加上第四个用作共享的乘法器/结果。

2

在这里,我们正在检查我们的工作。在真实的应用程序中,可能不会在这里进行检查,但这模拟了依赖结果以执行其他可能在程序后续阶段发生的操作。

您可以按如下方式运行此示例:

$ node thread-product.js

您可能会发现,在第一次尝试或者甚至在一系列尝试中,这个程序都能正常工作,但请继续运行它。或者您可能会发现断言立即失败。在前 20 次尝试中的某个时刻,您应该能够看到断言失败。虽然我们使用了原子操作,但在这些操作之间可能会对这些值进行更改。这是典型的竞态条件的例子。所有线程都在并发读写(尽管操作本身是原子的),因此对于给定的输入值,结果并不是确定性的。

为了解决这个问题,我们将使用现有的原语实现一个Mutex类。我们将使用Atomics.wait()等待直到可以获取锁,并使用Atomics.notify()通知线程锁已释放。我们将使用Atomics.compareExchange()交换锁定/解锁状态,并确定是否需要等待以获取锁定。在同一目录中创建一个名为mutex.js的文件,并添加 Example 6-8 的内容,以开始编写Mutex类。

Example 6-8. ch6-mutex/mutex.js(第一部分)
const UNLOCKED = 0;
const LOCKED = 1;

const {
  compareExchange, wait, notify
} = Atomics;

class Mutex {
  constructor(shared, index) {
    this.shared = shared;
    this.index = index;
  }

在这里,我们将我们的LOCKEDUNLOCKED状态定义为 1 和 0。实际上,它们可以是任何适合我们传递给Mutex构造函数的TypedArray中的值,但将它们设为 1 和 0 使得将其视为布尔值更容易理解。我们已经设置了构造函数,以接受两个值分配给属性:我们将操作的TypedArray,以及我们将用作锁定状态的数组中的索引。现在,我们准备开始使用Atomics来添加acquire()方法,该方法使用解构的Atomics。从 Example 6-9 添加acquire()方法。

Example 6-9. ch6-mutex/mutex.js(第二部分)
  acquire() {
    if (compareExchange(this.shared, this.index, UNLOCKED, LOCKED) === UNLOCKED) {
      return;
    }
    wait(this.shared, this.index, LOCKED);
    this.acquire();
  }

要获取锁定,我们尝试使用Atomics.compareExchange()将互斥锁数组索引处的UNLOCKED状态与LOCKED状态进行交换。如果交换成功,则无需其他操作,我们已经获取了锁定,因此可以直接返回。否则,我们需要等待解锁,这种情况下意味着等待值从LOCKED变为其他任何值的通知。然后我们再次尝试获取锁定。我们在这里通过递归来执行这个操作以说明“重试”的性质,但也可以很容易地使用循环。由于我们专门等待它变为解锁状态,因此在第二次尝试中应该可以成功,但在wait()compareExchange()之间,值可能已经发生了变化,因此我们需要再次检查。在实际实现中,您可能希望在wait()上添加超时并限制可以尝试的次数。

注意

在许多生产互斥锁实现中,除了“解锁”和“锁定”状态之外,您通常会找到表示“锁定并有争议”的状态。当一个线程试图获取已被另一个线程持有的锁时,就会出现争用。通过跟踪这种状态,互斥锁代码可以避免多余的notify()调用,从而提高性能。

现在我们将看看如何释放锁定。添加在示例 6-10 中显示的release()方法。

示例 6-10. ch6-mutex/mutex.js(第三部分)
  release() {
    if (compareExchange(this.shared, this.index, LOCKED, UNLOCKED) !== LOCKED) {
      throw new Error('was not acquired');
    }
    notify(this.shared, this.index, 1);
  }

我们在这里再次使用Atomics.compareExchange()来交换锁定状态,这与我们获取锁时的操作类似。这次,我们希望确保原始状态确实是LOCKED,因为如果我们未获取锁,我们不想释放它。此时唯一剩下的事情就是调用notify(),启用等待的线程(如果有的话)来获取锁定。我们将notify()的计数设置为 1,因为唤醒多于一个正在睡眠的线程是没有必要的,因为在任何时候只有一个线程能持有锁定。

现在我们已经有足够的内容作为一个可用的互斥锁。然而,很容易在获取锁定后忘记释放它,或者以某种方式有一个意外的临界区。对于许多用例,临界区是明确定义和预知的。在这些情况下,通过在Mutex类上添加exec()方法来包装临界区是有意义的。让我们通过在示例 6-11 中添加exec()方法来做到这一点,这也将完成该类。

示例 6-11. ch6-mutex/mutex.js(第四部分)
  exec(fn) {
    this.acquire();
    try {
      return fn();
    } finally {
      this.release();
    }
  }
}

module.exports = Mutex;

这里我们所做的只是调用传入的函数并返回其值,但在此之前用 acquire() 包装,之后用 release() 包装。这样传入的函数就包含了我们关键部分的所有代码。请注意,我们在 try 块中调用传入的函数,并在相应的 finally 中进行 release()。由于传入的函数可能会抛出异常,我们希望确保即使在这种情况下也释放锁。这完成了我们的 Mutex 类,现在我们可以继续在示例中使用它。

在同一目录中复制 thread-product.js,并命名为 thread-product-mutex.js。在该文件中 require mutex.js 文件,并将其赋值给名为 Mutexconst。为了让我们的锁使用,将 SharedArrayBuffer 添加另外 4 个字节(例如,new SharedArrayBuffer(4 * 5)),然后用 Example 6-12 的内容替换 else 块中的所有内容。

Example 6-12. ch6-mutex/thread-product-mutex.js
  const { i, shared } = workerData;
  const sharedInts = new Int32Array(shared);
  const mutex = new Mutex(sharedInts, 4); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  mutex.exec(() => {
    const a = sharedInts[i]; ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
    for (let j = 0; j < 1_000_000; j++) {}
    const b = sharedInts[3];
    sharedInts[3] = a * b;
    assert.strictEqual(sharedInts[3], a * b);
  });

1

在此行之前,一切与未使用互斥锁时完全相同。现在,我们将初始化一个,使用我们 Int32Array 的第五个元素作为我们的锁数据。

2

在传递给 exec() 的函数内部,我们处于由锁保护的关键部分。这意味着我们不需要原子操作来读取或操作数组。相反,我们可以像操作任何其他 TypedArray 一样操作它。

除了启用普通的数组访问技术外,互斥锁还允许我们确保在查看这些数据时没有其他线程能够修改它们。因此,我们的断言永远不会失败。试一试吧!运行以下命令来运行此示例,甚至运行数十次、数百次或甚至数千次。它永远不会像仅使用原子操作的版本那样使断言失败:

$ node thread-product-mutex.js
注意

互斥锁是锁定访问资源的简单工具。它们允许关键部分在没有其他线程干扰的情况下运行。它们是如何利用原子操作的组合来为多线程编程创建新的构建块的一个例子。在下一节 “使用环形缓冲区流数据” 中,我们将把这个构建块用于一些实际用途。

使用环形缓冲区流数据

许多应用涉及流数据。例如,HTTP 请求和响应通常通过 HTTP API 以字节数据序列形式呈现,随着它们接收到的数据块而来。在网络应用中,数据块受包大小的限制。在文件系统应用中,数据块可以受内核缓冲区大小的限制。即使我们将数据输出到这些资源而不考虑流式传输,内核也会将数据分成块,以便以缓冲的方式发送到目的地。

流式数据在用户应用程序中也经常发生,并且可以用作在计算单元(如进程或线程)之间传输大量数据的一种方式。即使没有分离的计算单元,您可能也希望或需要在处理数据之前将数据保存在某种缓冲区中。这就是环形缓冲区,也被称为循环缓冲区的便利之处。

环形缓冲区是使用一对索引来实现的先进先出(FIFO)队列,这对索引指向内存中数据数组的位置。关键是,当数据插入队列时,它不会移动到内存中的其他位置。相反,我们会随着数据的添加或移除而移动这些索引。数组被视为一个端点连接到另一个端点,形成一个数据环。这意味着如果这些索引增加超过数组的末尾,它们将返回到开始。

在物理世界中,餐馆点单轮类似于北美餐馆中常见的点单轮。在使用这种系统的餐馆中,点单轮通常放置在将顾客区域与厨房分隔开的地方。服务员会将顾客的点单纸条按顺序插入轮中。然后,在厨房一侧,厨师按照同样的顺序从轮中取出订单,以便按照适当的顺序烹饪食物,确保没有顾客等待时间过长。这就是一个有界的^(1) FIFO 队列,就像我们的环形缓冲区一样。事实上,它也是字面上的循环!

要实现环形缓冲区,我们需要两个索引,headtailhead 索引指向下一个要添加数据到队列中的位置,而 tail 索引指向从队列中读取数据的下一个位置。当向队列写入或从队列读取数据时,我们会分别增加 headtail 索引,模上缓冲区的大小。

图 6-1 展示了使用 16 字节缓冲区的环形缓冲区的工作原理。第一幅图示了包含 4 字节数据的环,从字节 0 开始(尾部位置),到字节 3 结束(头部在字节 4 前一字节)。一旦向缓冲区添加了四字节的数据,头部标记会向前移动四字节到字节 8,如第二幅图所示。在最后一幅图中,前四个字节已经被读取,所以尾部移动到字节 4 处。

mtjs 0601

图 6-1. 写入数据会使头部向前移动,而读取数据会使尾部向前移动。

让我们实现一个环形缓冲区。起初,我们不用担心线程问题,但为了稍后更容易处理,我们将在一个TypedArray中存储headtail以及队列的当前length。我们可以尝试仅使用headtail之间的差异作为长度,但这样会留下一个模棱两可的情况,即当headtail相同时,我们无法判断队列是空还是满,因此我们将单独使用一个length值。我们将从设置构造函数和访问器开始,通过将示例 6-13 的内容添加到名为ch6-ring-buffer/ring-buffer.js的文件中来完成。

示例 6-13. ch6-ring-buffer/ring-buffer.js(第一部分)
class RingBuffer {
  constructor(meta/*: Uint32Array[3]*/, buffer /*: Uint8Array */) {
    this.meta = meta;
    this.buffer = buffer;
  }

  get head() {
    return this.meta[0];
  }

  set head(n) {
    this.meta[0] = n;
  }

  get tail() {
    return this.meta[1];
  }

  set tail(n) {
    this.meta[1] = n;
  }

  get length() {
    return this.meta[2];
  }

  set length(n) {
    this.meta[2] = n;
  }

构造函数接受一个名为meta的三元素Uint32Array,我们将用它来存储headtaillength。为了方便起见,我们还添加了这些属性作为获取器和设置器,内部只是访问这些数组元素。它还接受一个Uint8Array,将作为环形缓冲区的后备存储。接下来,我们将添加write()方法。按照示例 6-14 中定义的方法进行添加。

示例 6-14. ch6-ring-buffer/ring-buffer.js(第二部分)
  write(data /*: Uint8Array */) { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
    let bytesWritten = data.length;
    if (bytesWritten > this.buffer.length - this.length) { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
      bytesWritten = this.buffer.length - this.length;
      data = data.subarray(0, bytesWritten);
    }
    if (bytesWritten === 0) {
      return bytesWritten;
    }
    if (
      (this.head >= this.tail && this.buffer.length - this.head >= bytesWritten) ||
      (this.head < this.tail && bytesWritten <= this.tail - this.head) ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
    ) {
      // Enough space after the head. Just write it in and increase the head.
      this.buffer.set(data, this.head);
      this.head += bytesWritten;
    } else { ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/4.png)
      // We need to split the chunk into two.
      const endSpaceAvailable = this.buffer.length - this.head;
      const endChunk = data.subarray(0, endSpaceAvailable);
      const beginChunk = data.subarray(endSpaceAvailable);
      this.buffer.set(endChunk, this.head);
      this.buffer.set(beginChunk, 0);
      this.head = beginChunk.length;
    }
    this.length += bytesWritten;
    return bytesWritten;
  }

1

为了使此代码能够正常工作,data需要是与this.buffer相同的TypedArray的实例。可以通过静态类型检查或断言来检查这一点,或者两者都可以。

2

如果缓冲区中没有足够的空间来写入所有数据,则将尽可能多的字节写入以填充缓冲区,并返回写入的字节数。这通知正在写入数据的人,他们需要等待一些数据被读出后才能继续写入。

3

此条件表示当我们有足够的连续空间来写入数据时。这种情况发生在数组中头部在尾部之后且头部后面的空间大于要写入的数据时,或者当头部在尾部之前且尾部和头部之间有足够的空间时。对于这些条件中的任何一种,我们只需将数据写入数组,并增加头部索引的长度。

4

if块的另一侧,我们需要写入数据直到数组的末尾,然后将其环绕以写入数组的开头。这意味着将数据分割为在末尾写入的块和在开头写入的块,并相应地写入它们。我们使用subarray()而不是slice()来切割数据,以避免不必要的第二次复制操作。

写入实际上只是将字节复制过来并使用set()更改head索引,对于数据跨越数组边界的特殊情况,需要适当调整。阅读非常类似,如示例 6-15 中的read()方法所示。

示例 6-15. ch6-ring-buffer/ring-buffer.js(第三部分)
  read(bytes) {
    if (bytes > this.length) { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
      bytes = this.length;
    }
    if (bytes === 0) {
      return new Uint8Array(0);
    }
    let readData;
    if (
      this.head > this.tail || this.buffer.length - this.tail >= bytes ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
    ) {
      // The data is in a contiguous chunk.
      readData = this.buffer.slice(this.tail, bytes)
      this.tail += bytes;
    } else { ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
      // Read from the end and the beginning.
      readData = new Uint8Array(bytes);
      const endBytesToRead = this.buffer.length - this.tail;
      readData.set(this.buffer.subarray(this.tail, this.buffer.length));
      readData.set(this.buffer.subarray(0, bytes - endBytesToRead), endBytesToRead);
      this.tail = bytes - endBytesToRead;
    }
    this.length -= bytes;
    return readData;
  }
}

1

read()的输入是请求的字节数。如果队列中没有足够的字节,它将返回当前队列中的所有字节。

2

如果请求的数据在从tail读取的连续块中,我们将直接使用slice()将其传递给调用者以获取这些字节的副本。我们将尾部移动到返回字节的末尾。

3

else情况下,数据跨越数组的边界,因此我们需要获取两个块并以相反顺序将它们拼接在一起。为此,我们将分配一个足够大的Uint8Array,然后将数据从数组的开头和结尾复制过来。新的尾部设置为数组开头的块的末尾。

从队列中读取字节时,重要的是复制它们出来,而不仅仅是引用相同的内存。如果不这样做,那么以后写入队列的其他数据可能会出现在这些数组中,这是我们不希望看到的。这就是为什么我们使用slice()或一个新的Uint8Array来返回数据。

此时,我们有一个可工作的单线程有界队列,实现为环形缓冲区。如果我们想要将其与一个线程写入(生产者)和一个线程读取(消费者)一起使用,我们可以使用SharedArrayBuffer作为构造函数输入的后备存储,将其传递给另一个线程,并在那里实例化。不幸的是,我们尚未使用任何原子操作或识别和隔离使用锁的关键部分,因此如果多个线程使用缓冲区,可能会出现竞争条件和错误数据。我们需要纠正这一点。

读取和写入操作假定在整个操作过程中,headtaillength都不会被其他线程更改。我们以后可能会更具体,但起初这样一般性至少会给我们所需的线程安全性,以避免竞争条件。我们可以使用来自“Mutex:基本锁”的Mutex类来识别关键部分,并确保它们一次只执行一次。

让我们引入Mutex类,并将包装类添加到将使用我们现有的RingBuffer类的文件中,这样可以使用示例 6-16。

示例 6-16. ch6-ring-buffer/ring-buffer.js(第四部分)
const Mutex = require('../ch6-mutex/mutex.js');

class SharedRingBuffer {
  constructor(shared/*: number | SharedArrayBuffer*/) {
    this.shared = typeof shared === 'number' ?
      new SharedArrayBuffer(shared + 16) : shared;
    this.ringBuffer = new RingBuffer(
      new Uint32Array(this.shared, 4, 3),
      new Uint8Array(this.shared, 16)
    );
    this.lock = new Mutex(new Int32Array(this.shared, 0, 1));
  }

  write(data) {
    return this.lock.exec(() => this.ringBuffer.write(data));
  }

  read(bytes) {
    return this.lock.exec(() => this.ringBuffer.read(bytes));
  }
}

为了开始,构造函数接受或创建 SharedArrayBuffer。注意,我们将缓冲区的大小增加了 16 字节,以处理 Mutex(需要一个元素的 Int32Array)和 RingBuffer 元数据(需要一个三元素的 Uint32Array)。我们将按照 Table 6-2 中的布局设置内存。

表 6-2. SharedRingBuffer 内存布局

数据 类型[大小] SharedArrayBuffer 索引
互斥锁 Int32Array[1] 0
RingBuffer 元数据 Uint32Array[3] 4
RingBuffer 缓冲区 Uint32Array[size] 16

read()write() 操作都被 Mutexexec() 方法包装起来。回想一下,这可以防止同一个互斥锁保护的其他关键部分同时运行。通过包装它们,我们确保即使有多个线程同时从同一个队列读取和写入,我们也不会因为 headtail 在这些关键部分中间被外部修改而出现竞态条件。

要看到这个数据结构的实际应用,让我们创建一些 生产者消费者 线程。我们将设置一个带有 100 字节的 SharedRingBuffer 进行操作。生产者线程将重复尝试获取锁并向 SharedRingBuffer 写入字符串 "Hello, World!\n"。消费者线程将尝试每次读取 20 字节,并记录他们能够读取多少字节。完成这些操作的代码都在 Example 6-17 中,你可以将其添加到 ch6-ring-buffer/ring-buffer.js 的末尾。

Example 6-17. ch6-ring-buffer/ring-buffer.js(第五部分)
const { isMainThread, Worker, workerData } = require('worker_threads');
const fs = require('fs');

if (isMainThread) {
  const shared = new SharedArrayBuffer(116);
  const threads = [
    new Worker(__filename, { workerData: { shared, isProducer: true } }),
    new Worker(__filename, { workerData: { shared, isProducer: true } }),
    new Worker(__filename, { workerData: { shared, isProducer: false } }),
    new Worker(__filename, { workerData: { shared, isProducer: false } })
  ];
} else {
  const { shared, isProducer } = workerData;
  const ringBuffer = new SharedRingBuffer(shared);

  if (isProducer) {
    const buffer = Buffer.from('Hello, World!\n');
    while (true) {
      ringBuffer.write(buffer);
    }
  } else {
    while (true) {
      const readBytes = ringBuffer.read(20);
      fs.writeSync(1, `Read ${readBytes.length} bytes\n`); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
    }
  }
}

1

你可能注意到,我们没有使用 console.log() 将字节计数写入 stdout,而是直接使用同步写入到与 stdout 对应的文件描述符。这是因为我们在没有任何 await 的情况下使用了无限循环。我们正在饿死 Node.js 事件循环,因此使用 console.log 或任何其他异步记录器,我们实际上永远不会看到任何输出。

你可以使用 Node.js 运行这个示例:

$ node ring-buffer.js

此脚本生成的输出将显示每个消费者线程在每次迭代中读取的字节数。因为我们每次要求 20 字节,所以你会看到这是最大可读取的数字。当队列为空时,有时你会看到全部为零。当队列部分填满时,你会看到其他数字。

在我们的示例中,有许多地方可以调整。SharedRingBuffer 的大小,生产者和消费者线程的数量,写入消息的大小,以及尝试读取的字节数,所有这些都会影响数据的吞吐量。和其他任何东西一样,值得测量和调整这些值,以找到适合你的应用程序的最佳状态。随便试试调整一些示例代码中的这些值,看看输出如何改变。

Actor Model

演员模型是一种用于执行并发计算的编程模式,最早是在 1970 年代设计的。使用这种模型,演员是一个允许执行代码的原始容器。演员能够运行逻辑,创建更多演员,向其他演员发送消息并接收消息。

这些演员通过消息传递与外部世界进行通信;否则,它们具有自己独立的内存访问权限。在 Erlang 编程语言中,演员是一等公民,^(2) 但可以使用 JavaScript 模拟它们。

演员模型旨在允许计算以高度并行化的方式运行,而不必担心代码运行的位置或甚至实现通信所使用的协议。实际上,编程代码不应关心一个演员是否本地或远程与另一个演员通信。图 6-2 显示了演员如何分布在多个进程和机器上。

mtjs 0602

图 6-2. 演员可以分布在多个进程和机器上

模式细微差别

演员可以逐个处理接收到的每条消息或任务。当这些消息首次接收时,它们会放置在消息队列中,有时也称为邮箱。使用队列很方便,因为如果同时接收到两条消息,则不应同时处理它们。如果没有队列,一个演员可能需要在发送消息之前检查另一个演员是否准备就绪,这将是一个非常繁琐的过程。

虽然没有两个演员能够写入同一块共享内存,但它们可以自由地改变自己的内存。这包括随时间维护状态修改。例如,一个演员可以跟踪它已处理的消息数量,然后在以后输出的消息中传递这些数据。

由于没有涉及共享内存,因此演员模型能够避免一些早期讨论的多线程陷阱,如竞态条件和死锁。在许多方面,演员就像函数式语言中的函数,接受输入并避免访问全局状态。

由于演员一次只能处理一个任务,它们通常可以以单线程方式实现。而且,虽然单个演员一次只能处理一个任务,但不同的演员可以自由地并行运行代码。

使用演员的系统不应期望消息以 FIFO 方式有序传递。相反,它应对延迟和无序交付具有弹性,特别是因为演员可以分布在网络中。

单个角色也可以有地址的概念,这是唯一标识单个角色的一种方式。表示此值的一种方式可能是使用 URI。例如,tcp://127.0.0.1:1234/3 可能指的是在本地计算机上监听端口 1234 的程序中运行的第三个角色。此处介绍的实现不使用这样的地址。

关于 JavaScript

在像 Erlang 这样的语言中作为一等公民存在的角色无法完全使用 JavaScript 来完美复制,但我们当然可以尝试。可能有几十种方法可以进行类比和实现角色,本节为您介绍其中一种方法。

角色模型的一个优点是,角色不需要局限于单台计算机。这意味着进程可以在多台计算机上运行并通过网络进行通信。我们可以使用 Node.js 进程来实现这一点,每个进程使用 TCP 协议通过 JSON 进行通信。

因为单个角色应该能够与其他角色并行运行代码,并且每个角色一次只处理一个任务,所以角色很可能应该在不同的线程上运行以最大化系统使用率。一种方法是实例化新的工作线程。另一种方法是为每个角色设置专用进程,但这样会使用更多资源。

因为不需要处理不同角色之间的共享内存,因此可以基本忽略 SharedArrayBufferAtomics 对象(尽管更健壮的系统可能会依赖它们进行协调)。

角色需要一个消息队列,以便在处理一个消息时,另一个消息可以等待,直到角色准备好。JavaScript 的工作线程通过 postMessage() 方法在某种程度上帮助我们处理这个问题。通过这种方式传递的消息会等到当前 JavaScript 栈完成后再抓取下一个消息。如果每个角色只运行同步代码,那么可以使用这个内置队列。另一方面,如果角色可以执行异步工作,那么就需要手动构建一个队列。

到目前为止,角色模型可能听起来与 “线程池” 中介绍的线程池模式很相似。事实上,它们有很多相似之处,你几乎可以将角色模型看作是一个线程池的池。但是两个概念之间有足够的差异值得区分。事实上,角色模型为计算提供了一种独特的编程范式,真正是一种可以改变编写代码方式的高级编程模式。在实践中,角色模型涉及的程序通常依赖于线程池。

示例实现

创建一个名为ch6-actors/的新目录来实现这个示例。在此目录中,从示例 6-3 复制并粘贴现有的ch6-thread-pool/rpc-worker.js文件以及从示例 6-2 复制ch6-thread-pool/worker.js文件。这些文件将作为本示例中线程池的基础,并且可以保持不变。

接下来,创建一个名为ch6-actors/server.js的文件,并将内容从示例 6-18 添加到其中。

示例 6-18. ch6-actors/server.js(第一部分)
#!/usr/bin/env node

const http = require('http');
const net = require('net');

const [,, web_host, actor_host] = process.argv;
const [web_hostname, web_port] = web_host.split(':');
const [actor_hostname, actor_port] = actor_host.split(':');

let message_id = 0;
let actors = new Set(); // collection of actor handlers
let messages = new Map(); // message ID -> HTTP response

此文件创建两个服务器实例。第一个是 TCP 服务器,一个相对基础的协议,而第二个是 HTTP 服务器,它是基于 TCP 的高级协议,尽管这两个服务器实例不会相互依赖。此文件的前半部分包含了用于接受命令行参数以配置这两个服务器的样板代码。

message_id变量包含一个数字,该数字将在每次新的 HTTP 请求时递增。messages变量包含了消息 ID 到响应处理程序的映射,这些处理程序将用于回复消息。这与你在“线程池”中使用的模式相同。最后,actors变量包含了一系列处理函数,用于向外部演员进程发送消息。

接下来,将内容从示例 6-19 添加到文件中。

示例 6-19. ch6-actors/server.js(第二部分)
net.createServer((client) => {
  const handler = data => client.write(JSON.stringify(data) + '\0'); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  actors.add(handler);
  console.log('actor pool connected', actors.size);
  client.on('end', () => {
    actors.delete(handler); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
    console.log('actor pool disconnected', actors.size);
  }).on('data', (raw_data) => {
    const chunks = String(raw_data).split('\0'); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
    chunks.pop(); ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/4.png)
    for (let chunk of chunks) {
      const data = JSON.parse(chunk);
      const res = messages.get(data.id);
      res.end(JSON.stringify(data) + '\0');
      messages.delete(data.id);
    }
  });
}).listen(actor_port, actor_hostname, () => {
  console.log(`actor: tcp://${actor_hostname}:${actor_port}`);
});

1

在消息之间插入空字节'\0'

2

当客户端连接关闭时,它将从actors集合中移除。

3

data事件可能包含多个消息,并且以空字节分割。

4

最后一个字节是空字节,因此chunks中的最后一个条目是空字符串。

此文件创建了 TCP 服务器。这是专用的演员进程连接到主服务器进程的方式。每当演员进程连接时,net.createServer()回调函数都会被调用。client参数表示 TCP 客户端,实质上是与演员进程的连接。每次建立连接时都会记录一条消息,并且为方便地向演员发送消息添加了一个处理函数到actors集合中。

当客户端从服务器断开连接时,该客户端的处理函数将从actors集合中删除。演员通过 TCP 发送消息与服务器进行通信,这将触发data事件。³它们发送的消息是 JSON 编码数据。该数据包含一个id字段,该字段与消息 ID 相关联。当回调函数运行时,将从messages映射中检索相应的处理函数。最后,响应消息将发送回 HTTP 请求,消息将从messages映射中删除,并且服务器将监听指定的接口和端口。

注意

服务器与演员池客户端之间的连接是长连接。因此,需要设置事件处理程序来处理诸如dataend等事件。

这个文件显著缺少客户端连接的错误处理程序。由于缺少这部分内容,连接错误将导致服务器进程终止。一个更健壮的解决方案是从actors集合中删除客户端。

由于一方发送消息时并不能保证另一方会在单个data事件中接收到它,所以会在消息之间插入'\0'空字节。特别是在快速连续发送多个消息时,它们将会在一个data事件中到达。这是一个你使用curl进行单一请求时不会遇到的 bug,但是当使用autocannon进行多次请求时会遇到。这将导致多个 JSON 文档被串联在一起,例如:{"id":1…}{"id":2…}。将这样的值传递给JSON.parse()将会导致错误。空字节使得事件看起来像这样:{"id":1…}\0{"id":2…}\0。然后字符串会被空字节分割,并且每个片段将被分别解析。如果空字节出现在 JSON 对象中,它会被转义,这意味着在分隔 JSON 文档时可以安全使用空字节。

紧接着,将示例 6-20 的内容添加到文件中。

示例 6-20. ch6-actors/server.js(第三部分)
http.createServer(async (req, res) => {
  message_id++;
  if (actors.size === 0) return res.end('ERROR: EMPTY ACTOR POOL');
  const actor = randomActor();
  messages.set(message_id, res);
  actor({
    id: message_id,
    method: 'square_sum',
    args: [Number(req.url.substr(1))]
  });
}).listen(web_port, web_hostname, () => {
  console.log(`web:   http://${web_hostname}:${web_port}`);
});

这部分文件创建了一个 HTTP 服务器。与 TCP 服务器不同,每个请求表示一个短暂的连接。http.createServer()回调函数将在接收到每个 HTTP 请求时被调用。

在这个回调函数内部,当前消息 ID 会递增,并且会查询演员列表。如果列表为空,这可能发生在服务器启动时,但是尚未加入演员时,将返回错误消息“ERROR: EMPTY ACTOR POOL”。否则,如果有演员存在,则会随机选择一个。不过,这不是最佳的解决方案,一个更健壮的解决方案在本节末尾讨论。

接下来,向执行者发送一个 JSON 消息。该消息包含一个id字段,表示消息 ID,一个method字段,表示要调用的函数(在本例中始终是square_sum),以及参数列表。在这种情况下,HTTP 请求路径包含斜杠和一个数字,如/42,并提取该数字以用作参数。最后,服务器侦听提供的接口和端口。

接下来,将示例 6-21 中的内容添加到文件中。

示例 6-21. ch6-actors/server.js(第四部分)
function randomActor() {
  const pool = Array.from(actors);
  return pool[Math.floor(Math.random() * pool.length)];
}

文件的这一部分只是从actors列表中随机获取一个执行者处理程序。

这个文件完成后(目前为止),创建一个名为ch6-actors/actor.js的新文件。从示例 6-22 中添加内容到该文件。

示例 6-22. ch6-actors/actor.js(第一部分)
#!/usr/bin/env node

const net = require('net');
const RpcWorkerPool = require('./rpc-worker.js');

const [,, host] = process.argv;
const [hostname, port] = host.split(':');
const worker = new RpcWorkerPool('./worker.js', 4, 'leastbusy');

再次,这个文件从一些样板代码开始,用于提取服务器进程的主机名和端口信息。它还使用RpcWorkerPool类初始化了一个线程池。该池有严格的四个线程大小,可以看作是四个执行者,并使用leastbusy算法。

接下来,将示例 6-23 中的内容添加到文件中。

示例 6-23. ch6-actors/actor.js(第二部分)
const upstream = net.connect(port, hostname, () => {
  console.log('connected to server');
}).on('data', async (raw_data) => {
  const chunks = String(raw_data).split('\0'); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  chunks.pop();
  for (let chunk of chunks) {
    const data = JSON.parse(chunk);
    const value = await worker.exec(data.method, ...data.args);
    upstream.write(JSON.stringify({
      id: data.id,
      value,
      pid: process.pid
    }) + '\0');
  }
}).on('end', () => {
  console.log('disconnect from server');
});

1

执行者还需要处理空字节块的分离。

net.connect()方法创建与上游端口和主机名的连接,表示服务器进程,一旦连接成功就记录一条消息。当服务器向该执行者发送消息时,它触发data事件,并传递缓冲实例作为raw_data参数。然后解析包含 JSON 负载的数据。

然后执行者进程调用其工作者之一,调用请求的方法并传递参数。一旦工作者/执行者完成,数据就会被发送回服务器实例。使用id属性保留了相同的消息 ID。必须提供此值,因为给定的执行者进程可以同时接收多个消息请求,主服务器进程需要知道哪个回复与哪个请求相关联。返回的value也在消息中提供。进程 ID 也作为分配给pid的响应元数据提供,以便您可以可视化哪个程序在计算什么数据。

再次明显缺失的是正确的错误处理。如果发生连接错误,你会看到整个进程终止。

图 6-3 是你刚刚构建的实现的可视化。

mtjs 0603

图 6-3. 本节中执行者模型实现的可视化

现在你的文件已经完成,你可以运行你的程序了。首先,运行服务器,提供用于 HTTP 服务器的主机名和端口,然后是用于 TCP 服务器的主机名和端口。你可以通过运行以下命令来完成:

$ node server.js 127.0.0.1:8000 127.0.0.1:9000
# web:   http://127.0.0.1:8000
# actor: tcp://127.0.0.1:9000

在这种情况下,进程显示了两个服务器地址。

接下来,在一个新的终端窗口中向服务器发送一个请求:

$ curl http://localhost:8000/9999
# ERROR: EMPTY ACTOR POOL

糟糕!在这种情况下,服务器以错误回复。由于没有运行的演员进程,因此没有东西可以执行这项工作。

接下来,运行一个演员进程,并为其提供服务器实例的主机名和端口。你可以通过运行以下命令来完成:

$ node actor.js 127.0.0.1:9000

你应该能看到从服务器和工作进程打印出的连接建立消息。接下来,在一个单独的终端窗口中再次运行curl命令:

$ curl http://localhost:8000/99999
# {"id":4,"value":21081376.519967034,"pid":160004}

你应该得到与先前打印的值类似的返回值。通过附加新的演员进程,程序从没有可用于执行工作的演员转变为有四个演员可用。但你不需要停在这里。在另一个终端窗口中运行另一个使用相同命令的工作进程实例,并发出另一个 curl 命令:

$ node actor.js 127.0.0.1:9000

$ curl http://localhost:8000/8888888
# {"id":4,"value":21081376.519967034,"pid":160005}

当您多次运行命令时,应该看到响应中的 pid 值发生变化。恭喜,您现在动态增加了应用程序可用演员的数量。这是在运行时完成的,有效地提高了您的应用程序性能,而无需停机。

现在,演员模式的一个好处是代码运行的具体位置并不重要。在这种情况下,演员们存在于外部进程中。这使得在服务器首次执行时就发生了错误:发出了 HTTP 请求,但演员进程尚未连接。解决此问题的一种方法是在服务器进程中添加一些演员。

修改第一个 ch6-actors/server.js 文件,并将内容从 示例 6-24 添加到其中。

示例 6-24. ch6-actors/server.js(第五部分,奖励)
const RpcWorkerPool = require('./rpc-worker.js');
const worker = new RpcWorkerPool('./worker.js', 4, 'leastbusy');
actors.add(async (data) => {
  const value = await worker.exec(data.method, ...data.args);
  messages.get(data.id).end(JSON.stringify({
    id: data.id,
    value,
    pid: 'server'
  }) + '\0');
  messages.delete(data.id);
});

文件的这一部分在服务器进程中创建了一个工作线程池,有效地向池中添加了四个额外的演员。使用 Ctrl+C 杀死您创建的现有服务器和演员进程。然后,运行您的新服务器代码并发送一个 curl 请求:

$ node server.js 127.0.0.1:8000 127.0.0.1:9000
$ curl http://localhost:8000/8888888
# {"id":8,"value":17667693458.923462,"pid":"server"}

在这种情况下,pid 值已经硬编码为 server,表示执行计算的进程是服务器进程。与之前类似,你可以运行更多的演员进程,让它们连接到服务器,并运行更多的 curl 命令向服务器发送请求。当发生这种情况时,你应该看到请求被专用演员进程或服务器处理。

使用演员模式时,您不应该将加入的演员视为外部 API,而应该将它们视为程序本身的扩展。这种模式非常强大,并且带有一个有趣的用例。热代码加载是指当应用程序的新版本替换旧版本时,应用程序仍在运行。通过您构建的演员模式,您可以修改actor.js / worker.js文件,修改现有的square_sum()方法,甚至添加新的方法。然后,您可以启动新的演员程序并终止旧的演员程序,主服务器将开始使用新的演员。

此外,值得注意的是,本节介绍的演员模型版本确实存在几个缺点,在实施类似内容于生产环境之前应该考虑到这些。首先,尽管演员进程内的各个演员是根据最不忙碌的来选择的,但演员进程本身是随机选择的。这可能会导致工作负载不均衡。为了解决这个问题,您需要一些协调机制来跟踪哪些演员是空闲的。

另一个缺点是,个别演员无法被其他演员寻址;事实上,一个演员不能从另一个演员调用代码。在架构上,这些进程类似于星形拓扑结构,其中演员进程严格连接到服务器进程。理想情况下,所有演员都可以相互连接,并且演员可以单独寻址彼此。

这种方法的一个重要优点是其弹性。采用本节介绍的方法,只有一个单一的 HTTP 服务器。如果服务器进程停止,整个应用程序也会停止运行。一个更具弹性的系统可能会使每个进程既是 HTTP 服务器又是 TCP 服务器,并且有一个反向代理将请求路由到所有进程。一旦进行了这些更改,您就接近于更健壮平台提供的演员模型实现。

^(1) 在实际操作中,餐馆可能会比订单轮处理的要忙。餐馆通常会通过一些诸如在同一个轮槽中插入多张订单纸,并在每个槽中达成某种约定顺序的技巧来解决这个问题。在我们的环形缓冲区中,我们不能将多个数据片段推送到一个数组槽中,因此无法使用同样的技巧。相反,一个更完整的系统应该有一种方法来指示队列已满,目前无法处理更多数据。正如您将看到的那样,我们将会正好做到这一点。

^(2) 演员模式的另一个值得注意的实现是在 Scala 语言中。

^(3) 大型消息,例如如果传递的是字符串而不是几个小数字,则可能会跨 TCP 消息边界分割,并以多个data事件到达。如果将此代码用于生产环境,请牢记这一点。

第七章:WebAssembly

尽管这本书的标题是 Multithreaded JavaScript,现代 JavaScript 运行时也支持 WebAssembly。对于不了解的人来说,WebAssembly(通常缩写为 WASM)是一种二进制编码的指令格式,运行在基于堆栈的虚拟机上。它设计时考虑了安全性,并在仅能访问内存和主机环境提供的函数的沙箱中运行。在浏览器和其他 JavaScript 运行时中运行的程序部分,它比 JavaScript 可以更快地执行。另一个目标是为通常编译的语言(如 C、C++ 和 Rust)提供一个编译目标。这为这些语言的开发者开发 Web 提供了机会。

通常,WebAssembly 模块使用的内存由 ArrayBuffers 表示,但也可以由 SharedArrayBuffers 表示。此外,还有用于原子操作的 WebAssembly 指令,类似于我们在 JavaScript 中使用的 Atomics 对象。通过 SharedArrayBuffers、原子操作和 Web Workers(或在 Node.js 中的 worker_threads),我们可以完成使用 WebAssembly 进行多线程编程的全套任务。

在我们深入讨论多线程 WebAssembly 之前,让我们构建一个“Hello, World!” 的例子并执行它,以找出 WebAssembly 的优势和限制。

你的第一个 WebAssembly

虽然 WebAssembly 是一个二进制格式,但存在一个纯文本格式来以人类可读的形式表示它。这类似于如何将机器码表示为人类可读的汇编语言。这种 WebAssembly 文本格式的语言简称为 WAT,但通常使用的文件扩展名是 .wat。它使用 S 表达式 作为其主要的语法分隔符,这对于解析和可读性都很有帮助。S 表达式主要来自 Lisp 系列语言,是由括号括起的嵌套列表,列表中的每个项之间有空白。

要感受这种格式,让我们在 WAT 中实现一个简单的加法函数。创建一个名为 ch7-wasm-add/add.wat 的文件,并添加 示例 7-1 的内容。

示例 7-1. ch7-wasm-add/add.wat
(module ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
  (func $add (param $a i32) (param $b i32) (result i32) ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
    local.get $a ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
    local.get $b
    i32.add)
  (export "add" (func $add)) ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/4.png)
)

1

第一行声明了一个模块。每个 WAT 文件都以这个开始。

2

我们声明了一个名为 $add 的函数,接受两个 32 位整数并返回另一个 32 位整数。

3

这是函数体的开始部分,在其中我们有三条语句。前两条语句获取函数参数并将它们依次放入堆栈中。请记住,WebAssembly 是基于堆栈的。这意味着许多操作将在堆栈的第一个(如果是一元操作)或前两个(如果是二元操作)项上进行。第三条语句是对 i32 值进行二元“add”操作,因此它从堆栈中获取顶部两个值并将它们相加,将结果放在堆栈顶部。函数的返回值是堆栈在完成时的顶部值。

4

为了在主机环境中使用模块外的函数,它需要被导出。在这里,我们导出了$add函数,并为其指定了外部名称add

我们可以使用 WebAssembly 二进制工具包(WABT)中的wat2wasm工具将此 WAT 文件转换为 WebAssembly 二进制文件。这可以通过ch7-wasm-add目录中的以下一行命令完成。

$ npx -p wabt wat2wasm add.wat -o add.wasm

现在我们有了我们的第一个 WebAssembly 文件!这些文件在主机环境外并不实用,所以让我们编写一点 JavaScript 来加载 WebAssembly 并测试add函数。将示例 7-2 的内容添加到ch7-wasm-add/add.js中。

示例 7-2. ch7-wasm-add/add.js
const fs = require('fs/promises'); // Needs Node.js v14 or higher.

(async () => {
  const wasm = await fs.readFile('./add.wasm');
  const { instance: { exports: { add } } } = await WebAssembly.instantiate(wasm);
  console.log(add(2, 3));
})();

如果您已经使用前面的wat2wasm命令创建了.wasm文件,那么您应该可以在ch7-wasm-add目录中运行它。

$ node add.js

您可以从输出中验证,我们确实通过我们的 WebAssembly 模块进行了加法操作。

堆栈上的简单数学运算不会使用线性内存或 WebAssembly 中没有意义的概念,比如字符串。考虑 C 语言中的字符串。实际上,它们只不过是指向以空字节结尾的字节数组的指针。我们不能通过值传递整个数组到 WebAssembly 函数或返回它们,但我们可以通过引用传递它们。这意味着要将字符串作为参数传递,我们需要首先在线性内存中分配字节并写入它们,然后将第一个字节的索引传递给 WebAssembly 函数。由于我们需要管理线性内存中的可用空间,这可能会变得更复杂。基本上,我们需要在线性内存上运行的malloc()free()实现。^(1)

在 WAT 中手写 WebAssembly 虽然显然可行,但通常不是提高效率和性能的最简单途径。它被设计为更高级语言的编译目标,这也是它真正闪耀的地方。“使用 Emscripten 将 C 程序编译为 WebAssembly”更详细地探讨了这一点。

WebAssembly 中的原子操作

虽然在这本书中详细介绍每个WebAssembly 指令并不合适,但值得指出的是与共享内存上的原子操作相关的指令,因为它们对于多线程的 WebAssembly 代码是关键的,无论是从其他语言编译还是手写 WAT。

WebAssembly 指令通常以类型开头。在原子操作的情况下,类型总是i32i64,分别对应 32 位和 64 位整数。所有原子操作在指令名称中紧跟.atomic.。之后,你会找到具体的指令名称。

让我们来看一些原子操作指令。我们不会详细介绍语法,但这应该让你对指令级别的操作类型有所了解:

[i32|i64].atomic.[load|load8_u|load16_u|load32_u]

load 指令系列相当于 JavaScript 中的 Atomics.load()。使用其中一个带后缀的指令允许您加载更小的位数,并使用零扩展结果。

[i32|i64].atomic.[store|store8|store16|store32]

store 指令系列相当于 JavaScript 中的 Atomics.store()。使用其中一个带后缀的指令将输入值包装到该位数,并将其存储在索引位置。

[i32|i64].atomic.[rmw|rmw8|rmw16|rmw32].[add|sub|and|or|xor|xchg|cmpxchg][|_u]

rmw 指令系列都执行读-修改-写操作,分别相当于 JavaScript 中 Atomics 对象的 add()sub()and()or()xor()exchange()compareExchange()。当它们进行零扩展时,操作后缀为 _u,并且 rmw 可以有与待读取位数相对应的后缀。

下面的两个操作有略微不同的命名约定:

memory.atomic.[wait32|wait64]

这些相当于 JavaScript 中的 Atomics.wait(),根据它们操作的位数后缀不同。

memory.atomic.notify

这相当于 JavaScript 中的 Atomics.notify()

这些指令足以在 WebAssembly 中执行与 JavaScript 中相同的原子操作,但 JavaScript 中没有的附加操作是:

atomic.fence

此指令不接受任何参数,也不返回任何内容。它旨在供具有保证非原子访问顺序方式的高级语言使用。

所有这些操作都与给定的 WebAssembly 模块的线性内存一起使用,这是一个允许读取和写入值的沙盒。当从 JavaScript 初始化 WebAssembly 模块时,可以选择使用线性内存进行初始化。这可以由SharedArrayBuffer支持,以便跨线程使用。

虽然在 WebAssembly 中使用这些指令是完全可能的,但它们遭受与 WebAssembly 其余部分相同的缺点:编写起来非常乏味和费力。幸运的是,我们可以将高级语言编译成 WebAssembly。

使用 Emscripten 将 C 程序编译为 WebAssembly

自 WebAssembly 诞生以来,Emscripten 一直是将 C 和 C++ 程序编译为 JavaScript 环境可用的首选方式。如今,它支持在浏览器中使用 web workers 和在 Node.js 中使用 worker_threads 来实现多线程的 C 和 C++ 代码。

实际上,在野外存在大量现有的多线程代码可以无缝地使用 Emscripten 编译,没有问题。在 Node.js 和浏览器中,Emscripten 模拟了编译为 WebAssembly 的本地代码使用的系统调用,以便以编译语言编写的程序可以在不进行太多更改的情况下运行。

的确,我们在 第一章 中编写的 C 代码可以毫无修改地编译!现在让我们试试。我们将使用 Docker 镜像来简化使用 Emscripten。对于其他编译器工具链,我们需要确保工具链与系统对齐,但由于 WebAssembly 和 JavaScript 都是跨平台的,我们可以在支持 Docker 的任何地方使用 Docker 镜像。

首先,请确保已安装 Docker。然后,在您的 ch1-c-threads 目录中,运行以下命令:

$ docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) \
  emscripten/emsdk emcc happycoin-threads.c -pthread \
  -s PTHREAD_POOL_SIZE=4 -o happycoin-threads.js

关于这个命令有几点需要讨论。我们正在运行 emscripten/emsdk 镜像,当前目录已挂载,并以当前用户身份运行。emcc 以及后续的内容是我们在容器内运行的命令。在大多数情况下,这看起来很像使用 cc 编译 C 程序时会做的事情。主要区别在于输出文件是 JavaScript 文件而不是可执行二进制文件。不用担心!还会生成一个 .wasm 文件。JS 文件被用作与必要系统调用的桥梁,并设置线程,因为这些无法仅通过 WebAssembly 实例化。

另一个额外的参数是 -s PTHREAD_POOL_SIZE=4。因为 happycoin-threads.c 使用了三个线程,我们在此提前分配它们。在 Emscripten 中处理线程创建有几种方法,主要是由于不会阻塞主浏览器线程。在这里预分配是最简单的,因为我们知道需要多少个线程。

现在我们可以运行多线程 Happycoin 的 WebAssembly 版本。我们将使用 Node.js 运行 JavaScript 文件。在撰写本文时,这要求 Node.js 版本为 v16 或更高,因为这是 Emscripten 输出支持的版本。

$ node happycoin-threads.js

输出结果应该类似于以下内容:

120190845798210000 ... [ 106 more entries ] ... 14356375476580480000
count 108
Pthread 0x9017f8 exited.
Pthread 0x701500 exited.
Pthread 0xd01e08 exited.
Pthread 0xb01b10 exited.

输出看起来与我们之前章节中的其他 Happycoin 示例相同,但 Emscripten 提供的包装还会告知我们线程何时退出。你还需要按 Ctrl+C 退出程序。为了额外的乐趣,看看你能否找出需要更改的内容,以使进程在完成时退出,并避免那些 Pthread 消息。

与 Happycoin 的原生或 JavaScript 版本进行比较时,你可能会注意到的一件事是时间。它显然比多线程 JavaScript 版本快,但比本机多线程 C 版本稍慢。总是很重要的是,通过测量你的应用程序,确保你获得了正确的利益与权衡。

虽然 Happycoin 示例不使用任何原子操作,但 Emscripten 支持完整的 POSIX 线程功能和 GNU 编译器集合(GCC)内置的原子操作函数。这意味着许多 C 和 C++ 程序可以使用 Emscripten 编译为 WebAssembly。

其他 WebAssembly 编译器

Emscripten 并不是将代码编译为 WebAssembly 的唯一方式。事实上,WebAssembly 主要设计为编译目标,而不是作为自身的通用语言。有许多工具可以将众所周知的语言编译为 WebAssembly,甚至有些语言是以 WebAssembly 为主要目标构建的,而不是机器码。这里列出了一些,但这并不是 详尽无遗。在这里你会注意到许多“在撰写时”,因为这个领域相对较新,创建多线程 WebAssembly 代码的最佳方法仍在开发中!至少,在撰写时是这样的。

Clang/Clang++

LLVM 的 C 家族编译器可以通过 -target wasm32-unknown-unknown-target wasm64-unknown-unknown 选项目标 WebAssembly。实际上,Emscripten 现在就是基于此的,其中 POSIX 线程和原子操作按预期工作。在撰写时,这是对多线程 WebAssembly 的一些最佳支持。虽然 clangclang++ 支持 WebAssembly 输出,但推荐的方法是使用 Emscripten,在浏览器和 Node.js 中获得完整的平台支持套件。

Rust

Rust 编程语言的编译器 rustc 支持生成 WebAssembly 输出。Rust 网站是使用 rustc很好的起点。要使用线程,可以使用 wasm-bindgen-rayon crate,它提供了使用 web workers 实现的并行 API。在撰写时,Rust 标准库的线程支持无法工作。

AssemblyScript

AssemblyScript 编译器以 TypeScript 的子集作为输入,然后生成 WebAssembly 输出。虽然它不支持生成线程,但它支持原子操作和使用 SharedArrayBuffers,因此只要你通过 web workers 或 worker_threads 在 JavaScript 侧处理线程本身,就可以在 AssemblyScript 中充分利用多线程编程。我们将在下一节详细介绍它。

当然,还有很多其他选项,新的选项也在不断出现。值得在网上看看,你选择的编译语言是否可以针对 WebAssembly,以及它是否支持在 WebAssembly 中的原子操作。

AssemblyScript

AssemblyScriptTypeScript 的一个子集,编译成 WebAssembly。与编译现有语言并提供现有系统 API 实现的方法不同,AssemblyScript 设计为一种以比 WAT 更熟悉的语法生成 WebAssembly 代码的方式。AssemblyScript 的一个主要卖点是许多项目已经使用 TypeScript,因此添加一些 AssemblyScript 代码以利用 WebAssembly 不需要进行太多的上下文切换,甚至学习完全不同的编程语言。

一个 AssemblyScript 模块看起来很像一个 TypeScript 模块。如果你不熟悉 TypeScript,可以将其视为普通的 JavaScript,但是在每个函数参数后面加上: number来指示类型信息。以下是执行加法的基本 TypeScript 模块:

export function add(a: number, b: number): number {
  return a + b
}

你会注意到,这几乎与普通的 ECMAScript 模块完全相同,唯一的区别是在每个函数参数后面以: number形式表示类型信息,并标识返回值的类型。TypeScript 编译器可以使用这些类型来检查调用此函数的任何代码是否传入了正确的类型,并且假设返回值的正确类型。

AssemblyScript 看起来基本相同,但不是使用 JavaScript 的 number 类型,而是为 WebAssembly 中的每种类型提供了内置类型。如果我们想在 TypeScript 中编写相同的加法模块,并假设在整个类型中都使用 32 位整数,它看起来会像是 示例 7-3。继续在名为 ch7-wasm-add/add.ts 的文件中添加它。

示例 7-3. ch7-wasm-add/add.ts
export function add(a: i32, b: i32): i32 {
  return a + b
}

由于 AssemblyScript 文件只是 TypeScript 文件,它们使用与原来相同的 .ts 扩展名。要将给定的 AssemblyScript 文件编译为 WebAssembly,我们可以使用 assemblyscript 模块中的 asc 命令。尝试在 ch7-wasm-add 目录中运行以下命令:

$ npx -p assemblyscript asc add.ts --binaryFile add.wasm

你可以尝试使用与 示例 7-2 中相同的 add.js 文件运行 WebAssembly 代码。输出应该与代码相同,因为代码是一样的。

如果省略 --binaryFile add.wasm,则会得到转换为 WAT 的模块,如 示例 7-4 所示。你会看到它与 示例 7-1 几乎相同。

示例 7-4. AssemblyScript 中 add 函数的 WAT 表示
(module
 (type $i32_i32_=>_i32 (func (param i32 i32) (result i32)))
 (memory $0 0)
 (export "add" (func $add/add))
 (export "memory" (memory $0))
 (func $add/add (param $0 i32) (param $1 i32) (result i32)
  local.get $0
  local.get $1
  i32.add
 )
)

AssemblyScript 不提供生成线程的能力,但可以在 JavaScript 环境中生成线程,并且 SharedArrayBuffers 可用于 WebAssembly 内存。最重要的是,它通过全局的 atomics 对象支持原子操作,这与常规 JavaScript 的 Atomics 并没有特别不同。主要区别在于,这些函数不是在 TypedArray 上操作,而是在 WebAssembly 模块的线性内存中进行操作,带有指针和可选偏移量。详情请参阅 AssemblyScript 文档

要查看其运行效果,让我们创建一个新的 Happycoin 示例实现,这是我们自第 第一章 以来不断迭代的一个例子。

AssemblyScript 幸福币

与我们之前的 Happycoin 示例版本类似,这种方法将数字的处理多路复用到多个线程,并将结果返回。这是多线程 AssemblyScript 工作方式的一个示例。在实际应用程序中,您可能希望利用共享内存和原子操作,但为了保持简单,我们将仅仅把工作分配到线程中。

让我们首先创建一个名为 ch7-happycoin-as 的目录,并切换到该目录。我们将初始化一个新项目,并按以下步骤添加一些必要的依赖项:

$ npm init -y
$ npm install assemblyscript
$ npm install @assemblyscript/loader

assemblyscript 包包含 AssemblyScript 编译器,而 assemblyscript/loader 包为我们提供了方便的工具,用于与构建的模块进行交互。

在新创建的 package.jsonscripts 对象中,我们将添加 "build""start" 属性,以简化程序的编译和运行:

"build": "asc happycoin.ts --binaryFile happycoin.wasm --exportRuntime",
"start": "node --no-warnings --experimental-wasi-unstable-preview1 happycoin.mjs"

额外的 --exportRuntime 参数为我们提供了一些与 AssemblyScript 值交互的高级工具。稍后我们会详细介绍这一点。

在调用 Node.js 的 "start" 脚本时,我们传递了实验性的 WASI 标志。这启用了 WebAssembly 系统接口 (WASI) 接口,使 WebAssembly 能够访问通常无法访问的系统级功能。我们将从 AssemblyScript 中使用它来生成随机数。由于写作时它仍处于实验阶段,我们将添加 --no-warnings 标志^(2) 来抑制因使用 WASI 而产生的警告。实验性状态还意味着该标志可能会在未来更改,因此请务必查阅运行的 Node.js 版本的 Node.js 文档。

现在,让我们来编写一些 AssemblyScript 吧!示例 7-5 包含了 Happycoin 算法的 AssemblyScript 版本。请继续将其添加到名为 happycoin.ts 的文件中。

示例 7-5. ch7-happycoin-as/happycoin.ts
import 'wasi'; ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)

const randArr64 = new Uint64Array(1);
const randArr8 = Uint8Array.wrap(randArr64.buffer, 0, 8); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
function random64(): u64 {
  crypto.getRandomValues(randArr8); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/3.png)
  return randArr64[0];
}

function sumDigitsSquared(num: u64): u64 {
  let total: u64 = 0;
  while (num > 0) {
    const numModBase = num % 10;
    total += numModBase ** 2;
    num = num / 10;
  }
  return total;
}

function isHappy(num: u64): boolean {
  while (num != 1 && num != 4) {
    num = sumDigitsSquared(num);
  }
  return num === 1;
}

function isHappycoin(num: u64): boolean {
  return isHappy(num) && num % 10000 === 0;
}

export function getHappycoins(num: u32): Array<u64> {
  const result = new Array<u64>();
  for (let i: u32 = 1; i < num; i++) {
    const randomNum = random64();
    if (isHappycoin(randomNum)) {
      result.push(randomNum);
    }
  }
  return result;
}

1

在这里导入了wasi模块,以确保加载适当的支持 WASI 的全局变量。

2

我们为随机数初始化了一个Uint64Array,但crypto.getRandomValues()只能使用Uint8Array,因此我们将在同一缓冲区上创建一个视图。此外,AssemblyScript 中的TypedArray构造函数没有重载,因此可以使用静态的wrap()方法从ArrayBuffer实例构造新的TypedArray实例。

3

这种方法是我们为 WASI 启用的方法。

如果你熟悉 TypeScript,你可能会认为这个文件看起来非常接近于“Happycoin: Revisited”的 TypeScript 移植版本。你是对的!这是 AssemblyScript 的主要优势之一。我们不是在一个全新的语言中编写代码,但我们编写的代码非常接近于 WebAssembly。请注意,导出函数的返回值类型为Array<u64>。WebAssembly 中的导出函数不能返回任何类型的数组,但可以返回模块内存的索引(实际上是一个指针),这正是这里发生的事情。我们可以手动处理这个问题,但正如我们将看到的,AssemblyScript 加载器使这一切变得更加容易。

当然,由于 AssemblyScript 本身不提供线程生成的方法,我们需要从 JavaScript 中进行操作。在这个示例中,我们将利用顶层await的 ECMAScript 模块,将示例 7-6 的内容放入名为happycoin.mjs的文件中。

示例 7-6. ch7-happycoin-as/happycoin.mjs
import { WASI } from 'wasi'; ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/1.png)
import fs from 'fs/promises';
import loader from '@assemblyscript/loader';
import { Worker, isMainThread, parentPort } from 'worker_threads';

const THREAD_COUNT = 4;

if (isMainThread) {
  let inFlight = THREAD_COUNT;
  let count = 0;
  for (let i = 0; i < THREAD_COUNT; i++) {
    const worker = new Worker(new URL(import.meta.url)); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/mltrd-js/img/2.png)
    worker.on('message', msg => {
      count += msg.length;
      process.stdout.write(msg.join(' ') + ' ');
      if (--inFlight === 0) {
        process.stdout.write('\ncount ' + count + '\n');
      }
    });
  }
} else {
  const wasi = new WASI();
  const importObject = { wasi_snapshot_preview1: wasi.wasiImport };
  const wasmFile = await fs.readFile('./happycoin.wasm');
  const happycoinModule = await loader.instantiate(wasmFile, importObject);
  wasi.start(happycoinModule);

  const happycoinsWasmArray =
    happycoinModule.exports.getHappycoins(10_000_000/THREAD_COUNT);
  const happycoins = happycoinModule.exports.__getArray(happycoinsWasmArray);
  parentPort.postMessage(happycoins);
}

1

这需要使用--experimental-wasi-unstable-preview1标志才能完成。

2

如果你对 ESM 不熟悉,这可能看起来有些奇怪。我们无法像在 CommonJS 模块中那样使用__filename变量。相反,import.meta.url属性以文件 URL 字符串的形式给出了完整路径。我们需要将其传递给URL构造函数,以便在Worker构造函数中使用。

改编自“Happycoin: Revisited”,我们再次检查是否在主线程中,并从主线程生成四个工作线程。在主线程中,我们只期望在默认MessagePort上收到一个消息,其中包含一个找到的 Happycoins 数组。我们简单地记录这些消息及其总数,一旦所有工作线程都发送了消息。

else侧,在工作线程中,我们初始化了一个 WASI 实例以传递给 WebAssembly 模块,然后使用@assemblyscript/loader实例化模块,从而得到处理从getHappycoins函数返回的数组返回值所需的内容。我们调用模块导出的getHappycoins()方法,这为我们提供了指向 WebAssembly 线性内存中数组的指针。加载器提供的__getArray函数将该指针转换为 JavaScript 数组,然后我们可以像往常一样使用它。我们将其传递给主线程进行输出。

要运行此示例,请执行以下两个命令。第一个将将 AssemblyScript 编译为 WebAssembly,第二个将通过刚刚组装的 JavaScript 运行它:

$ npm run build
$ npm start

输出结果与先前的 Happycoin 示例大致相同。以下是来自一次本地运行的输出:

7641056713284760000 ... [ 134 more entries ] ... 10495060512882410000
count 136

和所有这些解决方案一样,评估通过适当的基准测试所做的权衡是很重要的。作为练习,请尝试计时本书中其他 Happycoin 实现与此示例之间的差异。它更快还是更慢?你能找出原因吗?可以做出哪些改进?

^(1) 在 C 和其他没有自动内存管理的语言中,必须为使用类似malloc()的分配函数分配内存,然后使用类似free()的函数释放以供稍后分配使用。像垃圾收集这样的内存管理技术使得在高级语言(如 JavaScript)中编写程序变得更加容易,但它们并非 WebAssembly 的内置特性。

^(2) 通常情况下,这不是您想在生产应用程序中启用的标志。希望在您阅读本文时,WASI 支持不再处于实验阶段。如果是这种情况,请相应调整这些参数。

第八章:分析

到目前为止,您应该对使用 JavaScript 构建多线程应用程序非常熟悉,无论是在用户的浏览器中运行的代码,还是在您的服务器上运行的代码,甚至是同时使用两者的应用程序。尽管本书提供了许多用例和参考资料,但从未提到“你应该在应用程序中添加多线程”,这其中有一个重要的原因。

总体而言,向应用程序添加工作线程的主要原因是提高性能。但这种权衡是伴随着增加复杂性的代价而来的。KISS 原则,即“保持简单,愚蠢”,建议您的应用程序应该非常简单,以至于任何人都可以快速查看代码并理解它。在编写代码后能够阅读代码至关重要,而毫无目的地向程序添加线程则绝对违反了 KISS 原则。

有绝对充分的理由向应用程序添加线程,只要您在测量性能并确认速度提升超过增加的维护成本时,那么您就找到了值得添加线程的情况。但如何识别出线程是否有助于帮助而不需要实现它们的所有工作?又如何衡量性能影响?

不适合使用多线程的情况

多线程不是解决应用程序性能问题的万能药。在性能方面,它通常不是最低果实。因此,通常应作为最后的尝试。这在 JavaScript 中尤为真实,因为多线程并没有像其他语言那样被社区广泛理解。添加线程支持可能需要对应用程序进行大幅更改,这意味着如果您首先解决其他代码效率低下的问题,您的工作投入与性能收益可能会更高。

一旦完成了这些,并且你的应用在其他方面表现良好,你就会面临这样一个问题:“现在是添加多线程的好时机吗?”本节剩余部分列举了一些情况,其中添加线程很可能不会提供任何性能优势。这可以帮助你避免进行一些发现工作。

低内存约束

在 JavaScript 中实例化多个线程时会产生一些额外的内存开销。这是因为浏览器需要为新的 JavaScript 环境分配额外的内存——包括全局变量和您的代码可用的 API,以及引擎本身使用的底层内存。在 Node.js 正常服务器环境或者浏览器运行在强大的笔记本电脑环境下,这种开销可能是很小的。但如果您在 512 MB RAM 的嵌入式 ARM 设备上运行代码,或者在 K-12 教室捐赠的网络书籍上运行代码,则可能会成为一种阻碍。

额外线程的内存影响是多少?这有点难以量化,并且取决于 JavaScript 引擎和平台。安全的答案是,像大多数性能方面一样,应该在真实环境中进行测量。但我们当然可以尝试得到一些具体的数字。

首先,让我们考虑一个非常简单的 Node.js 程序,它只启动一个计时器,不引入任何第三方模块。该程序如下所示:

#!/usr/bin/env node

const { Worker } = require('worker_threads');
const count = Number(process.argv[2]) || 0;

for (let i = 0; i < count; i++) {
  new Worker(__dirname + '/worker.js');
}

console.log(`PID: ${process.pid}, ADD THREADS: ${count}`);
setTimeout(() => {}, 1 * 60 * 60 * 1000);

运行程序并测量内存使用如下所示:

# Terminal 1
$ node leader.js 0
# PID 10000

# Terminal 2
$ pstree 10000 -pa # Linux only
$ ps -p 10000 -o pid,vsz,rss,pmem,comm,args

pstree 命令显示程序使用的线程。它显示主要的 V8 JavaScript 线程,以及一些在 “隐藏线程” 中涵盖的后台线程。以下是该命令的输出示例:

node,10000 ./leader.js
  ├─{node},10001
  ├─{node},10002
  ├─{node},10003
  ├─{node},10004
  ├─{node},10005
  └─{node},10006

ps 命令显示有关进程的信息,特别是进程的内存使用情况。以下是该命令的输出示例:

  PID    VSZ   RSS  %MEM COMMAND    COMMAND
66766 1409260 48212   0.1 node      node ./leader.js

在这里用于测量程序内存使用的两个重要变量,均以千字节为单位。首先是 VSZ,或称虚拟内存大小,这是进程可以访问的内存,包括交换内存、分配内存,甚至由共享库(如 TLS)使用的内存,大约为 1.4 GB。接下来是 RSS,或称驻留集大小,即当前进程正在使用的物理内存量,大约为 48 MB。

测量内存可能有些主观,估算进程实际上可以容纳多少也有些棘手。在这种情况下,我们主要关注 RSS 值。

现在,让我们考虑程序的更复杂版本,使用线程。同样,将使用相同的简单计时器,但这次将创建总共四个线程。在这种情况下需要一个新的 worker.js 文件:

console.log(`WPID: ${process.pid}`);
setTimeout(() => {}, 1 * 60 * 60 * 1000);

运行 leader.js 程序时,如果输入一个大于 0 的数值参数,程序将创建额外的工作线程。表 8-1 列出了不同额外线程迭代的内存使用输出。

表 8-1. 使用 Node.js v16.5 时的线程内存开销

添加线程 VSZ RSS 大小
0 318,124 KB 31,836 KB 47,876 KB
1 787,880 KB 38,372 KB 57,772 KB
2 990,884 KB 45,124 KB 68,228 KB
4 1,401,500 KB 56,160 KB 87,708 KB
8 2,222,732 KB 78,396 KB 126,672 KB
16 3,866,220 KB 122,992 KB 205,420 KB

图 8-1 显示了 RSS 内存与线程数量之间的相关性。

线程数量与内存使用的比较

图 8-1. 随着每个额外线程的内存使用增加

根据这些信息,在使用 Node.js 16.5 和 x86 处理器时,每个新线程实例化的 RSS 内存开销约为 6 MB。再次强调,这个数字有些粗略,并且您需要在特定情况下进行测量。当线程引入更多模块时,内存开销会增加。如果在每个线程中实例化重型框架和 Web 服务器,可能会增加数百兆字节的内存开销。

警告

尽管越来越难以找到它们,但在 32 位计算机或智能手机上运行的程序具有最大可寻址内存空间为 4 GB 的限制。此限制适用于程序中的任何线程。

低核心数

在核心数较少的情况下,您的应用程序运行速度会变慢。如果机器只有一个核心,这一点尤其明显,如果有两个核心也可能如此。即使在应用程序中使用线程池并根据核心数扩展池,如果创建一个单工作线程,应用程序也会变慢。当创建额外的线程时,应用程序现在至少有两个线程(主线程和工作线程),这两个线程将竞争资源。

另一个导致您的应用程序变慢的原因是在线程之间进行通信时会增加额外开销。即使在单核和两个线程的情况下,即使两者不竞争资源,即主线程没有工作要做而工作线程在运行,反之亦然,在这两个线程之间进行消息传递时仍会存在开销。

这可能并不是什么大问题。例如,如果您创建了一个可在多个环境中运行的可分发应用程序,通常在多核系统上运行,很少在单核系统上运行,则此开销可能是可以接受的。但是,如果您正在构建一个几乎完全在单核环境中运行的应用程序,那么最好根本不添加线程。换句话说,您可能不应该构建一个利用您强大的多核开发笔记本电脑的应用程序,然后将其部署到只允许单核的生产环境中。

我们在谈论多少性能损失?在 Linux 操作系统上,向操作系统指示程序及其所有线程应仅在 CPU 核心的子集上运行非常直接。使用此命令可以让开发人员测试在低核心环境中运行多线程应用程序的效果。如果您使用的是基于 Linux 的计算机,请随意运行这些示例;否则,将提供摘要。

首先,返回到您在“线程池”中创建的ch6-thread-pool/示例。执行应用程序以创建包含两个工作线程的工作池:

$ THREADS=2 STRATEGY=leastbusy node main.js

注意,当线程池为 2 时,应用程序有三个可用的 JavaScript 环境,而libuv应该有一个默认的池大小为 5,因此 Node.js v16 版本会有大约 8 个线程。程序正在运行并且可以访问机器上的所有核心时,您可以准备运行快速基准测试。执行以下命令向服务器发送一连串的请求:

$ npx autocannon http://localhost:1337/

在这种情况下,我们只关注平均请求率,在输出的最后一个表格中,标识为 Req/Sec 行和 Avg 列。在一个样本运行中,返回了 17.5 的值。

使用 Ctrl+C 终止服务器并重新运行它。但是这次使用taskset命令来强制进程(及其所有子线程)使用相同的 CPU 核心:

# Linux only command
$ THREADS=2 STRATEGY=leastbusy taskset -c 0 node main.js

在这种情况下,设置了两个环境变量THREADSSTRATEGY,然后运行了taskset命令。-c 0标志告诉命令只允许程序使用第 0 个 CPU。随后跟随的参数被视为要运行的命令。请注意,taskset命令还可以用于修改已经运行的进程。当这种情况发生时,命令会显示一些有用的输出来告诉您发生了什么。当在具有 16 个核心的计算机上使用该命令时,以下是该输出的一份副本:

pid 211154's current affinity list: 0-15
pid 211154's new affinity list: 0

在这种情况下,程序曾经可以访问所有 16 个核心(0-15),但现在只能访问一个核心(0)。

程序运行并锁定到单个 CPU 核心以模拟可用核心较少的环境后,再次运行相同的基准测试命令:

$ npx autocannon http://localhost:1337/

在一个这样的运行中,平均每秒请求减少到了 8.32。这意味着,当尝试在单核心环境中使用三个 JavaScript 线程时,该特定程序的吞吐量与访问所有核心时相比,性能下降了 48%!

一个自然的问题可能是:为了最大化ch6-thread-pool应用程序的吞吐量,线程池应该有多大,应用程序应该提供多少个核心?为了找到答案,应用程序的基准测试被应用了 16 种排列,并且测量了性能。为了帮助减少任何异常请求,测试的长度增加到了两分钟。此数据的表格版本在表 8-2 中提供。

表 8-2. 可用核心与线程池大小及其对吞吐量的影响

1 核心 2 核心 3 核心 4 核心
1 个线程 8.46 9.08 9.21 9.19
2 个线程 8.69 9.60 17.61 17.28
3 个线程 8.23 9.38 16.92 16.91
4 个线程 8.47 9.57 17.44 17.75

数据的图表已在图 8-2 中重新生成。

在这种情况下,当线程池中专用于的线程数至少为两个,而应用程序可用的核心数至少为三个时,显然会带来性能上的显著好处。除此之外,数据中并没有太多有趣的内容。在实际应用程序中测量核心与线程的影响时,您可能会看到更多有趣的性能权衡。

这些数据提出的一个问题是:为什么添加超过两个或三个线程不会使应用程序运行更快?要回答这类问题需要假设、尝试应用程序代码,并试图消除任何瓶颈。在这种情况下,可能是主线程忙于协调、处理请求和与线程通信,导致工作线程无法有效完成工作。

两个线程和三个核心

图 8-2. 可用核心与线程池大小的关系及其对吞吐量的影响

容器与线程

在编写服务器软件时,比如使用 Node.js,一个经验法则是进程应该进行水平扩展。这是一个复杂的术语,意味着您应该以隔离的方式运行多个程序的冗余版本,例如在 Docker 容器中。水平扩展有助于性能,使开发人员可以对整个应用程序群体的性能进行微调。当缩放基元以线程池的形式发生在程序内部时,这种调优并不容易执行。

编排器(如 Kubernetes)是跨多台服务器运行容器的工具。它们使得根据需求轻松扩展应用程序;在假期季节期间,工程师可以手动增加运行实例的数量。编排器还可以根据 CPU 使用率、流量吞吐量甚至工作队列的大小等启发式动态调整规模。

如果在运行时在应用程序内执行动态缩放,会是什么样子?显然,可用的线程池需要重新调整大小。还需要一些通信机制,允许工程师发送消息给进程来调整池大小;也许需要一个额外的服务器监听端口以接收此类管理命令。这种功能需要在应用程序代码中增加额外的复杂性。

虽然增加进程而不是增加线程数会增加整体资源消耗,更不用说将进程封装在容器中的开销了,但更大的公司通常更喜欢这种方法的缩放灵活性。

使用时机

有时候,您可能会走运,会遇到从多线程解决方案中获益良多的问题。以下是一些此类问题的最直接特征,需要特别留意:

令人尴尬的并行

这是一个问题类别,一个大任务可以分解为较小的任务,并且几乎不需要或根本不需要共享状态。其中一个这样的问题是“示例应用:康威生命游戏”中涉及的生命游戏模拟。对于这个问题,游戏网格可以被细分为较小的网格,每个网格可以分配给一个单独的线程。

大量数学

另一个适合线程的问题的特征是那些涉及大量使用数学,也就是 CPU 密集型工作的问题。可以说计算机所做的一切都是数学,但是一个数学密集型应用的反面是 I/O 重型应用,或者主要处理网络操作的应用。考虑一个密码哈希破解工具,该工具有一个弱 SHA1 密码的摘要。这样的工具可能通过对每个可能的 10 个字符密码组合运行安全哈希算法 1 (SHA1) 来工作,这确实需要大量的数学计算。

MapReduce 友好的问题

MapReduce 是受函数式编程启发的编程模型。这种模型通常用于跨多台不同机器分布的大规模数据处理。MapReduce 被分成两部分。第一部分是 Map,它接受一组值并生成一组值。第二部分是 Reduce,在这里再次迭代值列表,并生成一个单一的值。可以使用 JavaScript 中的 Array#map()Array#reduce() 创建单线程版本,但多线程版本需要不同的线程处理数据列表的子集。搜索引擎使用 Map 扫描数百万篇文档中的关键字,然后使用 Reduce 对其进行评分和排名,为用户提供相关结果页面。像 Hadoop 和 MongoDB 这样的数据库系统受益于 MapReduce。

图形处理

许多图形处理任务也受益于多线程。就像“生命游戏”问题一样,该问题在一个细胞网格上运行,图像也被表示为像素网格。在这两种情况下,每个坐标的值可以表示为一个数字,尽管“生命游戏”使用单个 1 位数字,而图像更可能使用 3 或 4 个字节(红色、绿色、蓝色和可选的 alpha 透明度)。图像过滤变成了将图像细分为更小的图像,使用线程池中的线程并行处理这些小图像,然后在更改完成后更新界面。

这并不是您应该使用多线程的所有情况的完整列表;这只是一些最明显的用例列表。

一个重复的主题是不需要共享数据,或者至少不需要协调读写共享数据的问题更容易使用多线程建模。虽然编写没有太多副作用的代码通常是有益的,但在编写多线程代码时,这种好处会加倍。

JavaScript 应用程序特别有益的另一个用例是模板渲染。根据所使用的库,模板的渲染可能是使用表示原始模板的字符串和包含变量以修改模板的对象来完成的。在这种用例中,通常没有太多全局状态需要考虑,只有两个输入,而返回一个单个字符串输出。这是流行的模板渲染包mustachehandlebars的情况。将模板渲染从 Node.js 应用程序的主线程中转移似乎是获得性能的一个合理地方。

让我们测试这个假设。创建一个名为ch8-template-render/的新目录。在这个目录中,从示例 6-3 中复制并粘贴现有的ch6-thread-pool/rpc-worker.js文件。虽然文件不经修改也能正常工作,但您应该注释掉console.log()语句,以免减慢基准测试的速度。

您还需要初始化一个 npm 项目并安装一些基本包。您可以通过运行以下命令来实现这一点:

$ npm init -y
$ npm install fastify@3 mustache@4

接下来,创建一个名为server.js的文件。这代表一个 HTTP 应用程序,当收到请求时执行基本的 HTML 渲染。这个基准测试将使用一些真实世界的包,而不是加载所有内置模块。从示例 8-1 的内容开始编写文件。

示例 8-1。ch8-template-render/server.js(第一部分)
#!/usr/bin/env node
// npm install fastify@3 mustache@4

const Fastify = require('fastify');
const RpcWorkerPool = require('./rpc-worker.js');
const worker = new RpcWorkerPool('./worker.js', 4, 'leastbusy');
const template = require('./template.js');
const server = Fastify();

文件首先实例化了 Fastify Web 框架,以及一个具有四个工作线程的工作池。该应用程序还加载了一个名为template.js的模块,该模块将用于渲染 Web 应用程序使用的模板。

现在,您已经准备好声明一些路由,并告诉服务器监听请求。继续编辑文件,将示例 8-2 中的内容添加到其中。

示例 8-2。ch8-template-render/server.js(第二部分)
server.get('/main', async (request, reply) =>
  template.renderLove({ me: 'Thomas', you: 'Katelyn' }));

server.get('/offload', async (request, reply) =>
  worker.exec('renderLove', { me: 'Thomas', you: 'Katelyn' }));

server.listen(3000, (err, address) => {
  if (err) throw err;
  console.log(`listening on: ${address}`);
});

应用程序引入了两条路由。第一条是GET /main,将在主线程中执行请求的渲染。这代表了一个单线程应用程序。第二条路由是GET /offload,其中渲染工作将被转移到一个单独的工作线程。最后,服务器被指示监听端口 3000。

此时,应用程序在功能上已经完成。但作为额外的奖励,能够量化服务器正在忙碌处理的工作量将是很好的。虽然我们主要可以通过使用 HTTP 请求基准测试来测试此应用程序的效率,但有时也很好看看其他数字。添加示例 8-3 中的内容来完成文件。

示例 8-3。ch8-template-render/server.js(第三部分)
const timer = process.hrtime.bigint;
setInterval(() => {
  const start = timer();
  setImmediate(() => {
    console.log(`delay: ${(timer() - start).toLocaleString()}ns`);
  });
}, 1000);

此代码使用 setInterval 调用,每秒运行一次。它包装了一个 setImmediate() 调用,在调用前后测量当前时间的纳秒数。这并非完美,但这是一种近似当前进程负载的方法。随着进程事件循环变得更加繁忙,报告的数字也会更高。事件循环的繁忙程度还会影响整个过程中异步操作的延迟。因此,保持这个数字较低与应用程序的性能更加相关。

接下来,创建一个名为 worker.js 的文件。将内容从 示例 8-4 添加到其中。

示例 8-4. ch8-template-render/worker.js
const { parentPort } = require('worker_threads');
const template = require('./template.js');

function asyncOnMessageWrap(fn) {
  return async function(msg) {
    parentPort.postMessage(await fn(msg));
  }
}

const commands = {
  renderLove: (data) => template.renderLove(data)
};

parentPort.on('message', asyncOnMessageWrap(async ({ method, params, id }) => ({
  result: await commandsmethod, id
})));

这是您之前创建的工作文件的修改版本。在这种情况下,只使用一个命令 renderLove(),它接受一个包含键值对的对象,供模板渲染函数使用。

最后,创建一个名为 template.js 的文件,并将内容从 示例 8-5 添加到其中。

示例 8-5. ch8-template-render/template.js
const Mustache = require('mustache');
const love_template = "<em>{{me}} loves {{you}}</em> ".repeat(80);

module.exports.renderLove = (data) => {
  const result = Mustache.render(love_template, data);
  // Mustache.clearCache();
  return result;
};

在实际应用中,该文件可能用于从磁盘读取模板文件并替换值,暴露出完整的模板列表。对于这个简单示例,只导出一个单一模板渲染器并使用一个硬编码模板。此模板使用两个变量 meyou。字符串多次重复以接近实际应用可能使用的模板长度。模板越长,渲染时间越长。

现在文件已创建好,可以准备运行应用程序了。运行以下命令启动服务器,然后对其进行基准测试:

# Terminal 1
$ node server.js

# Terminal 2
$ npx autocannon -d 60 http://localhost:3000/main
$ npx autocannon -d 60 http://localhost:3000/offload

在一台强大的 16 核笔记本上进行的测试中,当完全在主线程中渲染模板时,应用程序的平均吞吐量为每秒 13,285 个请求。然而,当将模板渲染任务转移到工作线程时,同样的测试的平均吞吐量达到每秒 18,981 个请求。在这种情况下,吞吐量增加了约 43%。

事件循环延迟也显著减少。在进程空闲时调用 setImmediate() 的平均时间约为 87 μs。当在主线程执行模板渲染时,延迟平均为 769 μs。将渲染任务转移到工作线程后,同样的样本平均值为 232 μs。从这两个值中减去空闲状态的时间意味着使用线程时的性能提升约为 4.7 倍。图 8-3 在 60 秒基准测试期间比较了这些样本。

单线程时,事件循环总是进一步延迟

图 8-3. 在单线程与多线程使用时的事件循环延迟

这是否意味着您应该立即重构应用程序,将渲染工作移交给另一个线程?未必。通过这个假设的例子,应用程序在增加线程后变得更快,但这是在一台 16 核的机器上完成的。您的生产应用程序很可能只能访问更少的核心。

尽管如此,在测试过程中性能差异最大的是模板的大小。当它们较小,如不重复字符串时,使用单个线程渲染模板会更快。之所以会变慢,是因为在线程之间传递模板数据的开销要比渲染微小模板所需的时间大得多。

就像所有基准测试一样,需要以一颗谷物的心态看待这个。您需要在生产环境中测试这些更改,以确保是否从额外的线程中受益。

注意事项总结

这是在 JavaScript 中使用线程时需注意的几个注意事项的综合列表:

复杂性

使用共享内存时,应用程序往往更加复杂。如果您手动使用 Atomics 进行调用并手动处理 SharedBufferArray 实例,则特别如此。诚然,通过使用第三方模块,可以隐藏应用程序的大部分复杂性。在这种情况下,可以以清晰的方式表示您的工作线程,与主线程通信,并将所有的互通和协调抽象化处理。

存储器开销

每增加一个线程都会增加程序的额外内存开销。如果在每个线程中加载了大量模块,则会使内存开销进一步增加。虽然在现代计算机上内存开销可能不是一个大问题,但最终运行代码的硬件上进行测试仍然是值得的。帮助缓解此问题的一种方法是审查在单独线程中加载的代码。确保您不会不必要地加载太多内容!

无共享对象

不能在线程之间共享对象可能会导致将单线程应用程序轻松转换为多线程应用程序变得困难。相反,当涉及到对象变异时,您需要传递消息,以便最终变异一个存在于单一位置的对象。

不进行 DOM 访问

仅浏览器应用程序的主线程可以访问 DOM。这可能会使将 UI 渲染任务转移到另一个线程变得困难。尽管如此,主线程可以负责 DOM 变化,而其他线程可以进行大量的计算工作,并将数据变化返回给主线程以更新 UI 是完全可能的。

修改后的 API

与缺乏 DOM 访问的情况类似,可用的线程 API 有一些细微的变化。在浏览器中,这意味着不能调用 alert(),而且各个工作线程类型还有更多的规则,比如不允许阻塞 XMLHttpRequest#open() 请求、localStorage 限制、顶级 await 等。虽然有些问题看起来比较边缘,但这意味着并不是所有代码都能在所有可能的 JavaScript 环境中未经修改地运行。当处理这些问题时,文档是你的朋友。

结构化克隆算法的限制。

结构化克隆算法有一些限制,可能会使得在不同线程之间传递某些类实例变得困难。目前,即使两个线程访问的是相同的类定义,传递给线程的类实例也会变成普通的 Object 实例。虽然可以将数据重新恢复为类实例,但这确实需要手动操作。

浏览器需要特殊的头信息。

在浏览器中通过 SharedArrayBuffer 操作共享内存时,服务器必须在页面使用的 HTML 文档请求中提供两个额外的头信息。如果你完全控制服务器,那么这些头信息可能很容易添加。然而,在某些托管环境中,可能很难或不可能提供这些头信息。即使是这本书中用来托管本地服务器的包也需要修改才能启用这些头信息。

线程准备就绪检测。

没有内置功能来知道一个被创建的线程何时准备好与共享内存工作。相反,必须首先构建一个解决方案,基本上是向线程发送 ping,然后等待收到响应。

附录。结构化克隆算法

结构化克隆算法是 JavaScript 引擎在使用特定 API 复制对象时采用的一种机制。尤其是在传递数据给工作线程时,它被广泛应用,尽管其他 API 也在使用。通过这种机制,数据被序列化,然后在另一个 JavaScript 领域内作为对象进行反序列化。

当以这种方式克隆对象(例如从主线程到工作线程或从一个工作线程到另一个)时,在一侧修改对象不会影响另一侧的对象。现在数据实际上有两个副本。结构化克隆算法的目的是为开发人员提供比 JSON.stringify 更友好的机制,同时施加合理的限制。

在浏览器和 Node.js 之间复制数据时,会使用结构化克隆算法。类似地,Node.js 在工作线程之间复制数据时也会使用它。基本上,当您看到 .postMessage() 调用时,传递的数据是以这种方式克隆的。浏览器和 Node.js 遵循相同的规则,但它们都支持可以复制的额外对象实例。

作为一个快速的经验法则,任何可以干净地表示为 JSON 的数据都可以安全地通过这种方式进行克隆。遵循以这种方式表示的数据将确保几乎没有令人惊讶的情况。即便如此,结构化克隆算法还支持其他几种类型的数据。

首先,除了 Symbol 类型之外,JavaScript 中所有的原始数据类型都可以表示。这包括 BooleannullundefinedNumberBigIntString 类型。

ArrayMapSet 的实例,它们分别用于存储数据集合,也可以以这种方式进行克隆。甚至存储二进制数据的 ArrayBufferArrayBufferViewBlob 实例也可以传递。

一些更复杂的对象实例,只要它们是相当通用和被广泛理解的,也可以通过。这包括使用 BooleanString 构造函数创建的对象,Date,甚至 RegExp 实例。^(1)

在浏览器端,像 FileFileListImageBitmapImageData 这样更复杂且不太为人知的对象实例也可以被克隆。

在 Node.js 方面,可以复制的特殊对象实例包括 WebAssembly.ModuleCryptoKeyFileHandleHistogramKeyObjectMessagePortnet.BlockListnet.SocketAddressX509Certificate。甚至可以复制 R⁠e⁠a⁠da⁠b⁠l⁠e⁠S⁠t⁠r⁠e⁠a⁠mWritableStreamTransformStream 的实例。

另一个与结构化克隆算法兼容但与 JSON 对象不兼容的显著差异是,递归对象(具有引用另一个属性的嵌套属性的对象)也可以被克隆。一旦遇到重复的嵌套对象,算法就足够智能地停止对对象的序列化。

有一些限制可能会影响您的实现。首先,不能以这种方式克隆函数。函数可能是非常复杂的东西。例如,它们有一个可用的作用域并且可以访问在它们外部声明的变量。在不同领域之间传递这样的内容并没有太多意义。

另一个缺失的特性可能会影响您的实现,即浏览器中的 DOM 元素不能传递。这是否意味着 Web Worker 执行的工作不能显示给用户在 DOM 中?绝对不是。相反,您需要让 Web Worker 返回一个值,然后主 JavaScript Realm 能够转换并显示给用户。例如,如果在 Web Worker 中计算fibonacci的 1,000 次迭代,则可以返回数值,并且调用 JavaScript 代码可以取得该值并将其放置在 DOM 中。

JavaScript 中的对象相当复杂。有时可以使用对象字面语法创建它们。其他时候可以通过实例化基类来创建它们。还有时可以通过设置属性描述符和设置器/获取器来修改它们。在结构化克隆算法中,只保留对象的基本值。

大多数显著的是,这意味着,当您定义自己的类并传递一个实例进行克隆时,只会克隆该实例的自有属性,结果对象将是Object的一个实例。原型中定义的属性也不会被克隆。即使您在调用端和 Web Worker 内部都定义了class Foo {},值仍然是Object的一个实例。这是因为没有真正的方法来保证克隆的双方处理的是完全相同的Foo类。^(2)

某些对象将完全拒绝被克隆。例如,如果尝试将window从主线程传递到工作线程,或者反向尝试返回self,您可能会收到以下错误之一,具体取决于浏览器:

Uncaught DOMException: The object could not be cloned.
DataCloneError: The object could not be cloned.

在不同的 JavaScript 引擎之间存在一些不一致,因此最好在多个浏览器中测试您的代码。例如,Chrome 和 Node.js 支持克隆Error实例,但当前 Firefox 不支持。^(3) 一般的经验法则是 JSON 兼容的对象通常不会有问题,但更复杂的数据可能会有。因此,传递更简单的数据通常是最好的选择。

^(1) 对于RegExp实例存在一个小的特例。它们包含一个.lastIndex属性,该属性在多次运行正则表达式时用于知道表达式最后结束的位置。此属性不会被传递。

^(2) 已有提案允许序列化和反序列化类实例,比如“JavaScript 对象的用户定义结构化克隆”,因此这一限制可能不是永久性的。

^(3) Firefox 最终计划支持这一功能。参见“允许结构化克隆原生错误类型”

posted @ 2025-11-14 20:42  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报