JavaScript-并发指南-全-

JavaScript 并发指南(全)

原文:zh.annas-archive.org/md5/7e924b9f647bce94394717ac6228b30d

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

不久前,我还会害怕使用我依赖的许多 Web 应用程序。当它们工作良好时,它们是出色的;当它们不工作时,那是一场噩梦。特别是令人沮丧的是,驱动应用程序的 JavaScript 代码中并没有任何错误。不,问题通常是因为处理大量数据集而运行了太多的代码。最终结果总是相同的:UI 会冻结,我会无助地诅咒 Web。

现在,这种情况并不常见。我们已经修复了许多年前常见的 JavaScript 问题。我没有期望得那么快普及的是 JavaScript 的并发。在我们的应用程序中散布着一些并发片段,但我们很少看到真正的并发 JavaScript 代码。

让我们改变现状。

本书涵盖的内容

第一章,为什么需要 JavaScript 并发?,是关于 JavaScript 并发的一个介绍。

第二章,JavaScript 执行模型,将带你了解运行我们的 JavaScript 代码的机制。

第三章,使用 Promises 进行同步,探讨了使用 Promises 的同步技术。

第四章,使用 Generators 进行懒加载,将帮助你通过懒加载来节省资源。

第五章,与 Workers 一起工作,探讨了在 JavaScript 中实现真正的并行性。

第六章,实用的并行性,将帮助你识别正确的并行化问题来解决。

第七章,并发抽象,将使你亲自动手编写类似常规代码的并发代码。

第八章,使用 NodeJS 的事件驱动 IO,将展示在这个环境中并发语义是如何工作的。

第九章,高级 NodeJS 并发,是学习特定的 Node 并发问题。

第十章,构建并发应用程序,是关于如何将所有内容整合在一起。

你需要为本书准备的内容

本书的要求如下:

  • 任何主流浏览器的最新版本

  • NodeJS(至少 4.0 版本)

  • 一个代码编辑器

本书面向的对象

《JavaScript 并发》是为任何想要学习如何编写更高效、更强大、更易于维护的应用程序,并利用 JavaScript 语言最新发展的 JavaScript 开发者而编写的。

从第一原理出发,涵盖了并发、异步和并行编程的所有方面,到本书结束时,你将能够创建一个利用书中涵盖的所有主题的完整应用程序。

规范

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名如下所示:“我们可以通过使用include指令来包含其他上下文。”

代码块设置如下:

// Loads the worker script, and starts the
// worker thread.
var worker = new Worker('worker.js');

新术语重要词汇以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,在文本中会这样显示:“点击下一步按钮将您带到下一屏幕。”

注意

警告或重要注意事项如下所示。

小贴士

小技巧和技巧如下所示。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

要发送给我们一般性的反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及本书的标题。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您是 Packt 出版社书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从您在www.packtpub.com的账户下载示例代码文件,适用于您购买的所有 Packt 出版社的书籍。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

错误清单

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息来报告。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误部分下的现有错误清单中。

要查看之前提交的勘误表,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

盗版

在互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者和我们提供有价值内容的能力方面所提供的帮助。

询问

如果您对本书的任何方面有问题,您可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决问题。

第一章. 为什么需要 JavaScript 并发?

JavaScript 并不是一个与并发相关的语言。实际上,它通常与完全相反的并发挑战相关联。然而,在过去的几年里,这种情况发生了很大的变化,尤其是在 ES 2015 中引入了新的语言特性。Promise 在 JavaScript 中已经使用了多年;而现在,它们已经成为了一种原生类型。Generators 是语言中的另一个补充,它改变了我们思考 JavaScript 中并发方式的方式。Web workers 已经在浏览器中存在了几年,但我们很少看到它们被使用。也许,这更多与 workers 无关,而与我们对于并发在我们应用程序中扮演的角色理解有关。

本章的目标是探讨一些通用的并发思想,从并发究竟是什么开始。如果你没有任何形式的并发编程背景,那没关系,因为本章是你完美的起点。如果你过去使用 JavaScript 或其他语言进行过并发编程,那么将本章视为一个复习,只是以 JavaScript 为背景。

我们将本章总结为一些基本的并发原则。这些是有价值的编程工具,我们在编写并发代码时应该将其放在心中。一旦我们学会了应用这些原则,它们将告诉我们我们的并发设计是否正确,或者我们需要退一步,问问自己我们真正想要实现什么。这些原则采用自上而下的方法来设计我们的应用程序。这意味着它们适用于从开始到结束的整个过程。在整个书中,我们将参考这些原则,所以如果你只阅读本章的一个部分,确保是最后的并发原则

同步 JavaScript

在我们开始构建大规模并发 JavaScript 架构之前,让我们将注意力转向我们所有人都熟悉的经典同步 JavaScript 代码。这些是作为点击事件的结果而被调用的 JavaScript 代码块,或者作为加载网页的结果而运行。一旦开始,它们就不会停止。也就是说,它们是运行至完成的。我们将在下一章更深入地探讨运行至完成的概念。

注意

我们会在章节中偶尔看到同步串行这两个术语被交替使用。它们都指的是依次运行的代码语句,直到没有更多可运行的内容为止。

尽管 JavaScript 被设计为单线程、运行至完成的环境,但网络的本质使得这一点变得复杂。想想看网络浏览器及其所有移动部件。有用于渲染用户界面的文档对象模型DOM)和用于获取远程数据源的XMLHttpRequestXHR)对象,仅举两例。让我们来看看 JavaScript 的同步特性和网络的异步特性。

同步性容易理解

当代码是同步的,它更容易理解。它更容易将我们在屏幕上看到的指令映射到我们头脑中的顺序步骤;这样做,然后那样做;检查这个,如果为true,则那样做,等等。这种串行处理类型足够容易理解,因为没有惊喜,假设代码不是完全糟糕。以下是我们可能如何可视化一段同步代码的示例:

同步性容易理解

另一方面,并发编程并不容易掌握。这是因为我们的代码编辑器中没有线性逻辑可以遵循。相反,我们不断地跳来跳去,试图将这段代码相对于那段代码的行为映射出来。时间是在并发设计中一个重要的因素;这是与大脑自然理解代码的方式相悖的。当我们阅读代码时,我们自然会将其在脑海中执行。这就是我们弄清楚它在做什么的方式。当实际执行与我们在脑海中想象的不一致时,这种方法就会失效。通常,代码读起来像一本书——并发代码就像一本页码编号但顺序混乱的书。让我们看看一些简单的伪 JavaScript 代码:

var collection = [ 'a', 'b', 'c', 'd' ];
var results = [];

for (let item of collection) {
    results.push(String.fromCharCode(item.charCodeAt(0)));
}
//    [ 'b', 'c', 'd', 'e' ]

在传统的多线程环境中,线程是与其他线程异步运行的某个东西。我们使用线程来利用大多数系统上找到的多个 CPU,从而提高性能。然而,这也有代价,因为它迫使我们重新思考代码在运行时的执行方式。不再是通常的逐步执行。这段代码可能在与另一个 CPU 上的其他代码并行运行,或者它可能在与同一 CPU 上的其他线程争夺时间。

当我们将并发引入同步代码时,很多简单性都会消失——这是代码中的“大脑冻结”。这就是我们编写并发代码的原因:代码预先假设并发。随着我们通过本书的进展,我们将详细阐述这个概念。对于 JavaScript 来说,假设并发设计很重要,因为这就是网络的工作方式。

异步性是不可避免的

JavaScript 中的并发之所以如此重要,是因为网络在非常高的层次和实现细节层面上都是并发的。换句话说,网络之所以是并发的,是因为在任何给定的时间点,都有大量的数据在跨越全球的纤维中流动。这与部署到网络浏览器的应用程序本身有关,以及后端服务器如何处理对数据的请求清单。

异步浏览器

让我们更仔细地看看浏览器和其中发现的异步操作类型。当用户加载网页时,页面将执行的第一项操作之一是下载并评估与页面一起的 JavaScript 代码。这本身就是一个异步操作,因为当我们的代码下载时,浏览器将继续执行其他任务,例如渲染页面元素。

另一个通过网络到达的异步数据源是应用程序数据本身。一旦我们的页面加载完成并且我们的 JavaScript 代码开始运行,我们就需要向用户展示一些数据。这实际上是我们的代码要做的第一件事,以便用户可以立即看到一些内容。同样,当我们等待这些数据到达时,JavaScript 引擎会继续执行我们的代码,直到到达下一组指令。以下是一个请求远程数据但不等待响应就继续执行代码的示例:

异步浏览器

在页面元素全部渲染并填充数据后,用户开始与我们的页面交互。这意味着会触发事件——点击一个元素会触发点击事件。触发这些事件的 DOM 环境是一个沙盒环境。这意味着在浏览器中,DOM 是一个子系统,与运行我们代码的 JavaScript 解释器分开。这种分离使得某些 JavaScript 并发场景特别困难。我们将在下一章深入探讨这些问题。

在所有这些异步源的影响下,我们的页面可能会因为处理不可避免出现的边缘情况而变得臃肿。异步思考并不自然,因此这种同步思维的结果很可能是这种猴子补丁式的修改。最好接受网络的异步本质。毕竟,同步的网络可能导致无法忍受的用户体验。现在,让我们进一步探讨我们可能在 JavaScript 架构中遇到的并发类型。

并发类型

JavaScript 是一种运行至完成的语言。这一点无法避免,尽管在其之上可能添加了任何并发机制。换句话说,我们的 JavaScript 代码在 if 语句的中间不会将控制权交给另一个线程。这一点之所以重要,是因为我们可以选择一个合理的抽象层次,帮助我们思考 JavaScript 并发。让我们看看在 JavaScript 代码中发现的两种并发动作类型。

异步动作

异步动作的一个定义特征是它们不会阻塞后续的其他动作。异步动作并不一定意味着“触发并忘记”。相反,当我们等待的动作部分完成时,我们运行一个回调函数。这个回调函数与我们的代码的其他部分不同步;因此,术语异步。

在网络前端,这通常意味着从远程服务获取数据。这些获取动作相对较慢,因为它们必须穿越网络连接。这些动作异步进行是有意义的,仅仅因为我们的代码正在等待某些数据返回以便触发回调函数,并不意味着用户必须坐着等待。此外,用户当前查看的任何屏幕都不太可能只依赖于一个远程资源。因此,顺序处理多个远程获取请求会对用户体验产生不利影响。

这里有一个关于异步代码的一般概念:

var request = fetch('/foo');

request.addEventListener((response) => {
    // Do something with "response" now that it has arrived.
});

// Don't wait with the response, update the DOM immediately.
updateUI();

小贴士

下载示例代码

您可以从您在 www.packtpub.com 的账户下载示例代码文件,以获取您购买的所有 Packt 出版物的所有示例代码。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。

我们不仅限于从远程数据源获取数据,作为异步动作的唯一来源。当我们发起网络请求时,这些异步控制流实际上离开了浏览器。但是,关于限制在浏览器内的异步动作怎么办?以 setTimeout() 函数为例。它遵循与网络获取请求相同的回调模式。函数传递一个回调,该回调在稍后执行。然而,没有任何东西离开浏览器。相反,动作被排队在任意数量的其他动作之后。这是因为异步动作仍然只是由一个 CPU 执行的一个控制线程。这意味着随着我们的应用程序规模和复杂性的增长,我们面临着并发扩展问题。但是,也许异步动作并不是为了解决单 CPU 问题而设计的。

也许更好地思考在单个 CPU 上执行的异步操作的方式是将它们想象成一个杂技演员。杂技演员的大脑是 CPU,协调他的动作。被抛来抛去的球是我们操作的数据。我们只关心两种基本动作——

异步操作

由于杂技演员只有一个大脑,他不可能一次集中精力完成多个任务。然而,杂技演员经验丰富,他知道他不需要将太多的注意力集中在抛或接的动作上。一旦球被抛起,他就可以将注意力转移到即将落下的球上。

对于任何观察这个正在表演的杂技演员的人来说,他似乎在全心全意地关注着所有的六个球,但实际上,他在任何时刻都在忽略其中的五个球。

并行操作

就像异步性一样,并行性允许控制流在等待操作完成之前继续进行。与异步性不同,并行性依赖于硬件。这是因为我们无法在单个 CPU 上同时进行两个或更多控制流的并行操作。然而,将并行性与异步性区分开来的主要方面是使用它的理由。这两种并发方法解决不同的问题,并且都需要不同的设计原则。

最终,我们希望执行原本如果同步进行将耗时过长的并行操作。想象一下,一个用户正在等待三个昂贵的操作完成。如果每个操作需要 10 秒钟完成(在用户体验的时间尺度上仿佛是永恒),那么这意味着用户将不得不等待 30 秒钟。如果我们能够并行执行这些任务,我们可以将总等待时间缩短至接近 10 秒。以更少的代价获得更多,从而实现性能良好的用户界面。

这一切都不是免费的。就像异步操作一样,并行操作会导致回调作为通信机制。一般来说,设计并行性是困难的,因为除了与工作线程通信外,我们还要担心手头上的任务,即我们希望通过使用工作线程实现什么?以及我们如何将问题分解成更小的操作?以下是我们引入并行性后代码的大致样子:

var worker = new Worker('worker.js');
var myElement = document.getElementById('myElement');

worker.addEventListener('message', (e) => {
    myElement.textContent = 'Done working!';
});

myElement.addEventListener('click', (e) => {
    worker.postMessage('work');
});

不要担心这段代码的机制,因为它们将在后面的章节中深入探讨。重要的是,当我们把工作线程加入进来时,我们向已经充斥着回调的环境中添加了更多的回调。这就是为什么我们必须在代码中设计并行性,这是本书的一个主要焦点,从第五章 Chapter 5 与工作线程一起工作开始。

让我们思考一下上一节中的杂技演员类比。抛接动作是由杂技演员异步执行的;也就是说,他只有一个大脑/CPU。但是假设我们周围的环境不断变化。我们的抛接表演的观众越来越多,一个杂技演员不可能让所有的人都保持愉快:

并行动作

解决方案是引入更多的杂技演员。这样我们就能增加更多的计算能力,能够在同一瞬间执行多个抛接动作。这对于单个异步运行的杂技演员来说是不可能的。

我们还没有走出困境,因为我们不能让新增加的杂技演员站在一个地方,像我们的单个杂技演员那样表演。观众更多,更多样化,需要娱乐。杂技演员需要能够处理不同的物品。他们需要在地板上移动,以便让观众的不同部分都保持愉快。他们甚至可能开始相互抛接。这取决于我们能否设计出一个能够协调这种抛接表演的设计。

JavaScript 并发原则:并行化、同步、节约

既然我们已经了解了并发的概念及其在前端 Web 开发中的作用,让我们来看看 JavaScript 开发的一些基本并发原则。这些原则仅仅是工具,在我们编写并发 JavaScript 代码时,它们会指导我们的设计选择。

当我们应用这些原则时,它们迫使我们退后一步,在实施之前提出适当的问题。特别是,它们是关于为什么和如何的问题:

  • 我们为什么要实施这种并发设计?

  • 我们希望通过它得到什么,而无法通过更简单的同步方法得到?

  • 我们如何实现并发,同时又不影响我们应用程序的功能?

这里是每个并发原则的参考可视化,它们在开发过程中相互依赖。有了这个,我们将转向每个原则进行进一步探索:

JavaScript 并发原则:并行化、同步、节约

并行化

并行化原则意味着利用现代 CPU 的能力,在更短的时间内计算结果。这在任何现代浏览器或 NodeJS 环境中都是可能的。在浏览器中,我们可以通过使用 web workers 实现真正的并行性。在 Node 中,我们可以通过启动新进程来实现真正的并行性。以下是浏览器视角下的 CPU 样子:

并行化

目标是在更短的时间内进行更多计算,我们现在必须问自己为什么要这样做?除了原始性能本身非常酷之外,用户必须能够感受到一些实际的影响。这个原则让我们审视我们的并行代码,并问——用户从这能得到什么?答案是,我们可以使用更大的数据集作为输入进行计算,并且由于长时间运行的 JavaScript,用户无响应体验的机会更小。

仔细审查并行化的实际收益很重要,因为当我们这样做时,我们给代码增加了不必要的复杂性。所以如果用户无论我们做什么都能看到相同的结果,那么并行化原则可能不适用。另一方面,如果可扩展性很重要,并且有很强的可能性数据集大小会增长,那么为了并行化而牺牲代码简单性的代价可能是值得的。以下是在考虑并行化原则时需要遵循的清单:

  • 我们的应用程序是否对大型数据集执行昂贵的计算?

  • 随着我们的数据集增长,是否存在潜在的瓶颈,这会负面影响用户体验?

  • 我们的用户目前是否在我们的应用程序性能中遇到瓶颈?

  • 在其他约束条件下,我们的设计中并行化有多可行?有哪些权衡?

  • 我们并发实现的收益是否超过了开销成本,无论是从用户感知的延迟还是从代码可维护性的角度来看?

同步

同步原则是关于协调并发动作的机制及其抽象。回调函数是 JavaScript 的一个概念,有着深厚的根源。当我们需要运行一些代码,但又不想立即运行时,它是显而易见的选择工具。总的来说,这种方法本身并没有什么固有的错误。单独使用回调模式可能是我们能够使用的最简洁、最易读的并发模式。当回调很多,并且它们之间有很多依赖关系时,回调就会崩溃。

Promise API

Promise API 是核心 JavaScript 语言结构,在 ECMAScript 6 中引入,以解决地球上每个应用程序面临的同步难题。这是一个简单的 API,实际上使用了回调(是的,我们正在用回调对抗回调)。Promise 的目标不是消除回调,而是移除不必要的回调。以下是一个用于同步两个网络 fetch 调用的 Promise 示例:

Promise API

关于承诺(promises)的关键之处在于它们是一种通用的同步机制。这意味着它们并不是专门为网络请求、Web Workers 或 DOM 事件而设计的。我们,程序员,必须将我们的异步操作用承诺包裹起来,并在必要时解决它们。这样做的好处是,依赖于承诺接口的调用者并不关心承诺内部正在发生什么。正如其名所示,它承诺在某个时刻解决一个值。这可能是在 5 秒后或立即发生。数据可以来自网络资源或 Web Worker。调用者并不关心,因为它假设并发,这意味着我们可以以任何我们喜欢的方式实现它,而不会破坏应用程序。以下是对前面图表的修改版本,这将让我们尝到承诺能实现什么:

Promise API

当我们学会在未来某个时刻将值视为值时,并发代码突然变得更容易接近。承诺和类似机制可以用来同步仅网络请求或仅 Web Worker 事件。但它们的真正力量在于使用它们来编写并发应用程序,其中并发是默认的。以下是一个清单,当思考同步原则时可以参考:

  • 我们的应用程序是否严重依赖于回调函数作为同步机制?

  • 我们是否经常需要同步多个异步事件,如网络请求?

  • 我们的回调函数是否包含比应用程序代码更多的同步样板代码?

  • 我们的代码对驱动异步事件的并发机制有什么样的假设?

  • 如果我们有一个神奇的终止并发按钮,我们的应用程序是否还会按预期运行?

保存

保存原则是关于节省计算和内存资源。这是通过使用懒加载技术来实现的。名称“懒”来源于这样的想法:我们只有在确定确实需要它时才会计算新的值。想象一个渲染页面元素的组件。我们可以向这个组件传递它需要渲染的确切数据。这意味着在组件实际需要之前会有几个计算发生。这也意味着需要将使用的数据分配到内存中,以便我们可以将其传递给组件。这种方法没有问题。事实上,这是我们 JavaScript 组件中传递数据的标准方式。

另一种方法使用懒加载(lazy evaluation)来实现相同的结果。我们不是先计算要渲染的值,然后在结构中分配它们以传递,而是计算一个项目,然后渲染它。把这想象成一种合作多任务处理,其中较大的操作被分解成更小的任务,控制焦点在它们之间来回传递。

这里有一个急切的方法来计算数据并将其传递给渲染 UI 元素的组件:

节省

这种方法有两个不理想的地方。首先,转换操作是在一开始就发生的,这可能会是一个昂贵的计算。如果由于某种约束,组件无法渲染它,会发生什么——比如因为某些限制?那么我们就进行了这个计算来转换不需要的数据。作为推论,我们为转换后的数据分配了一个新的数据结构,以便我们可以将其传递给我们的组件。这个短暂的内存结构实际上并没有任何作用,因为它会被立即回收。让我们看看懒惰方法可能是什么样子:

节省

使用懒惰方法,我们能够移除一开始发生的昂贵转换计算。相反,我们一次只转换一个项目。我们也能够移除转换数据结构的提前分配。相反,只有转换后的项目被传递到组件中。然后,组件可以请求另一个项目或停止。节省原则使用并发作为仅计算所需内容并仅分配所需内存的手段。

以下清单将帮助我们思考在编写并发代码时考虑节省原则:

  • 我们是否在计算永远不会使用的值?

  • 我们是否仅仅将数据结构作为传递给下一个组件的手段?

  • 我们是否将数据转换操作链式连接起来?

摘要

在本章中,我们介绍了一些在 JavaScript 中使用并发的动机。虽然同步 JavaScript 易于维护和理解,但在网络上异步 JavaScript 代码是不可避免的。因此,在编写 JavaScript 应用程序时,将并发作为我们的默认假设是很重要的。

我们对两种主要的并发类型感兴趣——异步操作和并行操作。异步性是关于操作的时间顺序,这给人一种事情同时发生的印象。没有这种类型的并发,用户体验会大大受损,因为用户会不断等待其他操作完成。并行性是另一种类型的并发,它解决不同类型的问题,我们希望通过更快地计算结果来提高性能。

最后,我们探讨了 JavaScript 编程中的三个并发原则。并行化原则是关于利用现代系统中发现的多个核心 CPU。同步原则是关于创建抽象,使我们能够编写并发代码,隐藏并发机制从我们的功能代码中。节省原则使用懒惰评估来仅计算所需内容,并避免不必要的内存分配。

在下一章中,我们将关注 JavaScript 执行环境。为了有效地使用 JavaScript 并发,我们需要了解代码运行时实际发生的事情。

第二章:JavaScript 执行模型

本书的第一章探讨了 JavaScript 并发的状态。一般来说,处理 JavaScript 应用程序中的并发并不是一件简单的事情。在编写并发 JavaScript 代码时有很多事情要考虑,我们提出的解决方案通常是非正统的。有很多回调,浏览它们足以让人发疯。我们还瞥见了我们的并发 JavaScript 代码编写模式如何随着现有的并发组件而开始改变。Web Workers 已经开始成熟,而 JavaScript 语言的并发结构刚刚被引入。

语言和运行环境只能带我们走一半的路。我们需要在设计层面考虑并发,而不是事后。并发应该是默认的。这话说起来容易,做起来却非常困难。在这本书中,我们将探讨 JavaScript 并发特性所能提供的一切,以及我们如何最好地利用它们作为设计工具。但是,在我们这样做之前,我们需要深入了解 JavaScript 运行时真正发生的事情。这种知识是设计并发应用程序的必要输入,因为我们将确切知道在选择一种并发机制而不是另一种机制时可以期待什么。

在本章中,我们将从浏览器环境开始,通过查看我们的代码所触及的所有子系统——例如 JavaScript 解释器、任务队列以及 DOM 本身。然后我们将通过一些代码来揭示幕后真正发生的事情,以编排我们的代码。我们将以对这个模型所面临的挑战的讨论来结束本章。

一切都是任务

当我们访问一个网页时,浏览器为我们创建了一个整个环境。这个环境有几个子系统,使得我们查看的网页能够根据万维网联盟W3C)规范看起来和表现如它应该的样子。任务是在网页浏览器内部的基本抽象。任何发生的事情要么是一个任务本身,要么是更大任务的一部分。

注意

如果你正在阅读任何 W3C 规范,术语“用户代理”将用于代替“网页浏览器”。在 99.9%的情况下,我们阅读的是主要浏览器供应商。

在本节中,我们将查看这些环境的主要组件,以及任务队列和事件循环如何促进这些组件之间的通信,以实现网页的整体外观和行为。

认识参与者

让我们介绍一些术语,这些术语将帮助我们贯穿本章的各个部分:

  • 执行环境:每当打开一个新的网页时,就会创建这个容器。这是一个包罗万象的环境,其中包含我们的 JavaScript 代码将与之交互的一切。它还充当一个沙盒——我们的 JavaScript 代码无法超出这个环境。

  • JavaScript 解释器:这是负责解析和执行我们的 JavaScript 源代码的组件。浏览器的工作是向解释器添加全局变量,例如windowXMLHttpRequest

  • 任务队列:每当需要发生某些事情时,就会将任务排队。执行环境至少有一个这样的队列,但通常有几个。

  • 事件循环:执行环境有一个负责服务所有任务队列的单个事件循环。只有一个事件循环,因为只有一个线程。

看一下在网页浏览器中创建的以下执行环境可视化。任务队列是浏览器中发生任何事情的入口点。例如,一个任务可以通过传递给 JavaScript 解释器来执行一个脚本,而另一个任务用于渲染挂起的 DOM 更改。现在我们将深入了解构成环境的各个部分。

遇见参与者

执行环境

网络浏览器执行环境中最能揭示其本质的方面可能是我们的 JavaScript 代码及其解释器所扮演的相对较小的角色。我们的代码只是在一个更大机器中的一个齿轮。在这些环境中,确实有很多事情在进行,因为浏览器实现的平台具有巨大的作用。这不仅仅是在屏幕上渲染元素,然后通过样式属性增强这些元素。DOM 本身就像一个微型平台,就像网络设施、文件访问、安全等。所有这些部分对于网站功能的网络经济和最近的应用程序都是必不可少的。

在并发上下文中,我们主要对将所有这些平台部件联系在一起的机制感兴趣。我们的应用程序主要用 JavaScript 编写,解释器知道如何解析和运行它。但是,这最终如何转化为页面上的视觉变化?浏览器的网络组件如何知道发起一个 HTTP 请求,以及如何在响应到达后调用 JavaScript 解释器?

正是这些运动部件的协调限制了 JavaScript 中的并发选项。这些限制是必要的,因为没有它们,编写网络应用程序将变得过于复杂。

事件循环

一旦执行环境建立,事件循环就是最先启动的组件之一。它的任务是处理环境中的一个或多个任务队列。浏览器厂商可以自由地根据需要实现队列,但至少必须有一个队列。如果浏览器愿意,可以将每个任务都放在一个队列中,并给予每个任务相同的优先级。这样做的问题在于,如果队列出现拥堵,必须优先处理的任务,如鼠标或键盘事件,就会陷入等待。

实际上,拥有几个队列是有意义的,至少可以按优先级分离任务。这一点尤为重要,因为只有一个控制线程——意味着只有一个 CPU——会处理这些队列。以下是一个通过不同优先级级别服务多个队列的事件循环的示例:

事件循环

尽管事件循环与执行环境同时启动,但这并不意味着它总是有任务可以处理。如果总是有任务需要处理,那么实际应用将没有 CPU 时间。事件循环会等待更多任务,并且优先级最高的队列会首先得到服务。例如,使用前面图像中使用的队列,交互队列将始终首先得到服务。即使事件循环正在处理渲染队列的任务,如果有一个交互任务被排队,事件循环也会先处理这个任务,然后再继续处理渲染任务。

任务队列

理解队列任务的概念对于理解浏览器的工作方式至关重要。实际上,“浏览器”这个术语是有误导性的。我们曾用它们来浏览早期、较为稀疏的静态网页。现在,大型和复杂的应用程序在浏览器中运行——它实际上更像是一个网络平台。服务这些任务的任务队列和事件循环可能是处理这么多动态部分的最佳设计。

我们在本章前面看到,从执行环境的视角来看,JavaScript 解释器以及它解析和运行的代码实际上只是一个黑盒。事实上,调用解释器本身就是一个任务,这也反映了 JavaScript 的运行至完成特性。许多任务都涉及到调用 JavaScript 解释器,如图所示:

任务队列

任何这些事件——用户点击一个元素、页面中加载脚本,或者从之前的 API 调用中到达浏览器中的数据——都会创建一个调用 JavaScript 解释器的任务。它告诉解释器运行特定的代码片段,并且它会继续运行直到完成。这就是 JavaScript 的运行至完成特性。接下来,我们将深入探讨由这些任务创建的执行上下文。

执行上下文

现在是时候看看 JavaScript 解释器本身了——当事件发生且需要运行代码时,它从其他浏览器组件接管。始终有一个活动的 JavaScript 上下文,在解释器内部,我们会找到一个上下文堆。这类似于许多编程语言,其中堆控制活动上下文。

将活动上下文视为我们 JavaScript 代码当前发生情况的快照。使用堆结构是因为活动上下文可以改变为其他内容,例如当函数被调用时。当这种情况发生时,一个新的快照会被推入堆中,成为活动上下文。当它运行完成后,它会被从堆中弹出,留下下一个上下文作为活动上下文。

在本节中,我们将探讨 JavaScript 解释器如何处理上下文切换,以及管理上下文堆的内部工作队列。

维护执行状态

JavaScript 解释器内的上下文堆不是一个静态结构——它是不断变化的。在整个堆的生命周期中,有两件重要的事情发生。首先,在堆的顶部,我们有活动上下文。这是解释器在执行其指令时当前执行的代码。以下是一个 JavaScript 执行上下文堆的示例,其中活动上下文始终位于顶部:

维护执行状态

调用堆的其他重要职责是在活动上下文被停用时标记其状态。例如,假设在几个语句之后,func1()调用func2()。在这个点上,上下文被标记为func2()调用之后的直接位置。然后,它被新的活动上下文——func2()所取代。当它完成时,这个过程会重复,func1()再次成为活动上下文。

这种上下文切换在我们的代码中到处发生。例如,有一个全局上下文,它是我们代码的入口点,有函数本身,它们有自己的上下文。还有语言中较新的添加,它们也有自己的上下文,例如模块和生成器。接下来,我们将查看负责创建新执行上下文的工作队列。

任务队列

任务队列类似于我们之前查看的任务队列。区别在于任务队列是针对 JavaScript 解释器的。也就是说,它们被封装在解释器中——浏览器不会直接与这些队列交互。然而,当解释器被浏览器调用,例如响应加载的脚本或事件回调任务时,解释器会创建新的任务。

任务队列

JavaScript 解释器中的作业队列实际上比用于协调所有网络浏览器组件的任务队列要简单得多。只有两个基本队列。一个是用于创建新的执行上下文堆栈(调用堆栈)。另一个是针对承诺解析回调函数的特定队列。

注意

我们将在下一章更深入地探讨如何让承诺解析回调作业工作。

考虑到这些内部 JavaScript 作业队列的责任限制,人们可能会得出结论,它们是不必要的——过度工程化的行为。但这并不正确,因为虽然今天在这些工作中找到了有限的责任,但作业队列的设计使得语言的扩展和改进变得更加容易。特别是,在考虑语言未来版本的新的并发构造时,作业队列机制是有利的。

使用计时器创建任务

到目前为止,在本章中,我们已经了解了网络浏览器环境中的所有内部工作者,以及 JavaScript 解释器在这个环境中的位置。所有这些与将并发原则应用于我们的代码有什么关系呢?通过了解底层发生的事情,我们对代码运行时发生的事情有了更深入的了解。特别是,我们知道相对于其他代码块发生的事情;时间顺序是并发的一个重要属性。

话虽如此,让我们实际编写一些代码。在本节中,我们将使用计时器显式地将任务添加到任务队列中。我们还将了解 JavaScript 解释器何时何地介入并开始执行我们的代码。

使用 setTimeout()

setTimeout() 函数是任何 JavaScript 代码中的基本组成部分。它用于在未来的某个时间点执行代码。新 JavaScript 程序员经常遇到 setTimeout() 函数,因为它是一个计时器。在未来的某个设定点,比如说 3 秒后,将调用一个回调函数。当我们调用 setTimeout() 时,我们会得到一个 atimer ID,稍后可以使用 clearTimeout() 清除它。以下是 setTimeout() 的基本用法:

// Creates a timer that calls our function in no less
// than 300MS. We can use the "console.time()" and the
// "console.timeEnd()" functions to see how long it actually
// takes.
//
// This is typically around 301MS, which isn't at all 
// noticeable by the user, but is unreliable for
// accurately scheduling function calls.
var timer = setTimeout(() => {
    console.timeEnd('setTimeout');
}, 300);

console.time('setTimeout');

下面是 JavaScript 新手容易误解的部分;它是一个尽力而为的计时器。当我们使用 setTimeout() 时,我们唯一可以保证的是我们的回调函数不会被调用得比我们传递给它的分配时间早。所以如果我们说在 300 毫秒后调用这个函数,它永远不会在 275 毫秒时调用它。一旦 300 毫秒过去,就会排队一个新的任务。如果在这个任务之前没有其他任务等待,回调就会准时运行。即使在其前面有少量任务在队列中,影响几乎不明显——它看起来是在正确的时间运行的。

但正如我们所见,JavaScript 是单线程的,并且是运行到完成的。这意味着一旦 JavaScript 解释器开始,它不会停止,直到完成;即使有任务等待计时器事件回调。所以,即使我们要求计时器在 300 毫秒后执行回调,它也可能在 500 毫秒后执行。让我们看看一个例子,看看这是如何可能的:

// Be careful, this function hogs the CPU...
function expensive(n = 25000) {
    var i = 0;
    while (++i < n * n) {}
    return i;
}

// Creates a timer, the callback uses
// "console.timeEnd()" to see how long we
// really waited, compared to the 300MS
// we were expecting.
var timer = setTimeout(() => {
    console.timeEnd('setTimeout');
}, 300);

console.time('setTimeout');

// This takes a number of seconds to
// complete on most CPUs. All the while, a
// task has been queued to run our callback
// function. But the event loop can't get
// to that task until "expensive()" completes.
expensive();

使用setInterval()

setTimeout()的表亲是setInterval()函数。正如其名所示,它接受一个在固定时间间隔被调用的回调函数。实际上,setInterval()setTimeout()接受完全相同的参数。唯一的区别是,它将每隔 x 毫秒调用该函数,直到使用clearInterval()清除计时器。

当我们想要反复调用同一个函数时,这个函数很有用。例如,如果我们轮询 API 端点,setInterval()是一个很好的候选解决方案。然而,请记住,回调的调度是固定的。也就是说,一旦我们用 1000 毫秒调用setInterval(),就不能在不先清除计时器的情况下更改这 1000 毫秒。对于需要动态间隔的情况,使用setTimeout()效果更好。回调安排下一个间隔,这使得间隔可以动态调整。例如,通过增加间隔来减少对 API 轮询的频率。

在我们之前看到的setTimeout()示例中,我们看到了运行 JavaScript 代码如何会干扰事件循环。也就是说,它阻止事件循环消耗调用 JavaScript 解释器并带有我们的回调函数的任务。这允许我们将代码执行推迟到未来的某个点,但没有保证准确性。让我们看看使用setInterval()安排任务会发生什么。之后还有一些阻塞的 JavaScript 代码运行:

// A counter for keeping track of which
// interval we're on.
var cnt = 0;

// Set up an interval timer. The callback will
// log which interval scheduled the callback.
var timer = setInterval(() => {
    console.log('Interval', ++cnt);
}, 3000);

// Block the CPU for a while. When we're no longer
// blocking the CPU, the first interval is called,
// as expected. Then the second, when expected. And
// so on. So while we block the callback tasks, we're
// also blocking tasks that schedule the next interval.
expensive(50000);

响应 DOM 事件

在上一节中,我们看到了如何安排 JavaScript 代码在将来某个时间运行。这是通过其他 JavaScript 代码显式完成的。大多数时候,我们的代码是响应用户交互而运行的。在本节中,我们将探讨不仅由 DOM 事件使用,而且由网络和 web worker 事件等使用的一个通用接口。我们还将探讨一种处理大量类似事件的技术——称为防抖。

事件目标

EventTarget接口被许多浏览器组件使用,包括 DOM 元素。它是我们向元素派发事件以及通过执行回调函数来监听事件和响应的方式。实际上,这是一个非常直接且易于理解的接口。这对于许多不同类型的组件使用此相同接口进行事件管理至关重要。随着我们继续阅读本书,我们将看到这一点。

在前一章中,我们介绍了用于执行定时器的回调函数的任务队列机制,这与EventTarget事件相关。也就是说,如果发生了事件,就会排队一个任务来调用 JavaScript 解释器并执行适当的回调。这里面临的与使用setTimeout()相同的限制也适用于此。以下是当存在长时间运行的阻塞用户事件的 JavaScript 代码时任务队列的样子:

事件目标

除了将监听器函数附加到对用户交互做出反应的事件目标外,我们还可以手动触发这些事件,如下面的代码所示:

// A generic event callback, logs the event timestamp.
function onClick(e) {
    console.log('click', new Date(e.timeStamp));
}

// The element we're going to use as the event
// target.
var button = document.querySelector('button');

// Setup our "onClick" function as the
// event listener for "click" events on this target.
button.addEventListener('click', onClick);

// In addition to users clicking the button, the
// EventTarget interface lets us manually dispatch
// events.
button.dispatchEvent(new Event('click'));

在可能的情况下,给用于回调的函数命名是一个好习惯。这样,当我们的代码出错时,追踪问题会容易得多。使用匿名函数并非不可能,只是更耗时。另一方面,箭头函数更简洁,具有更多的绑定灵活性。明智地权衡你的选择。

管理事件频率

用户交互事件的一个挑战是,在非常短的时间内可能会有很多事件。例如,当用户在屏幕上移动鼠标时,会触发数百个事件。如果我们有监听这些事件的事件目标,任务队列会很快填满,用户体验会变得缓慢。

即使我们在高频事件(如鼠标移动)上已经设置了事件监听器,我们也不一定需要响应所有这些事件。例如,如果有 150 次鼠标移动事件在 1-2 秒内发生,那么很可能是我们只关心最后一次移动——鼠标指针的最新位置。也就是说,JavaScript 解释器调用我们的事件回调代码的次数比实际需要的多 149 次。

为了处理这些类型的事件频率场景,我们可以利用一种称为“防抖”的技术。防抖函数意味着如果在给定的时间框架内连续多次调用,则实际上只使用最后一次调用,而忽略之前的调用。让我们通过一个例子来看看我们如何实现这一点:

// Keeps track of the number of "mousemove" events.
var events = 0;

// The "debounce()" takes the provided "func" an limits
// the frequency at which it is called using "limit"
// milliseconds.
function debounce(func, limit) {
    var timer;

    return function debounced(...args) {
        // Remove any existing timers.
        clearTimeout(timer);

        // Call the function after "limit" milliseconds.
        timer = setTimeout(() => {
            timer = null;
            func.apply(this, args);
        }, limit);
    };
}

// Logs some information about the mouse event. Also log
// the total number of events.
function onMouseMove(e) {
    console.log(`X ${e.clientX} Y ${e.clientY}`);
    console.log('events', ++events);
}

// Log what's being typed into the text input.
function onInput(e) {
    console.log('input', e.target.value);
}

// Listen to the "mousemove" event using the debounced
// version of the "onMouseMove()" function. If we 
// didn't wrap this callback with "debounce()"
window.addEventListener('mousemove', debounce(onMouseMove, 300));

// Listen to the "input" event using the debounced version
// of the "onInput()" function to prevent triggering events
// on every keystroke.
document.querySelector('input')
    .addEventListener('input', debounce(onInput, 250));

使用防抖技术来避免给 CPU 带来不必要的额外工作,是节约原则在起作用的一个例子。通过忽略 149 个事件,我们节省了(节约)了本应执行但没有任何实际价值的 CPU 指令。我们还节省了在这些事件处理程序中可能发生的任何类型的内存分配。

JavaScript 并发原则在第一章的末尾介绍,即“为什么 JavaScript 并发?”,并在本书剩余部分的代码示例中会指出。

响应网络事件

任何前端应用中的另一个关键部分是网络交互,获取数据,发布命令等等。由于网络通信本质上是一种异步活动,我们必须依赖于事件——具体来说是EventTarget接口。

我们首先将探讨将我们的回调函数与请求和从后端获取响应连接起来的通用机制。然后,我们将看看尝试同步多个网络请求是如何创建一个看似无望的并发场景的。

发起请求

为了与网络交互,我们创建一个新的XMLHttpRequest实例。然后我们告诉它我们想要发起的请求类型——GET 还是 POST 以及请求端点。这些请求对象也实现了EventTarget接口,这样我们就可以监听来自网络的数据。以下是一个示例代码:

// Callback for successful network request,
// parses JSON data.
function onLoad(e) {
    console.log('load', JSON.parse(this.responseText));
}

// Callback for problematic network request,
// logs error.
function onError() {
    console.error('network', this.statusText || 
        'unknown error');
}

// Callback for a cancelled network request,
// logs warning.
function onAbort() {
    console.warn('request aborted...');
}

var request = new XMLHttpRequest();

// Uses the "EventTarget" interface to attach event 
// listeners, for each of the potential conditions.
request.addEventListener('load', onLoad);
request.addEventListener('error', onError);
request.addEventListener('abort', onAbort);

// Sends a "GET" request for "api.json".
request.open('get', 'api.json');
request.send();

我们可以看到,网络请求有几种可能的状态。成功的路径是服务器响应我们所需的数据,并且我们能够将其解析为 JSON。错误状态是当出现问题时,比如服务器不可达。我们在这里关心的最后一个状态是请求被取消或中止。这意味着我们不再关心成功的路径,因为在我们请求进行过程中,我们的应用程序中发生了某些变化。例如,用户导航到了另一个部分。

尽管之前的代码足够容易使用和理解,但并非总是如此。我们正在查看单个请求和一些回调。我们的应用程序组件通常不会只包含一个网络请求。

协调请求

在前面的章节中,我们看到了使用XMLHttpRequest实例进行网络请求的基本交互。当有多个请求时,挑战就会出现。大多数时候,我们发起多个网络请求,以便我们有渲染 UI 组件所需的数据。后端的所有响应都将在不同时间到达,并且可能相互依赖。

某种程度上,我们需要同步这些异步网络请求的响应。让我们看看我们可以如何使用EventTarget回调函数来完成这项工作:

// The function that's called when a response arrives ,
// it's also responsible for coordinating responses.
function onLoad() {

    // When the response is ready, we push the parsed
    // response onto the "responses" array, so that we
    // can use responses later on when the rest of them
    // arrive.
    responses.push(JSON.parse(this.responseText));

    // Have all the respected responses showed up yet?
    if (responses.length === 3) {
        // How we can do whatever we need to, in order
        // to render the UI component because we have
        // all the data.
        for (let response of responses) {
            console.log('hello', response.hello);
        }
    }
}

// Creates our API request instances, and a "responses"
// array used to hold out-of-sync responses.
var req1 = new XMLHttpRequest(),
    req2 = new XMLHttpRequest(),
    req3 = new XMLHttpRequest(),
    responses = [];

// Issue network requests for all our network requests.
for (let req of [ req1, req2, req3 ]) {
    req.addEventListener('load', onLoad);

    req.open('get', 'api.json');
    req.send();
}

当有多个请求时,需要考虑很多额外的因素。由于它们都在不同时间到达,我们需要在数组中存储解析后的响应,并且每当有响应到达时,我们需要检查我们是否已经得到了我们期望的一切。这个简化的例子甚至没有考虑到失败的或取消的请求。正如这段代码所暗示的,回调函数方法在同步方面是有限的。在接下来的章节中,我们将学习如何克服这一限制。

此模型下的并发挑战

我们将本章结束于对 JavaScript 并发执行模型所面临的挑战的讨论。有两个基本障碍。第一个是无论什么情况,任何运行的 JavaScript 代码都会阻塞其他任何事情的发生。第二个障碍是尝试使用回调函数同步异步操作,导致回调地狱。

并行性有限

以前,JavaScript 缺乏并行性并不是真正的问题。没有人会想念它,因为 JavaScript 被视为 HTML 页面的渐进增强工具。当前端开始承担更多责任时,这种情况发生了变化。如今,应用程序的大部分实际上都驻留在前端。这使得后端组件能够专注于 JavaScript(从浏览器角度来看,NodeJS 完全是另一回事,我们将在本书稍后讨论)无法解决的问题。

例如,将映射和减少 API 数据源到某个功能所需的表示形式可以在后端实现。这意味着前端 JavaScript 代码只需要查询这个端点。问题是这个 API 端点是针对某些特定的 UI 功能创建的,而不是作为我们数据模型的基本支持支柱。如果我们能在前端执行这些任务,我们就将 UI 功能和它们所需的数据转换紧密耦合在一起。这使后端能够专注于更紧迫的问题,如复制和负载均衡。

我们可以在前端执行这些类型的数据转换,但它们会破坏界面的可用性。这主要是因为所有移动部件都在争夺相同的计算资源。换句话说,这种模型使我们无法实现并行化原则并利用多个资源。我们将通过后续章节中介绍的 Web workers 克服这种网络浏览器的限制。

通过回调进行同步

通过回调进行同步难以实现且扩展性不佳。这是回调地狱,一个在 JavaScript 程序员中流行的术语。不用说,代码中无尽的回调同步会引发问题。我们经常不得不创建某种状态跟踪机制,例如全局变量。当问题确实出现时,一大堆回调函数在心理上非常耗时去遍历。

通常来说,同步多个异步操作的回调方法需要大量的开销。也就是说,存在只是为了处理异步操作而存在的样板代码。同步并发原则是关于编写不将主要目标嵌入到同步处理逻辑迷宫中的并发代码。通过减少回调函数的使用,Promise 帮助我们在整个应用程序中一致地编写并发代码。

概述

本章的重点一直是网络浏览器平台以及 JavaScript 在其中所处的位置。在我们查看和与网页互动时,总会有很多事件发生。这些事件被处理为任务,从队列中取出。其中一项任务就是使用代码调用 JavaScript 解释器来运行。

当 JavaScript 解释器运行时,它包含一个执行上下文栈。函数、模块和全局脚本代码——这些都是 JavaScript 执行上下文的例子。解释器还有它自己的内部作业队列;一个用于创建新的执行上下文栈,另一个用于调用承诺解析回调函数。

我们编写了一些代码,使用setTImeout()函数手动创建任务,并明确展示了长时间运行的 JavaScript 代码对这些任务可能造成的问题。然后我们研究了EventTarget接口,该接口用于监听 DOM 事件,以及网络请求,以及其他我们没有在本章中探讨的内容,如 web workers 和文件读取器。

我们总结了 JavaScript 程序员在使用此模型时面临的挑战。特别是,遵循我们的 JavaScript 并发原则很难。我们无法并行化,而仅使用回调函数来尝试同步则是一场噩梦。

在下一章中,我们将探讨一种使用承诺(promises)来思考同步的新方法。这将使我们能够真正开始设计和构建并发 JavaScript 应用程序。

第三章。使用承诺进行同步

JavaScript 库中已经存在了许多年的承诺(promises)实现。这一切始于 Promises/A+ 规范。库们实现了这个规范的自己的变体,直到最近(确切地说,是 ES6)承诺规范才被纳入 JavaScript 语言。它们做了章节标题所暗示的事情——帮助我们应用同步原则。

在本章中,我们将从对承诺术语的温和介绍开始,这样本章的其余部分就会更容易理解。然后,我们将探讨承诺被用来解决未来值的各种方式,以及我们在处理并发时如何使生活变得更轻松。准备好了吗?

承诺术语

在我们深入代码示例之前,让我们花一分钟时间确保我们对承诺周围的术语有一个牢固的掌握。有承诺实例,但还有各种状态和动作需要考虑。以下各节将更加有意义,如果我们能够确定承诺词汇表。这些解释简短而直接,所以如果你已经使用过承诺,你可以快速浏览这些定义以验证你的知识。

承诺

正如名称所暗示的,承诺就是承诺。将承诺想象为一个代理,它代表一个尚未存在的值。承诺让我们能够编写更好的并发代码,因为我们知道值最终会存在,我们不必编写大量的状态检查样板代码。

状态

承诺始终处于三种状态之一:

  • 挂起:这是承诺创建后的第一个状态。它保持在挂起状态,直到它被解决或拒绝。

  • 已解决:承诺的值已经被解决,并且可以通过 then() 回调函数访问。

  • 已拒绝:在尝试解决承诺的值时出了问题。今天没有数据。

承诺状态的一个有趣特性是它们只转换一次。它们要么从挂起(pending)状态转换为已解决(fulfilled)状态,要么从挂起状态转换为已拒绝(rejected)状态。一旦它们完成这种状态转换,它们就会在这个状态下持续存在。

执行者

执行器函数负责以某种方式解决调用者所等待的值。这个函数在承诺创建后立即被调用。它接受两个参数:一个 resolver 函数和一个 rejector 函数。

解决者

解决者是一个作为参数传递给执行器函数的函数。实际上,这非常方便,因为我们可以将解决者函数传递给另一个函数,依此类推。解决者函数在哪里被调用并不重要,但一旦被调用,承诺就会进入已解决状态。这种状态的变化将触发任何 then() 回调——我们很快就会看到这些是什么。

拒绝者

拒绝者与解决者类似。它是传递给executor函数的第二个参数,可以从任何地方调用。当它被调用时,它会将承诺的状态从挂起更改为拒绝。这种状态变化将调用传递给then()catch()的任何error回调函数。

Thenable

一个对象是 thenable 的,如果它有一个接受满足回调和拒绝回调作为参数的then()方法。换句话说,一个承诺是 thenable 的。但有时我们可能想要实现专门的解决语义。

解决和拒绝承诺

如果前面的部分引入了几个听起来令人困惑的新术语,那么请不要担心。我们将从本节开始,看看所有这些承诺术语在实际中的样子。在这里,我们将执行一些直接的承诺解决和拒绝。

解决承诺

解决者是一个函数,正如其名所示,为我们解决承诺。这不是解决承诺的唯一方法——我们将在本章后面探索更高级的技术。但这种方法无疑是最常见的。它作为第一个参数传递给执行者函数。这意味着执行者可以直接通过调用解决函数来解决承诺。但这不会给我们带来很多实用性,对吧?

在更普遍的情况下,承诺executor函数会设置即将发生的异步操作——比如进行网络调用。然后,在这些异步操作的回调函数中,我们可以解决承诺。一开始,在代码中传递一个解决函数可能有点反直觉,但一旦我们开始使用它们,这就会更有意义。

解决函数是一个绑定到承诺的不可见函数。它只能解决一个承诺一次。我们可以多次调用解决函数,但只有第一次调用会改变承诺的状态。以下是一个图解,描述了承诺的可能状态;它还显示了它们是如何改变的:

解决承诺

现在,让我们看看一些承诺代码。在这里,我们将解决一个承诺,这将导致then()满足回调函数被调用:

// The executor function used by our promise.
// The first argument is the resolver function,
// which is called in 1 second to resolve the promise.
function executor(resolve) {
    setTimeout(resolve, 1000);
}

// The fulfillment callback for our promise. This
// simply stopsthe fullfillment timer that was
// started after our executor function was run.
function fulfilled() {
    console.timeEnd('fulfillment');
}

// Creates the promise, which will run the executor
// function immediately. Then we start a timer to see
// how long it takes for our fulfillment function to
// be called.
var promise = new Promise(executor);
promise.then(fulfilled);
console.time('fulfillment');

如我们所见,当调用解决函数时,会调用fulfilled()函数。执行者实际上并没有调用解决函数。相反,它将解决函数传递给另一个异步函数——setTimeout()。执行者函数本身并不是我们试图控制的异步代码。执行者可以被视为一种协调者,协调异步操作以确定何时解决承诺。

上述示例没有解决任何值。当某些动作的调用者需要确认它成功或失败时,这是一个有效的用例。相反,这次让我们尝试解决一个值,如下所示:

// The executor function used by our promise.
// Sets a timeout that calls "resolve()" one second
// after the promise is created. It's resolving
// a string value - "done!".
function executor(resolve) {
    setTimeout(() => {
        resolve('done!');
    }, 1000);
}

// The fulfillment callback for our promise accepts
// a value argument. This is the value that's passed
// to the resolver.
function fulfilled(value) {
    console.log('resolved', value);
}

// Create our promise, providing the executor and
// fulfillment function callbacks.
var promise = new Promise(executor);
promise.then(fulfilled);

我们可以看到,这段代码与前面的例子非常相似。不同之处在于,我们的解析函数实际上是在传递给setTimeout()的回调函数的作用域内被调用的。这是因为我们正在解析一个字符串值。还有传递给我们的fulfilled()函数的参数,它就是解析的值。

拒绝承诺

承诺的executor函数并不总是按计划进行,当这种情况发生时,我们需要拒绝承诺。这是从挂起状态到另一个可能的状态转换。承诺不是移动到满足状态,而是移动到拒绝状态。这导致执行不同的回调,而不是满足回调。幸运的是,拒绝承诺的机制与解析它们非常相似。让我们看看这是如何完成的:

// This executor function rejects the promise after
// a timeout of one second. It uses the rejector function
// to change the state, and to provide the rejected
// callbacks with a value.
function executor(resolve, reject) {
    setTimeout(() => {
        reject('Failed');
    }, 1000);
}

// The function used as a rejected callback function. It
// expects a reason for the rejection to be provided.
function rejected(reason) {
    console.error(reason);
}

// Creates the promise, and runs the executor. Uses the
// "catch()" method to assing the rejector callback function.
var promise = new Promise(executor);
promise.catch(rejected);

这段代码看起来与我们在上一节中查看的解析代码非常相似。我们设置了一个超时,而不是解析函数,我们拒绝了解析。这是通过rejector函数完成的,并将其作为第二个参数传递给执行器。

我们使用catch()方法而不是then()方法来设置我们的拒绝回调函数。我们将在本章后面部分探讨如何使用then()方法来处理满足和拒绝回调。在这个例子中,拒绝回调简单地记录失败原因作为错误。始终提供这个值是很重要的。当我们解析承诺时,一个值是常见的,尽管不是严格必要的。另一方面,对于拒绝,即使回调只是记录错误,不提供拒绝原因的情况也是没有可行性的。

让我们看看另一个例子,这个例子在执行器中捕获异常,并为拒绝回调提供对失败更具有意义的解释:

// This promise executor throws an error, and the rejected
// callback function is called as a result.
new Promise(() => {
    throw new Error('Problem executing promise');
}).catch((reason) => {
    console.error(reason);
});

// This promise executor catches an error, and rejects
// the promise with a more useful message.
new Promise((resolve, reject) => {
    try {
        var size = this.name.length;
    } catch(error) {
        reject(error instanceof TypeError ?
            'Missing "name" property' : error);
    }
}).catch((reason) => {
    console.error(reason);
});

在前面的例子中,第一个承诺有趣的是,它确实改变了状态,尽管我们并没有明确使用resolve()reject()来改变承诺的状态。然而,对于承诺最终改变状态来说,这是很重要的;我们将在下一节探讨这个话题。

空承诺

尽管executor函数传递了一个resolver函数和一个rejector函数,但承诺改变状态永远没有保证。在这种情况下,承诺只是挂起,既没有触发解析回调也没有触发拒绝回调。这看起来可能不是问题,实际上,对于简单的承诺来说,诊断和修复这些无响应的承诺很容易。然而,随着我们在本章后面部分遇到更复杂的情况,一个承诺可能因为其他几个承诺解析而解析。如果这些承诺中的一个没有解析或拒绝,那么整个流程就会崩溃。这种情况的调试非常耗时;以下图表是问题的可视化:

空承诺

从视觉上看,我们可以看到哪个 promise 导致依赖的 promise 挂起,但通过遍历代码来找出这一点并不理想。现在让我们看看一个导致 promise 挂起的executor函数:

// This promise is able to run the executor
// function without issue. The "then()" callback
// is never executed.
new Promise(() => {
    console.log('executing promise');
}).then(() => {
    console.log('never called');
});

// At this point, we have no idea what's
// wrong with the promise.
console.log('finished executing, promise hangs');

但如果有一种更安全的方式来处理这种不确定性呢?一个executor函数如果无限期地挂起而不解析或拒绝,这几乎是我们不希望出现在代码中的。让我们看看实现一个 executor 包装函数,它通过拒绝解析时间过长的 promise 来作为安全网。这将使诊断复杂的 promise 场景变得不再神秘:

// A wrapper for promise executor functions, that
// throws an error after the given timeout.
function executorWrapper(func, timeout) {

    // This is the function that's actually called by the
    // promise. It takes the resolver and rejector functions
    // as arguments.
    return function executor(resolve, reject) {
        // Setup our timer. When time runs out, we can
        // reject the promise with a timeout message.
        var timer = setTimeout(() => {
            reject(`Promise timed out after ${timeout}MS`);
        }, timeout);

        // Call the original executor function that we're
        // wrapping. We're actually wrapping the resolver
        // and rejector functions as well, so that when the
        // executor calls them, the timer is cleared.
        func((value) => {
            clearTimeout(timer);
            resolve(value);
        }, (value) => {
            clearTimeout(timer);
            reject(value);
        });
    };
}

// This promise executor times out, and a timeout
// error message is passed to the rejected callback.
new Promise(executorWrapper((resolve, reject) => {
    setTimeout(() => {
        resolve('done');
    }, 2000);
}, 1000)).catch((reason) => {
    console.error(reason);
});

// This promise resolves as expected, since the executor
// calls "resolve()" before time's up.
new Promise(executorWrapper((resolve, reject) => {
    setTimeout(() => {
        resolve(true);
    }, 500);
}, 1000)).then((value) => {
    console.log('resolved', value);
});

对 promise 做出反应

现在我们对执行 promise 的机制有了更好的理解,这一节将更深入地探讨使用 promises 来解决特定问题。通常,这意味着当 promise 被实现或拒绝时,有目的地做出反应。

我们将从查看 JavaScript 解释器内部的作业队列开始,以及这些对我们解析回调函数的意义。然后我们将查看利用 promised 数据,处理错误,创建更好的抽象来响应 promises 和 thenables。让我们开始吧。

解析工作队列

JavaScript 工作队列的概念在第二章JavaScript 执行模型中引入。其主要职责是启动新的执行上下文栈。这是主要的工作队列。然而,还有一个队列,专门用于处理由 promises 执行的回调函数。这意味着负责选择下一个要运行的工作的算法可以从这两个队列中选择任何一个,如果它们都满了的话。

Promises 内置了并发语义,这是有充分理由的。如果一个 promise 用于确保一个值最终被解析,那么给响应它的代码以高优先级是有意义的。否则,当值到达时,处理它的代码可能不得不在更长的队列中等待其他工作。让我们编写一些代码来演示这些并发语义:

// Creates 5 promises that log when they're
// executing, and when they're reacting to a
// resolved value.
for (let i = 0; i < 5; i++) {
    new Promise((resolve) => {
        console.log('executing promise');
        resolve(i);
    }).then((value) => {
        console.log('resolved', i);
    });
}

// This is called before any of the fulfilled
// callbacks, because this call stack job needs
// to complete before the interpreter reaches into
// the promise resolution callback queue, where
// the 5 "then()" callbacks are currently sitting.
console.log('done executing');

// →
// executing promise
// executing promise
// ...
// done executing
// resolved 1
// resolved 2
// ...

注意

拒绝回调也遵循相同的语义。

使用 promised 数据

到目前为止,我们在这个章节中看到了几个例子,其中解析函数使用一个值解析 promise。传递给这个函数的值是最终传递给实现回调函数的值。执行者的想法是设置任何异步操作,例如setTimeout(),稍后它会用这个值调用解析器。但在这些例子中,调用者实际上并没有等待任何值;我们只是用setTimeout()作为一个异步操作的例子。让我们看看一个我们实际上没有值的例子,并且需要异步网络请求来获取它:

// A generic function used to fetch resources
// from the server, returns a promise.
function get(path) {
    return new Promise((resolve, reject) => {
        var request = new XMLHttpRequest();

        // The promise is resolved with the parsed
        // JSON data when the data is loaded.
        request.addEventListener('load', (e) => {
            resolve(JSON.parse(e.target.responseText));
        });

        // When there's an error with the request, the
        // promise is rejected with the appropriate reason.
        request.addEventListener('error', (e) => {
            reject(e.target.statusText || 'unknown error');
        });

        // If the request is aborted, we simply resolve the
        // request.
        request.addEventListener('abort', resolve);

        request.open('get', path);
        request.send();
    });
}

// We can attach our "then()" handler directly
// to "get()" since it returns a promise. The
// value used here was a true asynchronous operation
// that had to go fetch a remote value, and parse it,
// before resolving it here.
get('api.json').then((value) => {
    console.log('hello', value.hello);
});

对于像get()这样的函数,它们不仅始终返回一个同步原语,如承诺,而且还封装了一些讨厌的异步细节。在我们的代码中到处处理XMLHttpRequest对象并不愉快。我们还简化了响应可能返回的各种模式。我们不必总是为loaderrorabort事件创建处理程序,我们只有一个接口需要担心——承诺。这就是同步并发原则的全部内容。

错误回调

有两种方式来响应拒绝的承诺。换句话说,提供错误回调。第一种方法是用catch()方法,它接受一个回调函数。另一种方法是作为then()的第二个参数传入拒绝回调函数。

在某些场景下,用于提供拒绝回调函数的then()方法更优越,可能应该用它来代替catch()。第一种场景是我们编写代码,使得承诺和 thenable 对象可以互换。catch()方法不一定属于 thenable。第二种场景是我们构建回调链,我们将在本章稍后探讨。

让我们看看一些比较两种向承诺提供拒绝回调函数方法的代码:

// This promise executor will randomly resolve
// or reject the promise.
function executor(resolve, reject) {
    cnt++;
    Math.round(Math.random()) ?
        resolve(`fulfilled promise ${cnt}`) :
        reject(`rejected promise ${cnt}`);
}

// Make "log()" and "error()" functions for easy
// callback functions.
var log = console.log.bind(console),
    error = console.error.bind(console),
    cnt = 0;

// Creates a promise, then assigns the error
// callback via the "catch()" method.
new Promise(executor).then(log).catch(error);

// Creates a promise, then assigns the error
// callback via the "then()" method.
new Promise(executor).then(log, error);

我们可以看到,这两种方法实际上非常相似。在代码美观方面,两者之间没有真正的优势。然而,当涉及到使用 thenables 时,then()方法在几个场景中更优越,我们将在稍后看到。但是,由于我们实际上并没有以任何方式使用承诺实例,除了添加回调之外,我们实际上没有必要担心catch()then()在注册错误回调方面的区别。

始终响应

承诺最终会进入满足状态或拒绝状态。我们通常为这两种状态分别有独立的回调函数。然而,有很大可能性我们希望对这两种状态执行一些相同的操作。例如,如果一个使用承诺的组件在承诺挂起时改变状态,我们希望在承诺解决或拒绝后确保状态被清理。

我们可以编写代码,使得满足和拒绝状态下的回调各自执行这些操作,或者它们可以各自调用一些公共函数来完成清理。以下是问题的可视化表示:

始终响应

将清理责任分配给承诺,而不是分配给个别结果,不是更有意义吗?这样,当承诺解决时运行的回调函数将专注于它需要处理的价值,而拒绝回调将专注于处理错误。让我们看看我们能否编写一些代码来扩展承诺的always()方法:

// Extends the promise prototype with an "always()"
// method. The given function will always be called,
// whether the promise is fulfilled or rejected.
Promise.prototype.always = function(func) {
    return this.then(func, func);
};

// Creates a promise that's randomly resolved or
// rejected.
var promise = new Promise((resolve, reject) => {
    Math.round(Math.random()) ?
        resolve('fulfilled') : reject('rejected');
});

// Give the promise fulfillment and rejection callbacks.
promise.then((value) => {
    console.log(value);
}, (reason) => {
    console.error(reason);
});

// This callback is always called after the one of
// the callbacks above.
promise.always((value) => {
    console.log('cleaning up...');
});

注意

注意这里的顺序很重要。如果我们先调用always()然后再调用then(),那么函数仍然会始终运行,但它会在提供给then()的回调之前运行。我们实际上可以在then()前后调用always(),以便在满足或拒绝回调之前和之后始终运行代码。

解决其他承诺

本章中我们迄今为止看到的承诺中的大多数要么是由执行器函数直接解析的,要么是在异步操作中调用解析器作为结果,当值准备好解析时。以这种方式传递解析器函数实际上非常灵活。例如,执行器甚至不需要执行任何工作,除了将其解析器函数存储在某个地方,以便稍后可以调用它来解析承诺。

这在我们发现自己处于需要多个值(这些值已经承诺给调用者)的更复杂的同步场景时特别有用。如果我们有解析函数,我们可以解析这个承诺。让我们看看存储几个承诺的解析器函数的代码,以便稍后可以解析每个承诺:

// Keeps a list of resolver functions.
var resolvers = [];

// Creates 5 new promises, and in each executor
// function, the resolver is pushed onto the
// "resolvers" array. We also give each promise
// a fulfillment callback.
for (let i = 0; i < 5; i++) {
    new Promise((resolve) => {
        resolvers.push(resolve);
    }).then((value) => {
        console.log(`resolved ${i + 1}`, value);
    });
}

// Sets a timeout that runs the function after 2
// seconds. When it runs, we iterate over every
// resolver function in the "resolvers" array,
// and we call it with a value.
setTimeout(() => {
    for (let resolver of resolvers) {
        resolver(true);
    }
}, 2000);

正如这个例子所清楚显示的,我们不需要在executor函数内部解析任何内容。事实上,我们甚至不需要在创建并设置好执行器和满足函数后显式地引用承诺实例。解析器函数已经被存储在某个地方,并且它持有对承诺的引用。

类似承诺的对象

Promise类是一个原始的 JavaScript 类型。然而,我们并不总是需要创建新的承诺实例来实现同步动作的相同行为。有一个静态的Promise.resolve()方法,我们可以用它来解析这样的对象。让我们看看这个方法是如何使用的:

// The "Promise.resolve()" method can resolve thenable
// objects. This is an object with a "then()" method
// which serves as the executor. This executor will
// randomly resolve or reject the promise.
Promise.resolve({ then: (resolve, reject) => {
    Math.round(Math.random()) ?
        resolve('fulfilled') : reject('rejected');

// This method returns a promise, so we're able
// to setup our fulfilled and rejected callbacks as
// usual.
}}).then((value) => {
    console.log('resolved', value);
}, (reason) => {
    console.error('reason', reason);
});

我们将在本章的最后部分重新访问Promise.resolve()方法,以查看更多用例。

构建回调链

在本章中,我们迄今为止检查的每个承诺方法都返回承诺。这允许我们再次在返回值上调用这些方法,从而产生一系列then().then()调用,等等。将承诺调用链在一起的一个挑战性方面是,承诺方法返回的实例是新的实例。也就是说,承诺具有某种不可变性,我们将在本节中探讨这一点。

随着我们的应用程序变大,并发挑战也随之增长。这意味着我们需要考虑更好的方法来利用同步原语,例如承诺。就像 JavaScript 中的任何其他原始值一样,我们可以将它们从一个函数传递到另一个函数。我们必须以相同的方式处理承诺——传递它们,并在回调函数链上构建。

承诺只改变状态一次

承诺从挂起状态开始,并在解决或拒绝状态中结束。一旦承诺过渡到这些状态之一,它们就会卡在这个状态。这有两个有趣的副作用。

首先,多次尝试解决或拒绝承诺会被忽略。换句话说,解析器和拒绝器是幂等的——只有第一次调用对承诺有影响。让我们看看代码是如何体现的:

// This executor function attempts to resolve the
// promise twice, but the fulfilled callback is
// only called once.
new Promise((resolve, reject) => {
    resolve('fulfilled');
    resolve('fulfilled');
}).then((value) => {
    console.log('then', value);
});

// This executor function attempts to reject the
// promise twice, but the rejected callback is
// only called once.
new Promise((resolve, reject) => {
    reject('rejected');
    reject('rejected');
}).catch((reason) => {
    console.error('reason');
});

承诺只改变状态一次的另一个含义是,承诺实际上可能在添加履行或拒绝回调之前就解决了。这种类型的竞态条件是并发编程的残酷现实。通常,回调函数是在创建承诺时添加的。由于 JavaScript 是运行到完成,处理承诺解决回调的工作队列直到回调被添加才会服务。但是,如果承诺在执行器中立即解决怎么办?如果回调在另一个 JavaScript 执行上下文中添加到承诺怎么办?让我们通过一些代码来看看我们是否可以更好地说明这些想法:

// This executor function resolves the promise immediately.
// By the time the "then()" callback is added, the promise
// is already resolved. But the callback is still called
// with the resolved value.
new Promise((resolve, reject) => {
    resolve('done');
    console.log('executor', 'resolved');
}).then((value) => {
    console.log('then', value);
});

// Creates a new promise that's resolved immediately by
// the executor function.
var promise = new Promise((resolve, reject) => {
    resolve('done');
    console.log('executor', 'resolved');
});

// This callback is run immediately, since the promise
// has already been resolved.
promise.then((value) => {
    console.log('then 1', value);
});

// This callback isn't added to the promise for another
// second after it's been resolved. It's still called
// right away with the resolved value.
setTimeout(() => {
    promise.then((value) => {
        console.log('then 2', value);
    });
}, 1000);

这段代码说明了承诺的一个非常重要的特性。无论我们的履行回调是在承诺的挂起状态还是履行状态时添加,都不会影响使用承诺的代码。表面上,这可能看起来不是什么大问题。但这类竞态条件检查需要我们维护更多的并发代码。相反,Promise 原语为我们处理了这个问题,我们可以开始将异步值视为原始类型。

不可变承诺

承诺并不是真正不可变的。它们会改变状态,then() 方法向承诺添加回调函数。然而,有一些承诺的不可变特性在这里值得讨论,因为它们在某些情况下会影响我们的承诺代码。

从技术上来说,then() 方法实际上并没有修改承诺对象。它创建了一个称为承诺能力的内部 JavaScript 记录,该记录引用了承诺和添加的函数。所以,它不是 JavaScript 术语中的真正引用。

下面是一个可视化,说明了当我们链式调用两个或多个 then() 调用时会发生什么:

不可变承诺

如我们所见,then() 方法并不返回与它被调用的上下文相同的实例。相反,then() 创建一个新的承诺实例并返回它。让我们看看一些代码,以更仔细地检查当我们使用 then() 链接承诺时会发生什么:

// Creates a promise that's resolved immediately, and
// is stored in "promise1".
var promise1 = new Promise((resolve, reject) => {
    resolve('fulfilled');
});

// Use the "then()" method of "promise1" to create a
// new promise instance, which is stored in "promise2".
var promise2 = promise1.then((value) => {
    console.log('then 1', value);
    // → then 1 fulfilled
});

// Create a "then()" callback for "promise2". This actually
// creates a third promise instance, but we don't do anything
// with it.
promise2.then((value) => {
    console.log('then 2', value);
    // → then 2 undefined
});

// Make sure that "promise1" and "promise2" are in fact
// different objects.
console.log('equal', promise1 === promise2);
// → equal false

我们可以清楚地看到,在这个例子中创建的两个承诺实例是独立的承诺对象。另一个值得指出的是,第二个承诺绑定到第一个承诺上——当第一个承诺解决时,它也会解决。然而,我们可以看到值并没有传递给第二个承诺。我们将在下一节解决这个问题。

许多then回调,许多承诺

如前所述,使用then()创建的承诺与其创建者绑定。也就是说,当第一个承诺解决时,绑定到它的承诺也会解决,依此类推。然而,我们也注意到了一个轻微的问题。解决后的值并没有通过第一个回调函数。这是因为每个针对承诺解决而运行的回调,其返回值被传递到第二个回调,依此类推。我们的第一个回调之所以得到值作为参数,是因为这发生在承诺机制中是透明的。

让我们看看另一个承诺链的示例。这次,我们将明确从我们的回调函数中返回值:

// Creates a new promise that's randomly resolved or
// rejected.
new Promise((resolve, reject) => {
    Math.round(Math.random()) ?
        resolve('fulfilled') : reject('rejected');
}).then((value) => {
    // Called when the original promise is resolved,
    // returns the value in case there's another
    // promise chained to this one.
    console.log('then 1', value);
    return value;
}).catch((reason) => {
    // Chained to the second promise, called
    // when it's rejected.
    console.error('catch 1', reason);
}).then((value) => {
    // Chained to the third promise, gets the
    // value as expected, and returns it for any
    // downstream promise callbacks to consume.
    console.log('then 2', value);
    return value;
}).catch((reason) => {
    // This is never called - rejections do not
    // proliferate through promise chains.
    console.error('catch 2', reason)
});

这看起来很有希望。现在我们可以看到解决后的值是如何通过承诺链的。但是有一个问题——拒绝不是累积的。相反,链中的第一个承诺实际上是拒绝的。剩余的承诺只是解决,而不是拒绝。这意味着最后一个catch()回调永远不会运行。

当我们以这种方式链式连接承诺时,我们的实现回调函数需要能够处理错误条件。例如,解决后的值可能有一个错误属性,可以对其进行特定检查。

传递承诺

在本节中,我们将扩展将承诺视为原始值的概念。我们经常对原始值做的事情是将它们作为参数传递给函数,并从函数中返回它们。承诺与其他原始值之间的关键区别在于我们如何使用它们。其他值现在存在,而承诺值最终将存在。因此,我们需要通过回调函数定义一些行动方案,以便在值到达时执行。

关于承诺(promises)的优点在于,用于提供这些回调函数的接口既小又一致。当我们能够将值与其将要作用其上的代码耦合时,我们不需要临时发明同步机制。这些单元可以像任何其他值一样在我们的应用程序中移动,并且并发语义是无侵入性的。以下是一些传递承诺的函数的示例:

传递承诺

到这个函数调用栈结束时,我们有一个承诺对象,它反映了多个承诺的解决情况。整个解决链是由第一个承诺的解决触发的。比值如何穿越承诺链的机制更重要的是,所有这些函数都可以自由使用这个承诺值,而不会影响其他函数。

这里有两个并发原则在起作用。首先,我们将通过执行异步操作来获取值只一次;每个回调函数都可以自由使用这个解析值。其次,我们做得很好,抽象了我们的同步机制。换句话说,代码感觉没有负担着样板并发代码。让我们看看传递承诺的代码实际上是什么样子:

// Simple utilty to compose a larger function, out
// of smaller functions.
function compose(...funcs) {
    return function(value) {
        var result = value;

        for (let func of funcs) {
            result = func(value);
        }

        return result;
    };
}

// Accepts a promise or a resolved value. If it's a promise,
// it adds a "then()" callback and returns a new promise.
// Otherwise, it performs the "update" and returns the
// value.
function updateFirstName(value) {
    if (value instanceof Promise) {
        return value.then(updateFirstName);
    }

    console.log('first name', value.first);
    return value;
}

// Works the same way as the above function, except it
// performs a different UI "update".
function updateLastName(value) {
    if (value instanceof Promise) {
        return value.then(updateLastName);
    }

    console.log('last name', value.last);
    return value;
}

// Works the same way as the above function, except it
// performs a different UI "update".
function updateAge(value) {
    if (value instanceof Promise) {
        return value.then(updateAge);
    }

    console.log('age', value.age);
    return value;
}

// A promise object that's resolved with a data object
// after one second.
var promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve({
            first: 'John',
            last: 'Smith',
            age: 37
        });
    }, 1000);
});

// We compose an "update()" function that updates the
// various UI components.
var update = compose(
    updateFirstName,
    updateLastName,
    updateAge
);

// Call our update function with a promise.
update(promise);

这里关键的功能是我们的更新函数——updateFirstName()updateLastName()updateAge()。它们非常灵活,可以接受一个承诺或由承诺解析的值。如果这些函数中的任何一个接收到一个承诺作为参数,它们将通过添加一个 then() 回调函数返回一个新的承诺。请注意,它添加的是同一个函数。updateFirstName() 将添加 updateFirstName() 作为回调。当回调触发时,它将带有这次用于更新 UI 的普通对象。因此,承诺检查失败,我们可以继续更新 UI。

每个函数的承诺检查只需要三行,这并不算特别显眼。最终结果是灵活且易于阅读的代码。顺序并不重要;我们可以在不同的顺序中组合我们的 update() 函数,UI 组件将以相同的方式更新。我们可以直接将普通对象传递给 update(),一切都会正常工作。看起来不像并发代码的并发代码是我们的大胜利。

同步多个承诺

到目前为止,我们本章已经探讨了单个承诺实例,这些实例解析一个值,触发回调,并可能引起其他承诺解析。在本节中,我们将探讨几个静态的承诺方法,这些方法有助于我们在需要同步多个承诺值解析的场景中。

首先,我们将解决一个常见的情况,即我们开发的组件需要同步访问多个异步资源。然后,我们将探讨一个不太常见的场景,即由于 UI 中发生的事件,异步操作在解析之前变得无关紧要。

等待承诺

在我们等待多个承诺解析的情况下,可能需要将多个数据源转换为 UI 组件可消费的格式,我们可以使用 Promise.all() 方法。它接受一组承诺实例作为输入,并返回一个新的承诺实例。这个新实例只有在所有输入承诺都解析后才会解析。

我们提供给由 Promise.then() 创建的新承诺的 then() 回调函数,输入的是一个解析值的数组。这些值在索引位置上对应于输入的承诺。这是一个非常强大的同步机制,它帮助我们实现同步并发原则,因为它隐藏了所有的记录。

我们不是有多个回调,每个回调都需要协调它们所绑定承诺的状态,而是一个回调,它包含了我们需要的所有已解决数据。以下是一个示例,说明如何同步多个承诺:

// Utility to send a "GET" HTTP request, and return
// a promise that's resolved with the parsed response.
function get(path) {
    return new Promise((resolve, reject) => {
        var request = new XMLHttpRequest();

        // The promise is resolved with the parsed
        // JSON data when the data is loaded.
        request.addEventListener('load', (e) => {
            resolve(JSON.parse(e.target.responseText));
        });

        // When there's an error with the request, the
        // promise is rejected with the appropriate reason.
        request.addEventListener('error', (e) => {
            reject(e.target.statusText || 'unknown error');
        });

        // If the request is aborted, we simply resolve the
        // request.
        request.addEventListener('abort', resolve);

        request.open('get', path);
        request.send();
    });
}

// For our request promises.
var requests = [];

// Issues 5 API requests, and places the 5 corresponding
// promises in the "requests" array.
for (let i = 0; i < 5; i++) {
    requests.push(get('api.json'));
}

// Using "Promise.all()" let's us pass in an array of
// promises, returning a new promise that's resolved
// when all promises resolve. Our callback gets an array
// of resolved values that correspond to the promises.
Promise.all(requests).then((values) => {
    console.log('first', values.map(x => x[0]));
    console.log('second', values.map(x => x[1]));
});

取消承诺

在本书中,我们迄今为止看到的 XHR 请求都有处理已取消请求的处理程序。这是因为我们可以手动取消请求,防止任何load回调运行。需要这种功能的一个典型场景是用户点击取消按钮,或者导航到应用程序的不同部分,使得请求变得多余。

如果我们将抽象层次提升到承诺层面,同样的原则适用。在并发操作执行过程中,可能会发生某些事情,使得承诺变得毫无意义。当然,承诺与 XHR 请求之间的区别在于,前者没有abort()方法。我们最不想做的事情就是在我们的承诺回调中引入不必要的取消逻辑。

这就是Promise.race()方法能帮到我们的地方。正如其名所示,该方法返回一个新承诺,该承诺由第一个解决的输入承诺解决。这可能听起来不多,但实现Promise.race()的逻辑并不容易。这是同步原则在起作用,隐藏了并发复杂性,使其从应用程序代码中消失。让我们看看这个方法如何帮助我们处理由于用户交互而取消的承诺:

// The resolver function used to cancel data requests.
var cancelResolver;

// A simple "constant" value, used to resolved cancel
// promises.
var CANCELLED = {};

// Our UI components.
var buttonLoad = document.querySelector('button.load'),
    buttonCancel = document.querySelector('button.cancel');

// Requests data, returns a promise.
function getDataPromise() {

    // Creates the cancel promise. The executor assigns
    // the "resolve" function to "cancelResolver", so
    // it can be called later.
    var cancelPromise = new Promise((resolve) => {
        cancelResolver = resolve;
    });

    // The actual data we want. This would normally be
    // an HTTP request, but we're simulating one here
    // for brevity using setTimeout().
    var dataPromise = new Promise((resolve) => {
        setTimeout(() => {
            resolve({ hello: 'world' });
        }, 3000);
    });

    // The "Promise.race()" method returns a new promise,
    // and it's resolved with whichever input promise is
    // resolved first.
    return Promise.race([
        cancelPromise,
        dataPromise
    ]);
}

// When the cancel button is clicked, we use the
// "cancelResolver()" function to resolve the
// cancel promise.
buttonCancel.addEventListener('click', () => {
    cancelResolver(CANCELLED);
});

// When the load button is clicked, we make a request
// for data using "getDataPromise()".
buttonLoad.addEventListener('click', () => {
    buttonLoad.disabled = true;

    getDataPromise().then((value) => {
        buttonLoad.disabled = false;

        // The promise was resolved, but it was because
        // the user cancelled the request. So we exit
        // here by returning the CANCELLED "constant".
        // Otherwise, we have data to work with.
        if (Object.is(value, CANCELLED)) {
            return value;
        }

        console.log('loaded data', value);
    });
});

注意

作为练习,尝试想象一个更复杂的场景,其中dataPromise是由Promise.all()创建的承诺。我们的cancelResolver()函数将能够无缝取消许多复杂的异步操作。

没有执行者的承诺

在本节的最后,我们将探讨Promise.resolve()Promise.reject()方法。我们已经在本章前面看到Promise.resolve()如何解决 thenable 对象。它还可以直接解决值或其他承诺。当我们实现一个可能既同步又异步的函数时,这些方法非常有用。我们不希望在使用具有模糊并发语义的函数时陷入这种境地。

例如,这是一个既是同步又是异步的函数,导致混淆,并且几乎肯定会导致后续的 bug:

// Example function that returns "value" from
// a cache, or "fetchs" it asynchronously.
function getData(value) {

    // If it exists in the cache, we return
    // this value.
    var index = getData.cache.indexOf(value);

    if (index > -1) {
        return getData.cache[index];
    }

    // Otherwise, we have to go "fetch" it. This
    // "resolve()" call would typically be found in
    // a network request callback function.
    return new Promise((resolve) => {
        getData.cache.push(value);
        resolve(value);
    });
}

// Creates the cache.
getData.cache = [];

console.log('getting foo', getData('foo'));
// → getting foo Promise 
console.log('getting bar', getData('bar'));
// → getting bar Promise
console.log('getting foo', getData('foo'));
// → getting foo foo

我们可以看到最后一个调用返回了一个缓存值,而不是一个承诺。这从直觉上是有意义的,因为我们不是承诺一个最终值,我们已经有它了!问题是,我们向使用我们的getData()函数的任何代码暴露了一个不一致性。也就是说,调用getData()的代码需要处理并发语义。这段代码不是并发的。让我们通过引入Promise.resolve()来改变这一点:

// Example function that returns "value" from
// a cache, or "fetchs" it asynchronously.
function getData(value) {
    var cache = getData.cache;

    // If there's no cache for this function, let's
    // reject the promise. Gotta have cache.
    if (!Array.isArray(cache)) {
        return Promise.reject('missing cache');
    }

    // If it exists in the cache, we return
    // a promise that's resolved using the
    // cached value.
    var index = getData.cache.indexOf(value);

    if (index > -1) {
        return Promise.resolve(getData.cache[index]);
    }

    // Otherwise, we have to go "fetch" it. This
    // "resolve()" call would typically be found in
    // a network request callback function.
    return new Promise((resolve) => {
        getData.cache.push(value);
        resolve(value);
    });
}

// Creates the cache.
getData.cache = [];

// Each call to "getData()" is consistent. Even
// when synchronous values are used, they still
// get resolved as promises.
getData('foo').then((value) => {
    console.log('getting foo', `"${value}"`);
}, (reason) => {
    console.error(reason);
});

getData('bar').then((value) => {
    console.log('getting bar', `"${value}"`);
}, (reason) => {
    console.error(reason);
});

getData('foo').then((value) => {
    console.log('getting foo', `"${value}"`);
}, (reason) => {
    console.error(reason);
});

这样更好。使用Promise.resolve()Promise.reject(),任何使用getData()的代码都会默认获得并发性,即使数据获取操作是同步的。

摘要

本章详细介绍了 ES6 中引入的Promise对象,帮助 JavaScript 程序员解决多年来困扰该语言的同步问题。随着异步性的出现,带来了回调——大量的回调。这导致了我们想尽一切办法避免的回调地狱。

通过实现一个足够通用的简单接口来解析任何值,Promise 帮助我们处理同步问题。Promise 始终处于三种状态之一——挂起、已解决或已拒绝,并且它们只改变一次状态。当这些状态变化发生时,会触发回调。Promise 有一个执行器函数,其任务是设置使用 Promise resolverrejector函数来改变 Promise 状态的异步操作。

Promise 带来的大部分价值在于它们如何帮助我们简化复杂场景。因为,如果我们只需要处理一个运行带有已解决值的回调的单个异步操作,Promise 几乎就没有价值。这不是一个常见的情况。常见的情况是多个异步操作,每个操作都解析值;并且这些值需要同步和转换。Promise 有方法允许我们这样做,因此我们能够更好地将同步并发原则应用到我们的代码中。

在下一章中,我们将探讨另一种新引入的语言原语——生成器。与 Promise 类似,生成器是帮助我们应用并发原则——节省资源的机制。

第四章:使用生成器进行延迟评估

延迟评估是一种编程技术,当我们不想在最后一刻计算值时使用。这样,我们就可以确定我们确实需要它。相反的方法,即急切评估,可能会计算几个不需要的值。这通常不是问题,直到我们的应用程序的大小和复杂性增长到用户无法察觉这些浪费计算的程度。

生成器是 JavaScript 语言 ES6 规范中引入的一种新原始类型。生成器帮助我们实现代码中的延迟评估技术,并且作为推论,帮助我们实现节省并发原则。

我们将从一些简单的生成器介绍开始本章,这样我们就可以了解它们的行为。然后,我们将转向更高级的延迟评估场景,并以协程的概述结束本章。让我们开始吧。

调用栈和内存分配

内存分配是任何编程语言的必要条件。没有它,我们就没有数据结构可以工作,甚至没有原始类型。内存很便宜,看起来似乎有足够的内存可用;这还不是庆祝的理由。虽然今天在内存中分配较大的数据结构比 10 年前更容易,但我们仍然需要在完成时释放内存。JavaScript 是一种垃圾回收语言,这意味着我们的代码不需要显式地在内存中销毁对象。然而,垃圾回收器会带来 CPU 的惩罚。

因此,这里有两个因素在起作用。我们想要节省两种资源,我们将尝试使用生成器来实现延迟评估。我们不希望无谓地分配内存,如果我们能避免这一点,那么我们就可以避免频繁地调用垃圾回收器。在本节中,我将介绍一些生成器概念。

标记函数上下文

在正常的函数调用栈中,一个函数返回一个值。return语句激活一个新的执行上下文,并丢弃旧的上下文,因为我们已经返回了,所以我们已经完成了。生成器函数是一种特殊的 JavaScript 函数,用它们自己的语法表示,它们的调用栈与return语句相比并不那么简单明了。以下是调用生成器函数时发生的情况的视觉表示,它开始产生值:

标记函数上下文

正如return语句将值传递给调用上下文一样,yield语句将值返回。然而,与普通函数不同,生成器函数上下文不会被丢弃。实际上,它们被标记,以便当控制权返回给生成器上下文时,它可以从中断的地方继续产生值,直到完成。这个标记数据非常微不足道,因为它仅仅指向我们代码中的一个位置。

序列而不是数组

在 JavaScript 中,当我们需要遍历一系列事物,如数字、字符串、对象等时,我们使用数组。数组是通用且强大的。在延迟评估的上下文中,数组的挑战在于数组本身是需要分配的数据。因此,数组内的元素需要在内存中分配某个位置,我们还需要有关数组中元素元数据。

如果我们正在处理大量对象,与数组相关的内存开销是显著的。此外,我们还需要以某种方式将这些对象放入数组中。这是一个额外的步骤,会增加 CPU 时间。另一种概念是序列。序列并不是一个实际的 JavaScript 语言结构。它是一个抽象概念——实际上不分配数组的数组。序列有助于延迟评估。正因为如此,没有需要分配的内容,也没有初始填充步骤。以下是迭代数组所涉及的步骤的示意图:

用序列代替数组

如我们所见,在我们能够遍历这三个对象之前,我们首先必须分配一个数组,然后填充这些对象。让我们通过以下图表来对比这种方法和序列的概念:

用序列代替数组

在序列中,我们没有为我们要迭代的对象提供一个显式的容器结构。与序列相关的唯一开销是当前项的指针。我们可以使用生成器函数作为在 JavaScript 中生成序列的机制。正如我们在前一节中看到的,生成器在向调用者返回值时,会标记其执行上下文。这正是我们所寻找的最小开销。它使我们能够延迟评估对象,并以序列的形式迭代它们。

创建生成器和产生值

在本节中,我将介绍生成器函数的语法,并带大家了解如何从生成器中产生值。我们还将探讨两种可以用来遍历生成器产生的值的途径。

生成器函数语法

生成器函数的语法几乎与普通函数相同。声明中的区别在于function关键字后面跟着一个星号。更深刻的不同之处在于返回值,它始终是一个生成器实例。此外,尽管创建了一个新对象,但不需要new关键字。让我们看看生成器函数的样子:

// Generator functions use an asterisk to
// denote a that a generator instance is returned.
// We can return values from generators, but instead
// of the caller getting that value, they'll always
// get a generator instance.
function* gen() {
    return 'hello world';
}

// Creates the generator instance.
var generator = gen();

// Let's see what this looks like.
console.log('generator', generator);
// → generator Generator

// Here's how we get the return value. Looks awkward,
// because we would never use a generator function
// that simply returns a single value.
console.log('return', generator.next().value);
// → return hello world

我们几乎不可能以这种方式使用生成器,但这是一个很好的方式来展示生成器函数的细微差别。例如,return语句在生成器函数中是完全有效的,然而,它们对调用者产生了完全不同的结果,正如我们所看到的。在实践中,我们更有可能遇到生成器中的yield语句,所以让我们看看它们。

产生值

生成器函数的常见情况是产生值并将控制权交还给调用者。将控制权交还给调用者是生成器的定义特征。当我们产生值时,生成器会标记我们的代码位置。它这样做是因为调用者很可能会从生成器请求另一个值,当它这样做时,生成器只需从上次离开的地方继续即可。让我们看看一个多次产生值的生成器函数:

// This function yields values, in order. There's no
// container structure, like an array. Instead, each time
// the yield statement is called, control is yielded
// back to the caller, and the position in the function
// is bookmarked.
function* gen() {
    yield 'first';
    yield 'second';
    yield 'third';
}

var generator = gen();

// Each time we call "next()", control is passed back
// to the generator function's execution context. Then,
// the generator looks up the bookmark for where it
// last yielded control.
console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().value);

之前的代码展示了序列的样子。我们有三个值,它们按顺序从我们的函数中产生。它们也没有被放入任何容器结构中。第一次调用yieldfirst传递给next(),这就是它被使用的地方。对于其他两个值也是同样的情况。实际上,这就是惰性求值的作用。我们有三个console.log()调用。gen()的急切实现会返回一个值集合供我们记录。相反,当我们需要记录一个值时,我们会去生成器中获取它。这就是惰性因素;我们直到实际需要时才保存我们的努力,避免分配和计算。

我们之前示例的不太理想之处在于,我们实际上在重复调用console.log(),而实际上我们想要迭代序列,为序列中的每个项目调用console.log()。现在让我们迭代一些生成器序列。

迭代生成器

next()方法,不出所料,给我们生成器序列中的下一个值。next()实际返回的对象有两个属性:产生的value和生成器是否done。然而,我们通常不想硬编码我们的next()调用。相反,我们希望随着生成器产生值而迭代地调用它。以下是一个使用while循环迭代生成器的示例:

// A basic generator function that yields
// sequential values.
function* gen() {
    yield 'first';
    yield 'second';
    yield 'third';
}

// Creates the generator.
var generator = gen();

// Loop till the sequence is finished.
while(true) {

    // Gets the next item from the sequence.
    let item = generator.next();

    // Is there a next value, or are we done?
    if (item.done) {
        break;
    }

    console.log('while', item.value);
}

这个循环将继续,直到产生的项目的done属性为true;在这个时候,我们知道没有更多项目了,因此我们可以停止。这允许我们迭代一个产生的值序列,而无需创建一个仅用于迭代的数组。然而,这个循环中有许多样板代码,与生成器迭代的管理比实际迭代更多。让我们看看另一种方法:

// The "for..of" loop removes the need to explicitly
// call generator constructs, like "next()", "value",
// and "done".
for (let item of generator) {
    console.log('for..of', item);
}

这要好得多。我们已经将代码压缩成更专注于当前任务的东西。这段代码本质上与我们的while循环做的是完全相同的事情,除了for..of语句,它理解当可迭代对象是生成器时应该做什么。在并发 JavaScript 应用程序中迭代生成器是一种常见模式,因此在这里优化为紧凑和可读的代码是一个明智的决定。

无穷序列

一些序列是无限的,如素数、斐波那契数、奇数等。无穷序列不仅限于数字集合;还可以考虑更抽象的概念为无限。例如,一个无限重复的字符串集合,一个无限切换的布尔值,等等。在本节中,我们将探讨生成器如何使我们能够处理无穷序列。

无尽头

从无穷序列中分配项目从内存消耗的角度来看并不实用。事实上,甚至无法分配整个序列——它是无限的。内存是有限的。因此,最好是完全避开整个分配问题,并使用生成器按需生成序列中的值。在任何给定的时间点,我们的应用程序只会使用无穷序列的一小部分。以下是无穷序列的使用与这些序列潜在大小的可视化:

无尽头

如我们所见,有大量的项目可用,但我们永远不会在这个序列中使用。让我们看看一些生成器代码,它可以从无穷大的斐波那契序列中懒加载项目:

// Generates an infinite Fibonacci sequence.
function* fib() {
    var seq = [ 0, 1 ],
        next;

    // This loop doesn't actually run infinitely,
    // only as long as items from the sequence
    // are requested using "next()".
    while (true) {

        // Yields the next item in the sequence.
        yield (next = seq[0] + seq[1]);

        // Stores state necessary to compute the
        // item in the next iteration.
        seq[0] = seq[1];
        seq[1] = next;
    }
}

// Launch the generator. This will never be "done"
// generating values. However, it's lazy - it only
// generates what we ask for.
var generator = fib();

// Gets the first 5 items of the sequence.
for (let i = 0; i < 5; i++) {
    console.log('item', generator.next().value);
}

交替序列

无穷序列的变体可以是循环序列或交替序列。当达到序列的末尾时,这些类型的序列是循环的;它们从开始处重新开始。以下是一个在两个值之间交替的序列的示例:

交替序列

这些类型的序列将无限期地生成值。当我们有一组规则来定义序列以及生成的项目集合时,这很有用;然后,我们再次从头开始这个集合。现在,让我们看看一些代码,看看如何使用生成器实现这些序列。以下是一个通用的生成器函数,我们可以用它来在值之间交替:

// A generic generator that will infinitely iterate
// over the provided arguments, yielding each item.
function* alternate(...seq) {
    while (true) {
        for (let item of seq) {
            yield item;
        }
    }
}

这是我们第一次声明一个接受参数的生成器函数。实际上,我们正在使用扩展运算符来遍历传递给函数的参数。与参数不同,我们使用扩展运算符创建的seq参数是一个真正的数组。当我们遍历这个数组时,我们从生成器中产生每个项目。乍一看,这可能并不那么有用,但正是while循环在这里增加了真正的力量。由于while循环永远不会退出,for循环将简单地重复自己。也就是说,它会交替。这消除了显式记账代码的需要(我们是否到达了序列的末尾?我们如何重置计数器并回到开始?等等)让我们看看这个生成器函数是如何工作的:

// Create a generator that alternates between
// the provided arguments.
var alternator = alternate(true, false);

console.log('true/false', alternator.next().value);
console.log('true/false', alternator.next().value);
console.log('true/false', alternator.next().value);
console.log('true/false', alternator.next().value);
// → 
// true/false true
// true/false false
// true/false true
// true/false false

很酷。所以alternator将一直生成true/false值,只要我们继续请求它们。这里的主要好处是我们不需要知道下一个值,alternator会为我们处理这个问题。让我们看看这个生成器函数,它使用不同的序列进行迭代:

// Create a new generator instance, with new values
// to alternate with each iteration.
alternator = alternate('one', 'two', 'three');

// Gets the first 10 items from the infinite sequence.
for (let i = 0; i< 10; i++) {
    console.log('one/two/three',
        `"${alternator.next().value}"`);
}
// → 
// one/two/three "one"
// one/two/three "two"
// one/two/three "three"
// one/two/three "one"
// one/two/three "two"
// one/two/three "three"
// one/two/three "one"
// one/two/three "two"
// one/two/three "three"
// one/two/three "one"

如我们所见,alternate()函数在交替传递给它的任何参数时非常有用。

委托给其他生成器

我们已经看到yield语句能够暂停生成器函数的执行上下文,并将值返回到调用上下文。yield语句有一个变体,允许我们委托给其他生成器函数。另一种技术是通过交织几个生成器源来创建一个生成器网格。在本节中,我们将探讨这两个想法。

选择策略

委托给其他生成器使我们的函数能够在运行时决定,将控制权从一个生成器传递给另一个生成器。换句话说,它允许根据策略选择更合适的生成器函数。以下是一个生成器函数的视觉表示,该函数做出决定并委托给几个其他生成器函数之一:

选择策略

我们这里有三个专门的生成器,我们希望在应用程序的各个地方使用它们。也就是说,它们各自以独特的方式工作。也许,它们是为特定类型的输入量身定制的。然而,这些生成器只是对它们所提供的输入做出假设。这可能不是最佳工具,因此,我们必须弄清楚使用哪个生成器。我们想要避免的是在各个地方实现这个策略选择代码。如果能够将所有这些封装到一个通用目的的生成器中,该生成器可以捕捉到代码中的常见情况,那就太好了。

假设我们有以下生成器函数,并且它们在我们的整个应用程序中都被同等使用:

// Generator that maps a collection of objects
// to a specific property name.
function* iteratePropertyValues(collection, property) {
    for (let object of collection) {
        yield object[property];
    }
}

// Generator that yields each value of the given object.
function* iterateObjectValues(collection) {
    for (let key of Object.keys(collection)) {
        yield collection[key];
    }
}

// Generator that yields each item from the given array.
function* iterateArrayElements(collection) {
    for (let element of collection) {
        yield element;
    }
}

这些函数都是小而简洁的,并且它们在需要的地方很容易使用。问题是每个这些函数都对传入的集合做出了假设。它是对象的数组,每个对象都有特定的属性吗?它是字符串数组吗?它是一个对象而不是数组吗?由于这些生成器函数在我们的代码中用于类似的目的,并且经常被使用,我们可以实现一个更通用的迭代器,其任务是确定使用哪个最佳生成器函数,然后将其委托给它。让我们看看这个函数是什么样子:

// This generator defers to other generators. But first,
// it executes some logic to determine the best strategy.
function* iterateNames(collection) {

    // Are we dealing with an array?
    if (Array.isArray(collection)) {

        // This is a heuristic where we check the first
        // element of the array. Based on what's there, we
        // make assumptions about the remaining elements.
        let first = collection[0];

        // Here is where we defer to other more specialized
        // generators, based on what we find out about the
        // first array element.
        if (first.hasOwnProperty('name')) {
            yield* iteratePropertyValues(collection,
                'name');
        } else if (first.hasOwnProperty('customerName')) {
            yield* iteratePropertyValues(collection,
                'customerName');
        } else {
            yield* iterateArrayElements(collection);
        }
    } else {
        yield* iterateObjectValues(collection);
    }
}

iterateNames()函数视为其他三个生成器中的任何一个的简单代理。它检查输入并根据集合做出选择。我们本可以实施一个大的生成器函数,但那样会阻止我们在需要直接使用较小生成器的情况下使用。如果我们想将它们用于组合新功能,或者如果另一个组合生成器想使用它怎么办?始终保持生成器函数小而专注是一个好主意。yield*语法允许我们将控制权传递给更合适的生成器。

现在,让我们看看这个通用生成器函数是如何通过委托给最适合处理数据的生成器来使用的:

var collection;

// Iterates over an array of string names.
collection = [ 'First', 'Second', 'Third' ];

for (let name of iterateNames(collection)) {
    console.log('array element', `"${name}"`);
}

// Iterates over an object, where the names
// are the values - the keys aren't relevant here.
collection = {
    first: 'First',
    second: 'Second',
    third: 'Third'
};

for (let name of iterateNames(collection)) {
    console.log('object value', `"${name}"`);
}

// Iterates over the "name" property of each object
// in the collection.
collection = [
    { name: 'First' },
    { name: 'Second' },
    { name: 'Third' }
];

for (let name of iterateNames(collection)) {
    console.log('property value', `"${name}"`);
}

交织生成器

当一个生成器委托给另一个生成器时,控制权不会在第二个生成器完全完成后交还给第一个生成器。在前面的例子中,我们的生成器只是寻找一个更好的生成器来完成工作。然而,在其他时候,我们可能想要使用两个或更多数据源。因此,而不是将控制权传递给一个生成器,然后传递给另一个,如此类推,我们将交替使用各种来源,轮流消耗数据。

下面是一个图解,说明了将多个数据源交织在一起以创建单个数据源的生成器概念:

交织生成器

策略是轮询数据源,而不是清空一个源,然后另一个,如此类推。当没有单个大型集合供我们工作时,这种生成器非常有用,而是有两个或更多集合。使用这种生成器技术,我们实际上可以将多个数据源视为一个大型源,而无需为大型结构分配内存。让我们看看以下代码示例:

'use strict';

// Utility function that converts the input array to a
// generator by yielding each of it's values. If its
// not an array, it assumes it's already a generator
// and defers to it.
function* toGen(array) {
    if (Array.isArray(array)) {
        for (let item of array) {
            yield item;
        }
    } else {
        yield* array;
    }
}

// Interweaves the given data sources (arrays or
// generators) into a single generator source.
function* weave(...sources) {

    // This controls the "while" loop. As long as
    // there's a source that's yielding data, the
    // while loop is still valid.
    var yielding = true;

    // We have to make sure that each of our
    // sources is a generator.
    var generators = sources.map(
        source =>toGen(source));

    // Starts the main weaving loop. It makes it's
    // way through each source, yielding one item
    // from each, then starting over, till every
    // source is empty.
    while (yielding) {
        yielding = false;

        for (let source of generators) {
            let next = source.next();

            // As long as we're yielding data, the
            // "yielding" value is true, and the
            // "while" loop continues. As soon as
            // "done" is true for every source, the
            // "yielding" variable stays false, and
            // the "while loop exits.
            if (!next.done) {
                yielding = true;
                yield next.value;
            }
        }
    }
}

// A basic filter that generates values by
// iterating over the given source, and yielding items
// that are not disabled.
function* enabled(source) {
    for (let item of source) {
        if (!item.disabled) {
            yield item;
        }
    }
}

// These are the two data sources we want to weave
// together into one generator, which can then be
// filtered by another generator.
var enrolled = [
    { name: 'First' },
    { name: 'Sencond' },
    { name: 'Third', disabled: true }
];

var pending = [
    { name: 'Fourth' },
    { name: 'Fifth' },
    { name: 'Sixth', disabled: true }
];

// Creates the generator, which yields user objects
// from two data sources.
var users = enabled(weave(enrolled, pending));

// Actually performs the weaving and filtering.
for (let user of users) {
    console.log('name', `"${user.name}"`);
}

将数据传递给生成器

yield语句不仅将控制权交还给调用者,还返回一个值。这个值通过next()方法传递给生成器函数。这就是我们在生成器创建后如何将数据传递给生成器的方式。在本节中,我们将讨论生成器的双向特性,以及创建反馈循环可以产生一些简洁的代码。

重复使用生成器

一些生成器是通用型的,并在我们的代码中频繁使用。在这种情况下,我们是否需要不断地创建和销毁这些生成器实例?或者我们可以重用它们?例如,考虑一个主要依赖于初始条件的序列。假设我们想要生成一个偶数序列。我们会从 2 开始,当我们遍历这个生成器时,值会增加。下次我们想要遍历偶数时,我们必须创建一个新的生成器。

这有点浪费,因为我们所做的只是重置一个计数器。如果我们采取不同的方法,一种可以让我们继续使用相同的生成器实例来处理这些类型的序列的方法会怎样?生成器的next()方法是实现这种功能的可能途径之一。我们可以传递一个值,然后重置我们的计数器。因此,每次我们需要遍历偶数时,我们不必创建一个新的生成器实例,只需用重置生成器初始条件的值调用next()即可。

实际上,yield关键字返回一个值——传递给next()的参数。大多数情况下,这是未定义的,例如当生成器在for..of循环中迭代时。然而,这就是我们能够在生成器开始运行后传递参数给生成器的方式。这与传递参数给生成器函数不同,这对于生成器的初始配置很有用。传递给next()的值是我们需要更改下一个要生成的值时与生成器交流的方式。

让我们看看我们如何可以使用next()方法创建一个可重用的偶数序列生成器:

// This generator will keep generating even numbers.
function* genEvens() {

    // The initial value is 2\. But this can change based
    // on the input passed to "next()".
    var value = 2,
        input;

    while (true) {

        // We yield the value, and get the input. If 
        // input is provided, this will serve as the
        // next value.
        input = yield value;

        if (input) {
            value = input;
        } else {
            // Make sure that the next value is even.
            // Handles the case when an odd value is
            // passed to "next()".
            value += value % 2 ? 1 : 2;
        }
    }
}

// Creates the "evens" generator.
var evens = genEvens(),
    even;

// Iterate over evens up to 10.
while ((even = evens.next().value) <= 10) {
    console.log('even', even);
}
// →
// even 2
// even 4
// even 6
// even 8
// even 10

// Resets the generator. We don't need to
// create a new one.
evens.next(999);

// Iterate over evens between 1000 - 1024.
while ((even = evens.next().value) <= 1024) {
    console.log('evens from 1000', even);
}
// → 
// evens from 1000 1000
// evens from 1000 1002
// evens from 1000 1004
// evens from 1000 1006
// evens from 1000 1008
// evens from 1000 1010
// evens from 1000 1012
// evens from 1000 1014
// ...

注意

如果你想知道为什么我们不使用for..of循环而使用while循环,那是因为你使用for..of循环来遍历生成器。当你这样做时,一旦循环退出,生成器就会被标记为完成。因此,它将不再可用。

轻量级 map/reduce

我们还可以使用next()方法将一个值映射到另一个值。例如,假设我们有一个包含七个项目的集合。为了映射这些项目,我们会遍历这个集合,将每个项目传递给next()。正如我们在前面的部分中看到的,这种方法可以重置生成器的状态,但它也可以用来提供输入数据流,就像它提供输出数据流一样。

让我们看看我们是否可以编写一些代码来实现这一点——通过将集合项目通过next()馈入生成器来映射集合项目:

// This generator will keep iterating, as
// long as "next()" is called. It's expecting
// a value as well, so that it can call the
// "iteratee()" function on it, and yield the
// result.
function* genMapNext(iteratee) {
    var input = yield null;

    while (true) {
        input = yield iteratee(input);
    }
}

// Our array of values we want to map.
var array = [ 'a', 'b', 'c', 'b', 'a' ];

// A "mapper" generator. We pass an iteratee
// function as an argument to "genMapNext()".
var mapper = genMapNext(x =>x.toUpperCase());

// Our starting point for the reduction.
var reduced = {};

// We have to call "next()" to bootstrap the
// generator. 
mapper.next();

// Now we can start iterating over the array.
// The "mapped" value is yielded from the
// generator. The value we want mapped is fed
// into the generator by passing it to "next()".
for (let item of array) {
    let mapped = mapper.next(item).value;

    // Our reduction logic takes the mapped value,
    // and adds it to the "reduced" object, counting
    // the number of duplicate keys.
    if (reduced.hasOwnProperty(mapped)) {
        reduced[mapped]++;
    } else {
        reduced[mapped] = 1;
    }
}

console.log('reduced', reduced);
// → reduced { A: 2, B: 2, C: 1 }

如我们所见,这确实可行。我们可以使用这种方法执行轻量级的 map/reduce 作业。映射生成器具有应用于集合中每个项目的iteratee函数。当我们遍历数组时,我们可以通过将它们作为参数传递给next()方法来将这些项目馈入生成器。

但是,关于之前的方法,总感觉它并不那么优化——需要这样启动生成器,并且每次迭代都要显式调用next(),感觉有点笨拙。实际上,我们能否直接应用iteratee函数,而不是调用next()呢?在使用生成器时,我们需要留意这些事情;特别是在向生成器传递数据时。仅仅因为我们能够这样做,并不意味着这是一个好主意。

如果我们像对待其他所有生成器一样简单地迭代生成器,映射和归约可能会感觉更自然。我们仍然想要生成器提供的轻量级映射,以避免内存分配。让我们尝试一种不同的方法——一种不需要next()方法的方法:

// This generator is a more useful mapper than
// "genMapNext()" because it doesn't rely on values
// coming into the generator through "next()".
//
// Instead, this generator accepts an iterable, and
// an iteratee function. The iterable is then
// iterated-over, and the result of the iteratee
// is yielded.
function* genMap(iterable, iteratee) {
    for (let item of iterable) {
        yield iteratee(item);
    }
}

// Creates our "mapped" generator, using an iterable
// data source, and an iteratee function.
var mapped = genMap(array, x =>x.toUpperCase());
var reduced = {}

// Now we can simply iterate over our genrator, instead
// of calling "next()". The job of each loop iteration
// is to perform the reduction logic, instead of having
// to call "next()".
for (let item of mapped) {
    if (reduced.hasOwnProperty(item)) {
        reduced[item]++;
    } else {
        reduced[item] = 1;
    }
}

console.log('reduce improved', reduced);
// → reduce improved { A: 2, B: 2, C: 1 }

这看起来像是一个改进。代码更少,生成器的流程也更容易理解。区别在于我们提前将数组和我们自己的iteratee函数传递给生成器。然后,当我们迭代生成器时,每个项目都会被惰性地映射。将这个数组归约成一个对象的代码也更容易阅读。

我们刚刚实现的genMap()函数是通用的,这对我们来说是有利的。在实际应用中,映射将比大写转换更复杂。更有可能的是,会有多个映射级别。也就是说,我们映射集合,然后映射它 N 次,然后再进行归约。如果我们已经很好地设计了我们的代码,那么我们将想要将较小的迭代函数组合成生成器。

但是,我们如何保持这种通用性和惰性呢?想法是拥有几个生成器,每个生成器都作为下一个生成器的输入。这意味着当我们的归约代码迭代这些生成器时,只有一个项目会通过映射的各个层次,到达归约代码。让我们尝试实现这一点:

// This function composes a generator
// function out of iteratees. The idea is to create
// a generator for each iteratee, so that each item
// from the original iterable, flows down, through
// each iteratee, before mapping the next item.
function composeGenMap(...iteratees) {

    // We're returning a generator function. That way,
    // the same mapping composition can be used on
    // several iterables, not just one.
    return function* (iterable) {

        // Creates the generator for each iteratee
        // passed to the function. The next generator
        // gets the previous generator as the "iterable"
        // argument.
        for (let iteratee of iteratees) {
            iterable = genMap(iterable, iteratee);
        }

        // Simply defer to the last iterable we created.
        yield* iterable;
    }
}

// Our iterable data source.
var array = [ 1, 2, 3 ];

// Creates a "composed" mapping generator, using 3
// iteratee functions.
var composed = composeGenMap(
    x => x + 1,
    x => x * x,
    x => x - 2
);

// Now we can iterate over the composed generator,
// passing it our iterable, and lazily mapping
// values.
for (let item of composed(array)) {
    console.log('composed', item)
}
// →
// composed 2
// composed 7
// composed 14

协程

协程是一种并发技术,允许协作式多任务处理。这意味着如果我们的应用程序的某个部分需要执行任务的一部分,它可以这样做,然后将控制权交给应用程序的另一个部分。想想子程序,或者更近期的函数。这些子程序通常依赖于其他子程序。然而,它们并不是依次运行,而是相互协作。

在 JavaScript 中,没有内建的协程机制。生成器不是协程,但它们有相似的性质。例如,生成器可以暂停函数的执行,将控制权交给另一个上下文,然后重新获得控制权并继续执行。这让我们前进了一步,但生成器是用来生成值的,这并不一定是协程的目标。在本节中,我们将探讨一些使用生成器在 JavaScript 中实现协程的技术。

创建协程函数

生成器为我们提供了在 JavaScript 中实现coroutine函数所需的大部分功能;它们可以暂停和恢复执行。我们只需要在生成器周围实现一些小的抽象,这样我们正在处理的函数就会感觉像是调用coroutine函数,而不是迭代生成器。以下是我们希望我们的协程在调用时表现的大致示例:

创建协程函数

想法是调用coroutine函数会从一个yield语句移动到下一个。我们可以通过传递一个参数来向协程提供输入,然后该参数由yield语句返回。这有很多东西需要记住,所以让我们在函数包装器中概括这些协程概念:

// Taken from: http://syzygy.st/javascript-coroutines/
// This utility takes a generator function, and returns
// a coroutine function. Any time the coroutine is invoked,
// it's job is to call "next()" on the generator.
//
// The effect is that the generator function can run
// indefinitely, pausing when it hits "yield" statements.
function coroutine(func) {

    // Creates the generator, and moves the function
    // ahead to the first "yield" statement.
    var gen = func();
    gen.next();

    // The "val" is passed to the generator function
    // through the "yield" statement. It then resumes
    // from there, till it hits another yield.
    return function(val) {
        gen.next(val);
    }
}

非常简单——只有五行代码,但功能强大。Harold 的包装函数返回的函数只是将生成器推进到下一个yield语句,如果提供了参数,则将其提供给next()。声称有用是一回事,但让我们实际使用这个工具来创建一个coroutine函数:

// Creates a coroutine function that when called,
// advances to the next yield statement.
var coFirst = coroutine(function* () {
    var input;

    // Input comes from the yield statement, and is
    // the argument value passed to "coFirst()".
    input = yield;
    console.log('step1', input);
    input = yield;
    console.log('step3', input);
});

// Works the same as the coroutine created above...
var coSecond = coroutine(function* () {
    var input;
    input = yield;
    console.log('step2', input);
    input = yield;
    console.log('step4', input);
});

// The two coroutines cooperating with one another,
// to produce the expected output. We can see that
// the second call to each coroutine picks up where
// the last yield statement left off.
coFirst('the money');
coSecond('the show');
coFirst('get ready');
coSecond('go');
// → 
// step1 the money
// step2 the show
// step3 get ready
// step4 go

当涉及到一系列步骤来完成某个任务时,我们通常需要记账代码、临时值等等。但在协程中这些都不是必需的,因为函数只是暂停,保留任何局部状态不变。换句话说,当协程能够很好地为我们隐藏这些细节时,我们不需要将并发逻辑与我们的应用逻辑交织在一起。

处理 DOM 事件

我们还可以在 DOM 事件处理程序中使用协程。这是通过将相同的coroutine()函数作为事件监听器添加到多个元素上实现的。让我们回顾一下,对这些协程函数的每次调用都对应一个单独的生成器。这意味着我们设置来处理 DOM 事件的协程被作为流传递。这几乎就像我们正在迭代这些事件。

由于这些coroutine函数使用相同的生成器,元素之间使用这种技术进行通信变得很容易。处理 DOM 事件的典型方法涉及回调函数,这些函数与某种中央源进行通信,该源在元素之间共享并维护状态。使用协程,元素通信的状态隐含在我们的函数代码中。让我们在 DOM 事件处理程序的上下文中使用我们的协程包装器:

// Coroutine function that's used with mousemove
// events.
var onMouseMove = coroutine(function* () {
    var e;

    // This loop continues indefinitely. The event
    // object comes in through the yield statement.
    while (true) {
        e = yield;

        // If the element is disabled, do nothing.
        // Otherwise, log a message.
        if (e.target.disabled) {
            continue;
        }

        console.log('mousemove', e.target.textContent);
    }
});

// Coroutine function that's used with click events.
var onClick = coroutine(function* () {

    // Store references to our two buttons. Since
    // coroutines are stateful, they'll always be 
    // available.
    var first = document.querySelector(
        'button:first-of-type');
    var second = document.querySelector(
        'button:last-of-type'),
    var e;

    while (true) {
        e = yield;

        // Disables the button that was clicked.
        e.target.disabled = true;

        // If the first button was clicked, toggle
        // the state of the second button.
        if (Object.is(e.target, first)) {
            second.disabled = !second.disabled;
            continue;
        }

        // If the second button was clicked, toggle
        // the state of the first button.
        if (Object.is(e.target, second)) {
            first.disabled = !first.disabled;
        }
    }
});

// Sets up the event handlers - our coroutine functions.
for (let button of document.querySelectorAll('button')) {
    button.addEventListener('mousemove', onMouseMove);
    button.addEventListener('click', onClick);
}

处理承诺值

在前面的章节中,我们看到了如何使用coroutine()函数来处理 DOM 事件。我们不是随意添加响应 DOM 事件的回调函数,而是使用相同的coroutine()函数,它将事件视为数据流。由于它们共享相同的生成器上下文,DOM 事件处理程序之间的协作变得更加容易。

我们可以将同样的原则应用到 promise 的then()回调中,这与 DOM 协程方法类似。我们不是传递一个常规函数,而是将协程传递给then()。当 promise 解决时,coroutine会随着解决值一起前进到下一个yield语句。让我们看一下以下代码:

// An array of promises.
var promises = [];

// Our resolution callback is a coroutine. This means
// that every time it's called, a new resolved promise
// value shows up here.
var onFulfilled = coroutine(function* () {
    var data;

    // Continue to process resolved promise values
    // as they arrive.
    while (true) {
        data = yield;
        console.log('data', data);
    }
});

// Create 5 promises that resolve at random times,
// between 1 and 5 seconds.
for (let i = 0; i< 5; i++) {
    promises.push(new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(i);
    }, Math.floor(
        Math.random() * (5000 - 1000)) + 1000);
    }));
}

// Attach our fulfillment coroutine as a "then()" callback.
for (let promise of promises) {
    promise.then(onFulfilled);
}

这非常有用,因为它提供了一些静态 promise 方法所不具备的东西。Promise.all()方法强制我们等待所有 promise 解决后,才解决返回的 promise。然而,在解决 promise 值之间没有相互依赖的情况下,我们可以简单地遍历它们,以它们解决的任何顺序做出响应。

我们可以通过将普通函数附加到then()作为回调来实现类似的效果,但那样的话,我们就没有一个共享的上下文来处理解决时的 promise 值。另一种我们可以采用的方法是将 promise 与协程结合使用,声明一些响应不同,取决于它们响应的数据类型的协程。然后,这些协程将在整个应用期间存在,当它们被创建时,会传递给 promise。

摘要

本章向您介绍了生成器的概念,这是 ES6 中的一种新结构,它允许我们实现惰性求值。生成器帮助我们实现节省并发原则,因为我们能够避免计算和中间内存分配。与生成器相关联有一些新的语法形式。首先,是生成器函数,它总是返回一个生成器实例。这些函数的声明方式与常规函数不同。这些函数负责生成值,这依赖于yield关键字。

我们随后探讨了更高级的生成器和惰性求值主题,包括委派给其他生成器、实现 map/reduce 功能以及将数据传递到生成器中。我们以如何使用生成器创建协程函数来结束本章。

在下一章中,我们将探讨 Web Workers——这是我们在浏览器环境中首次接触利用并行性的方法。

第五章。与 Workers 一起工作

Web Workers 允许在网页浏览器内实现真正的并行处理。它们已经经过了一段时间的成熟发展,并且现在得到了很好的供应商支持。在 Web Workers 出现之前,我们的 JavaScript 代码被限制在 CPU 上,我们的执行环境从页面首次加载时开始。Web Workers 出现是由于需求的推动——Web 应用程序的能力在不断增强。同时,它们也开始需要更多的计算能力。在今天,多个 CPU 核心变得很常见——甚至在低端硬件上也是如此。

在本章中,我们将探讨 Web Workers 的概念,以及它们如何与我们试图在应用程序中实现的并发原则相关联。然后,你将通过示例学习如何使用 Web Workers,这样在本书的后续部分,我们就可以开始将并行性与其他我们已经探索过的想法联系起来,例如承诺(promises)和生成器(generators)。

工作者是什么?

在我们深入实现示例之前,本节将快速概述 Web Workers 的概念,以及它们在系统内部如何与其他部分协同工作。Web Workers 是操作系统线程——一个我们可以调度事件的目标,并且它们以真正的并行方式执行我们的 JavaScript 代码。

操作系统线程

在本质上,Web Workers 仅仅是操作系统级别的线程。线程有点像进程,但它们需要的开销更少,因为它们与创建它们的进程共享内存地址。由于驱动 Web Workers 的线程处于操作系统级别,我们依赖于系统和其进程调度器。大多数时候,这正是我们想要的——让内核决定我们的 JavaScript 代码何时运行,以便最佳地利用 CPU。

这里有一个图表展示了浏览器如何将 Web Workers 映射到操作系统线程,以及这些线程如何映射到 CPU 核心:

操作系统线程

最后,最好让操作系统负责处理它擅长的事情——在物理硬件上调度软件任务。在更传统的多线程编程环境中,我们的代码与操作系统内核的距离更近。Web Workers 的情况并非如此。虽然底层机制是线程,但暴露的编程接口看起来更像是你可能在 DOM 中找到的东西。

事件目标

Web Workers 实现了熟悉的事件目标接口。这使得 Web Workers 的行为与其他我们习惯使用的组件相似,例如 DOM 元素或 XHR 请求。Workers 触发事件,这就是我们如何在主线程中从它们那里接收数据的方式。我们也可以向 Workers 发送数据,但这使用的是简单的方法调用。

当我们将数据传递到工作者中时,我们实际上触发了另一个事件;只是这次,它是在工作者的执行上下文中,而不是主页面的执行上下文中。除此之外没有更多的事情:数据输入,数据输出。没有互斥构造或类似的东西。这实际上是一件好事,因为作为平台的网络浏览器已经有很多移动部件。想象一下,如果我们加入一个更复杂的基于事件目标的线程模型,而不是简单的基于事件目标的模型,我们已经有足够的 bug 要修复了。

下面是一个关于 web worker 布局的大致概念,相对于产生这些工作者的主线程:

事件目标

真正的并行化

Web workers 是我们架构中实现并行化原则的手段。众所周知,工作者是操作系统线程,这意味着运行在其内部的 JavaScript 代码可能与主线程中某些 DOM 事件处理器的代码在同一确切时刻运行。能够做这样的事情一直是 JavaScript 程序员的一个目标。在 Web workers 出现之前,真正的并行化根本不可能实现。我们能做到的最好的事情就是假装它,给用户一种许多事情同时发生的印象。

然而,始终在相同的 CPU 核心上运行存在一些问题。我们在给定时间窗口内可以执行的运算量受到根本性的限制。当引入真正的并行化时,这种限制会发生变化,因为可以运行的运算时间窗口会随着每个添加的 CPU 核心而增长。

话虽如此,对于我们的应用程序所做的许多事情,单线程模型工作得很好。今天的机器很强大。我们可以在很短的时间内完成很多事情。问题出现在我们遇到峰值时。这些可能是任何干扰我们代码处理效率的事件。我们的应用程序不断被要求做更多的事情——更多功能、更多数据、更多这样和那样的事情。

我们能够更好地利用我们面前坐着的硬件的简单想法,这就是 Web workers 的全部内容。如果使用得当,Web workers 不必成为我们项目中永远不会使用的那种不可逾越的新事物,因为它包含了一些超出我们舒适区概念。

工作者类型

在开发并发 JavaScript 应用程序的过程中,我们可能会遇到三种类型的 Web workers。在本节中,我们将比较这三种类型,以便我们能够理解在任何特定情况下哪种类型的工作者是有意义的。

专用工作者

专用工作者可能是最常见的工作者类型。它们被认为是 Web workers 的默认类型。当我们的页面创建一个新的工作者时,它仅专注于页面的执行上下文,而不关注其他任何事情。当我们的页面消失时,由页面创建的所有专用工作者也会随之消失。

页面与其创建的任何专用工作者之间的通信路径是直接的。页面向工作者发送消息,工作者随后将消息回传给页面。这些消息的确切编排取决于我们使用 Web 工作者试图解决的问题。在整个书中,我们将深入探讨这些消息模式。

注意

在本书中,主线程和页面是同义的。主线程是你的典型执行上下文,在那里我们可以操作页面并监听输入。Web 工作者上下文在很大程度上是相同的,只是可以访问的组件更少。我们将在稍后讨论这些限制。

这里展示了页面与其专用工作者之间的通信情况:

专用工作者

如我们所见,专用工作者确实是“专用”的。它们只存在于帮助服务创建它们的页面。它们不会直接与其他工作者通信,也不能与任何其他页面通信。

子工作者

子工作者与专用工作者非常相似。主要区别在于它们是由专用工作者创建的,而不是由主线程创建的。例如,如果一个专用工作者有一个可以从并行执行中受益的任务,它可以生成自己的工作者并编排子工作者之间任务的执行。

除了拥有不同的创建者外,子工作者与专用工作者具有相同的特征。子工作者不会直接与主线程中运行的 JavaScript 通信。这是创建子工作者的工作者负责促进他们的通信。以下是如何将子工作者融入整个方案的示意图:

子工作者

共享工作者

第三种类型的 Web 工作者被称为共享工作者。共享工作者之所以被称为共享工作者,是因为多个页面可以共享同一实例的这种类型的工作者。可以访问特定共享工作者实例的页面受同源策略的限制,这意味着如果页面是从与工作者不同的域提供的,那么工作者不允许与该页面通信。

共享工作者解决的是与专用工作者解决的问题不同类型的问题。将专用工作者视为没有副作用的功能。你向它们传递数据,并得到不同的数据作为回报。将共享工作者视为遵循单例模式的程序对象。它们是不同浏览上下文之间共享状态的一种方式。例如,我们不会仅仅为了计算数字而创建共享工作者;我们可以使用专用工作者来完成这项工作。

当内存中有我们希望从同一应用中的任何页面访问的应用数据时,使用共享工作者是有意义的。想想用户在新标签页中打开链接。这创建了一个新的浏览上下文。这也意味着我们的 JavaScript 组件需要经过获取页面所需所有数据的流程,执行所有初始化步骤等等。这既重复又浪费。为什么不在不同的浏览上下文之间共享这些资源呢?以下是同一应用中的多个页面与共享工作者实例通信的示意图:

共享工作者

实际上,还有一种名为 Service Worker 的第四种 Web Worker 类型。这些是增加了与缓存网络资源和离线功能相关额外能力的共享工作者。Service Workers 仍处于其规范的早期阶段,但看起来很有希望。今天我们能从共享工作者中学到的任何东西,如果它们最终成为可行的 Web 技术,都将适用于 Service Workers。

在这里需要考虑的另一个重要因素是 Service Worker 增加的复杂性。主线程与 Service Worker 之间的通信机制涉及使用端口。同样,在共享工作者中运行的代码需要确保它通过正确的端口进行通信。我们将在本章后面更深入地介绍共享工作者通信。

工作者环境

Web Worker 环境与我们的代码通常运行的典型 JavaScript 环境不同。在本节中,我们将指出主线程 JavaScript 环境和 Web Worker 线程之间的关键差异。

有什么可用,有什么不可用?

对于 Web Workers 的一个常见误解是,它们与默认的 JavaScript 执行环境截然不同。确实,它们是不同的,但并没有大到无法接近的程度。也许正因为如此,JavaScript 开发者才会在 Web Workers 可能有益的情况下回避使用它们。

显而易见的一个差距是 DOM——它不存在于 Web Worker 执行环境中。规范编写者的这一决定是有意为之。通过避免将 DOM 集成到工作线程中,浏览器供应商可以避免许多潜在的边缘情况。我们都重视浏览器的稳定性胜过便利性,至少我们应该这样。从 Web Worker 内部访问 DOM 真的会那么方便吗?在本书接下来的几章中,我们将看到工作者擅长许多其他任务,这些任务最终有助于成功实现并发原则。

在我们的 web worker 代码中没有 DOM 访问,我们就不太可能犯错误。这实际上迫使我们真正思考我们最初为什么要使用 workers。我们可能实际上会退一步,重新思考我们的方法。除了 DOM 之外,我们日常使用的几乎所有东西都正好在我们期望的地方。这包括在 web workers 中使用我们最喜欢的库。

注意

要详细了解 web worker 执行环境中缺少的内容,请参阅此页面 developer.mozilla.org/en-US/docs/Web/API/Worker/Functions_and_classes_available_to_workers

加载脚本

我们永远不会将整个应用程序写在一个 JavaScript 文件中。相反,我们将通过将源代码分解成文件来提高模块化,这样我们可以逻辑上分解设计,使其能够在大脑中映射。同样,我们可能也不希望创建由数千行代码组成的 web workers。幸运的是,web workers 自带一种机制,允许我们将代码导入到我们的 web workers 中。

第一个场景是将我们自己的代码导入到 web worker 上下文中。我们可能有很多专门针对我们应用程序的低级实用工具。有很大可能性我们需要在常规脚本上下文和 worker 线程中同时使用这些实用工具。我们希望保持我们的代码模块化,并希望我们的代码在 workers 中的表现与其他任何上下文相同。

第二个场景是在 web workers 中加载第三方库。这与将我们的模块加载到 web workers 中的原理相同——我们的代码将在任何上下文中工作,只有少数例外,如 DOM 代码。让我们看看一个创建 web worker 并加载lodash库的示例。首先,我们将启动 worker:

// Loads the worker script, and starts the
// worker thread.
var worker = new Worker('worker.js');

接下来,我们将使用loadScripts()函数将lodash库引入我们的库中:

// Imports the lodash library, making the global "_"
// variable available in the worker context.
importScripts('lodash.min.js');

// We can use the library within the worker now.
console.log('in worker', _.at([ 1, 2, 3], 0, 2));
// → in worker [1, 3]

我们不需要担心在开始使用脚本之前等待脚本加载——importScripts()是一个阻塞操作。

与 workers 通信

上述示例创建了一个 web worker,它确实在其自己的线程中运行。但是,这对我们来说并不很有帮助,因为我们需要能够与我们创建的 workers 进行通信。在本节中,我们将介绍涉及从 web workers 发送和接收消息的基本机制,包括这些消息是如何序列化的。

发送消息

当我们想要将数据传递给 web worker 时,我们使用postMessage()方法。正如其名所示,此方法将给定消息发送到 worker。如果 worker 中设置了任何消息事件处理器,它们将对此调用做出响应。让我们看看一个基本示例,它将一个字符串发送到 worker:

// Launches the worker thread.
var worker = new Worker('worker.js');

// Posts a message to the worker, triggering
// any "message" event handlers.
worker.postMessage('hello world');

现在让我们看看这个通过为消息事件设置事件处理器来响应此消息的 worker:

// Setup an event listener for any "message"
// events dispatched to this worker.
addEventListener('message', (e) => {

    // The posted data is accessible through
    // the "data" property of the event.
    console.log(e.type, `"${e.data}"`);
    // → message "hello world"
});

注意

addEventListener() 函数在全局专用工作线程上下文中隐式调用。我们可以将其视为 Web Worker 的窗口对象。

消息序列化

从主线程传递到工作线程的消息数据会经过序列化转换。当这个序列化数据到达工作线程时,它会进行反序列化,数据就可以作为 JavaScript 原始类型使用。当工作线程想要将数据发送回主线程时,也存在同样的过程。

不言而喻,这增加了一个额外的步骤,可能会给我们的应用程序增加负担。因此,在线程间传递数据时,我们必须仔细考虑,因为这并不是一个免费的 CPU 成本操作。在这本书的整个 Web Worker 代码示例中,我们将把消息序列化视为我们并发决策过程中的一个关键因素。

所以问题是——为什么要走这么长的路?如果我们使用的 JavaScript 代码中的工作线程仅仅是线程,那么从技术上讲,我们应该能够使用相同的对象,因为这些线程使用相同的内存地址部分。当线程共享资源,如内存中的对象时,很可能会出现资源竞争的场景。例如,如果一个工作线程锁定了一个对象,而另一个工作线程试图使用它,那么这就是一个错误。我们必须实现逻辑,优雅地等待对象变得可用,并且我们必须在工作线程中实现释放锁定资源的逻辑。

简而言之,这是一个容易出错且令人头疼的问题,我们最好避免它。幸运的是,线程之间没有共享资源——只有序列化的消息。这意味着我们在将哪些类型的数据传递给工作线程方面受到限制。一般来说,传递可以编码为 JSON 字符串的东西是安全的。记住,工作线程必须从这个序列化字符串中重建对象,所以函数或类实例的字符串表示形式将不起作用。让我们通过一个例子来看看这是如何工作的。首先,一个简单的用于记录接收到的消息的工作线程:

// Simply display the content of any
// messages received.
addEventListener('message', (e) => {
    console.log('message', e.data);
});

现在我们来看看我们可以使用 postMessage() 将什么类型的数据序列化并发送到这个工作线程:

// Launches the worker.
var worker = new Worker('worker.js');

// Sends a plain object.
worker.postMessage({ hello: 'world' });
// → message { hello: "world" }

// Sends an array.
worker.postMessage([ 1, 2, 3 ]);
// → message [ 1, 2, 3 ]

// Tries to send a function, results in
// an error being thrown.
worker.postMessage(setTimeout);
// → Uncaught DataCloneError

如我们所见,当我们尝试将一个函数传递给 postMessage() 时,会出现一点小问题。这种类型的数据一旦到达工作线程,就无法重建,因此 postMessage() 简单地抛出一个异常。这类限制可能看起来过于严格,但它们确实消除了许多并发问题的可能性。

从工作线程接收消息

没有将数据传回主线程的能力,工作者对我们来说并不那么有用。在某个时候,工作者执行的工作需要在 UI 中反映出来。我们可能会记得工作者实例是事件目标。这意味着我们可以监听消息事件,并在工作者发送回数据时相应地做出反应。把这看作是向工作者发送数据的逆过程。工作者只是通过向其发送消息将主线程视为另一个工作者,而主线程则监听消息。我们在前一节中探讨的相同的序列化限制在这里也是相关的。

让我们看看一些将消息发送回主线程的工作者代码:

// After 2 seconds, post some data back to
// the main thread using the "postMessage()"
// function.
setTimeout(() => {
    postMessage('hello world');
}, 2000);

正如我们所见,这个工作者启动后,2 秒后向主线程发送一个字符串。现在,让我们看看我们如何在主页面 JavaScript 中处理这些传入的消息:

// Launches the new worker.
var worker = new Worker('worker.js');

// Adds an event listener for the "message"
// event. Notice that the "data" property
// contains the actual message payload, the
// same way messages sent to workers do.
worker.addEventListener('message', (e) => {
    console.log('from worker', `"${e.data}"`);
});

注意

你可能已经注意到我们没有明确终止任何我们的工作者线程。这是可以的。当浏览上下文被终止时,所有活跃的工作者线程都会随之终止。我们可以使用terminate()方法显式地终止工作者,这将显式地停止线程,而无需等待现有代码完成。然而,显式终止工作者是罕见的。一旦创建,工作者通常会在页面的整个生命周期中存活。启动工作者不是免费的,它会产生开销,因此我们应该尽可能只做一次。

共享应用程序状态

在本节中,我们将介绍共享工作者。首先,我们将探讨内存中相同的数据对象如何被多个浏览上下文访问。然后,我们将探讨获取远程资源,以及如何通知多个浏览上下文关于新数据到达的消息。最后,我们将探讨如何利用共享工作者来实现浏览器上下文之间的直接消息传递。

注意

请将本节视为实验性编码的高级材料。目前浏览器对共享工作者的支持并不太好(只有 Firefox 和 Chrome)。Web 工作者在 W3C 仍处于候选推荐阶段。一旦它们成为推荐,并且对共享工作者的浏览器支持更好,我们就可以开始使用它们了。为了额外的动力,随着服务工作者规范的成熟,共享工作者的熟练度将变得更加相关。

共享内存

我们在 Web 工作者中看到的序列化机制存在,因为我们不能直接从多个线程引用相同的对象。然而,共享工作者有一个不受单个页面限制的内存空间,这意味着我们可以通过各种消息传递方法间接访问这些内存中的对象。实际上,这是一个展示我们如何使用端口传递消息的好机会。让我们开始吧。

在共享工作者的场景中,端口的观念是必要的。没有它们,就没有控制共享工作者消息流入和流出的管理机制。例如,假设我们有三个页面使用相同的工作者,那么我们就需要创建三个端口来与这个工作者进行通信。将端口想象成从外部世界进入工作者的一个网关。这是一个小的间接操作。

下面是一个基本的共享工作者示例,让我们了解设置这类工作者涉及的内容:

// This is the shared state between the pages that
// connect to this worker.
var connections = 0;

// Listen for pages that connect to this worker, so
// we can setup the message ports.
addEventListener('connect', (e) => {

    // The "source" property represents the
    // message port created by the page that's
    // connecting to this worker. We have to call
    // "start()" to actually establish the connection.
    e.source.start();

    // We post a message back to the page, the payload
    // is the updated number of connections.
    e.source.postMessage(++connections);
});

有一个connect事件,一旦页面与这个工作者连接就会被触发。connect事件有一个source属性,这是一个消息端口。我们必须通过调用start()来告诉工作者它准备好与之通信。注意,我们必须在端口上调用postMessage(),而不是在全局上下文中。否则,工作者如何知道要将消息发送到哪个页面?端口在工作者和页面之间充当代理,如下面的图示所示:

共享内存

现在,让我们看看如何从多个页面使用这个共享工作者:

// Launches the shared worker.
var worker = new SharedWorker('worker.js');

// Sets up our "message" event handler. By connecting
// to the shared worker, we're actually causing a
// a message to be posted to our messaging port.
worker.port.addEventListener('message', (e) => {
    console.log('connections made', e.data);
});

// Starts the messaging port, indicating that we're
// ready to start sending and receiving messages.
worker.port.start();

与专用工作者相比,这种共享工作者只有两个主要区别。具体如下:

  • 我们有一个port对象,我们可以通过发布消息和附加事件监听器来与工作者进行通信。

  • 我们通过在端口上调用start()方法来告诉工作者我们已准备好开始通信,就像工作者做的那样。将这些start()调用想象成共享工作者和其新客户端之间的握手。

资源获取

之前的例子让我们尝到了来自同一应用程序的不同页面如何共享数据,避免了每次页面加载时都需要分配完全相同的结构。让我们在此基础上构建,并使用共享工作者来获取远程资源,以便与任何依赖它的页面共享结果。以下是工作者代码:

// Where we store the ports of connected
// pages so we can broadcast messages.
var ports = [];

// Fetches a resource from the API.
function fetch() {
    var request = new XMLHttpRequest();

    // When the response arrives, we only have
    // to parse the JSON string once, and then
    // broadcast it to any ports.
    request.addEventListener('load', (e) => {
        var resp = JSON.parse(e.target.responseText);

        for (let port of ports) {
            port.postMessage(resp);
        }
    });

    request.open('get', 'api.json');
    request.send();
}

// When a page connects to this worker, we push the
// port to the "ports" array so the worker can keep
// track of it.
addEventListener('connect', (e) => {
    ports.push(e.source);
    e.source.start();
});

// Now we can "poll" the API, and broadcast the result
// to any pages.
setInterval(fetch, 1000);

当页面连接到工作者时,我们不是响应端口,而是简单地将其引用存储在ports数组中。这就是我们跟踪连接到工作者的页面的方式,这在当前情况下很重要,因为并非所有消息都遵循命令-响应模式。在这种情况下,我们希望向任何可能监听它的页面广播更新的 API 资源。一个常见的情况是一个页面,但在有多个浏览器标签页打开查看同一应用程序的情况下,我们可以使用相同的数据。

例如,如果 API 资源是一个需要解析的大型 JSON 数组,如果需要由三个不同的浏览器标签页解析相同的数据,这将变得非常低效。另一个节省之处在于我们不是每秒对 API 进行三次轮询,如果每个页面都运行自己的轮询代码,就会是这样。在共享工作者上下文中,它只发生一次,并将数据分发到连接的页面。这也减轻了后端的负担,因为在总体上,请求的数量要少得多。现在让我们看看使用此工作者的代码:

// Launch the worker.
var worker = new SharedWorker('worker.js');

// Listen to the "message" event, and log
// any data that's sent back from the worker.
worker.port.addEventListener('message', (e) => {
    console.log('from worker', e.data);
});

// Inform the shared worker that we're ready
// to start receiving messages.
worker.port.start();

页面间的通信

到目前为止,我们将共享工作者中的数据视为中央资源。也就是说,它来自一个集中位置,例如 API,然后由连接到工作者的页面读取。我们还没有直接从页面直接修改任何数据。例如,假设我们甚至没有连接到后端,并且一个页面正在共享工作者中操作一个数据结构。其他页面随后需要了解这些更改。

但是,假设用户切换到这些页面之一并做一些调整。我们必须支持双向更新。让我们看看我们将如何使用共享工作者来实现这些功能:

// Stores the ports of any connected pages.
var ports = [];

addEventListener('connect', (e) => {

    // The received message data is distributed to any
    // pages connected to this worker. The page code
    // decides how to handle the data.
    e.source.addEventListener('message', (e) => {
        for (let port of ports) {
            port.postMessage(e.data);
        }
    });

    // Store the port reference for the connected page,
    // and start communications using the "start()"
    // method.
    ports.push(e.source);
    e.source.start();
});

这个工作者不过是一个卫星;它只是将接收到的任何内容传输到所有连接的端口。这就是我们所需要的,为什么还要添加更多呢?让我们看看连接到这个工作者的页面代码:

// Launch the shared worker, and store a reference
// to the main UI element we're working with.
var worker = new SharedWorker('worker.js');
var input = document.querySelector('input');

// Whenever the input value changes, post the input
// value to the worker for other pages to consume.
input.addEventListener('input', (e) => {
    worker.port.postMessage(e.target.value);
});

// When we receive input data, update the value of our
// text input. That is, unless the value is already
// updated.
worker.port.addEventListener('message', (e) => {
    if (e.data !== input.value) {
        input.value = e.data;
    }
});

// Starts worker communications.
worker.port.start();

真是令人兴奋!现在,如果我们打开两个或更多带有此页面的浏览器标签页,我们对输入值所做的任何更改都会立即反映在其他页面上。这个设计的巧妙之处在于它的工作方式相同;无论哪个页面正在执行更新,任何其他页面都会接收到更新后的数据。换句话说,页面承担着数据生产者和数据消费者的双重角色。

注意

你可能已经注意到,在这个最后的例子中,工作者向所有端口发送消息,包括发送消息的端口。我们可能不想这样做。为了避免向发送者发送消息,我们需要在for..of循环中排除发送端口。

这实际上并不容易做,因为message事件中没有发送任何端口标识信息。我们可以建立端口标识,并让消息包含 ID。这里有很多工作要做,而且好处并不大。这里的并发设计权衡是在页面代码中简单地检查消息是否确实与页面相关。

使用子工作者执行子任务

在本章中我们创建的所有工作者——专用工作者和共享工作者——都是由主线程启动的。在本节中,我们将讨论子工作者的概念。它们与专用工作者类似,只是创建者不同。例如,子工作者不能直接与主线程交互,只能通过创建子工作者的线程进行代理。

我们将探讨如何将较大的任务分解成较小的任务,并还将探讨围绕子工作者的一些挑战。

将工作分解为任务

我们的网络工作者(web workers)的职责是以一种方式执行任务,使得主线程可以继续服务其他事物,例如 DOM 事件,而不会被打断。有些任务对于网络工作者线程来说处理起来很简单。它们接收输入,计算结果,并将结果作为输出返回。但是,如果任务更大呢?如果它涉及多个较小的离散步骤,允许我们将较大的任务分解成较小的任务呢?

对于这样的任务,将其分解成更小的子任务是有意义的,这样我们就可以进一步利用所有可用的 CPU 核心。然而,将任务分解成更小的任务本身可能会带来沉重的性能惩罚。如果分解留在主线程中,我们的用户体验可能会受到影响。我们将会利用的一种技术是启动一个网络工作者,其任务是分解任务成更小的步骤,并为每个步骤启动一个子工作者。

让我们创建一个工作者,它在数组中搜索特定项,如果该项存在则返回 true。如果输入数组很大,我们会将其分成几个较小的数组,每个数组都并行搜索。这些并行搜索任务将被创建为子工作者。首先,我们将看看子工作者:

// Listens for incoming messages.
addEventListener('message', (e) => {

    // Posts a result back to the worker.
    // We call "indexOf()" on the input
    // array, looking for the "search" data.
    postMessage({
        result: e.data.array
            .indexOf(e.data.search) > -1
    });
});

因此,我们现在有一个子工作者,它可以处理数组的一部分并返回结果。这很简单。现在到了棘手的部分,让我们实现一个将输入数组分解成更小输入的工作者,这些输入随后被喂给子工作者。

addEventListener('message', (e) => {

    // The array that we're going to divide into
    // 4 smaller chunks.
    var array = e.data.array;

    // Computes the size, roughly, of a quarter
    // of the array - this is our chunk size.
    var size = Math.floor(0.25 * array.length);

    // The search data we're looking for.
    var search = e.data.search;

    // Used to divide the array into chunks in
    // the "while" loop below.
    var index = 0;

    // Where our chunks go, once they've been sliced.
    var chunks = [];

    // We need to store references to our sub-workers,
    // so we can terminate them.
    var workers = [];

    // This is for counting the number of results
    // returned from sub-workers.
    var results = 0;

    // Splits the array into proportionally-sized chunks.
    while (index < array.length) {
    chunks.push(array.slice(index, index + size));
        index += size;
    }

    // If there's anything left over (a 5th chunk),
    // throw it into the chunk before it.
    if (chunks.length > 4) {
        chunks[3] = chunks[3].concat(chunks[4]);
        chunks = chunks.slice(0, 4);
    }

    for (let chunk of chunks) {

        // Launches our sub-worker and stores a
        // reference to it in "workers".
        let worker = new Worker('sub-worker.js');
        workers.push(worker);

        // The sub-worker has a result.
        worker.addEventListener('message', (e) => {
            results++;

            // The the result is "truthy", we can post
            // a response back to the main thread.
            // Otherwise, we check if all the
            // responses are back yet. If so, we can
            // post a false value back. Either way, we
            // terminate all sub-workers.
            if (e.data.result) {
                postMessage({
                    search: search,
                    result: true
                });

                workers.forEach(x => x.terminate());
            } else if (results === 4) {
                postMessage({
                    search: search,
                    result: false
                });

                workers.forEach(x => x.terminate());
            }
        });

        // Give the worker a chunk of array to search.
        worker.postMessage({
            array: chunk,
            search: search
        });
    }
});

这种方法的优点是,一旦我们得到一个肯定的结果,我们就可以终止所有现有的子工作者。所以,如果我们处理一个特别大的数据集,我们可以避免在后台让一个或多个子工作者无谓地运行。

我们采取的方法是将输入数组切成四个比例(25%)的块。这样,我们限制并发级别为四。在下一章中,我们将进一步讨论细分任务和确定使用并发级别的策略。

现在,让我们通过编写一些代码来完成我们的示例,以便在页面上使用这个工作者:

// Launches the worker...
var worker = new Worker('worker.js');

// Generates some input data, an array
// of numbers for 0 - 1041.
var input = new Array(1041)
    .fill(true).map((v, i) => i);

// When the worker responds, display the
// results of our search. 
worker.addEventListener('message', (e) => {
    console.log(`${e.data.search} exists?`, e.data.result);
});

// Search for an item that exists.
worker.postMessage({
    array: input,
    search: 449
});
// → 449 exists? true

// Search for an item that doesn't exist.
worker.postMessage({
    array: input,
    search: 1045
});
// → 1045 exists? false

我们能够与工作者通信,传递给它一个输入数组和要搜索的数据。结果被传递到主线程,并且包括搜索词,这样我们就能将输出与发送给工作者的原始消息进行匹配。然而,这里有一些重大的障碍需要克服。虽然这确实很有用,能够细分任务以更好地利用多核 CPU,但其中涉及了很多复杂性。一旦我们得到了每个子工作者的结果,我们就必须处理协调问题。

如果这个简单的例子可以变得像现在这样复杂,那么想象一下在大型应用程序上下文中类似的代码。我们可以从两个角度来处理这些并发问题。第一个是关于并发的初步设计挑战。这些将在下一章中解决。然后,还有同步挑战——我们如何避免回调地狱?这个主题在第七章抽象并发中进行了深入探讨。

警告

虽然前面的例子是一个强大的并发技术,可以提供巨大的性能提升,但也有一些需要注意的缺点。所以在深入涉及子工作者实现的实现之前,考虑一下这些挑战和你必须做出的权衡。

子工作者没有直接与其通信的父页面。这使设计变得复杂,因为即使是子工作者的一个简单响应也需要通过一个由主线程中运行的 JavaScript 直接创建的工作者代理。这导致了一系列复杂的通信路径。换句话说,通过添加比实际需要的更多移动部件,很容易使设计复杂化。因此,在决定将子工作者作为设计选项之前,让我们首先排除一个可以依赖于专用工作者的方法。

第二个问题是,由于 Web 工作者仍然是一个候选的 W3C 推荐,并非所有浏览器都一致地实现了 Web 工作者的某些方面。共享工作者和子工作者是我们可能会遇到跨浏览器问题的两个领域。另一方面,专用工作者得到了大多数浏览器的良好支持,并且在大多数供应商之间表现一致。再次强调,从一个简单的专用工作者设计开始,如果不起作用,再考虑引入共享工作者和子工作者。

Web 工作者中的错误处理

本章中所有代码都做了一个天真假设,即运行在我们工作者中的代码是无错误的。显然,我们的工作者会遇到抛出异常的情况,或者我们在开发过程中可能会编写出有错误的代码——这是我们作为程序员面临的现实。然而,如果没有适当的错误事件处理器,调试 Web 工作者可能会变得困难。我们可以采取的另一方法是明确发送一个消息,表明它处于错误状态。在本节中,我们将介绍这两个错误处理主题。

错误条件检查

假设我们的主应用程序代码向工作者线程发送一个消息,并期望得到一些数据作为回应。如果出了问题,需要让期待数据的代码知道这一点怎么办?一个可能性是仍然发送主线程期望的消息;只是它有一个字段表示操作的错误状态。以下插图为我们提供了这个样子的一些想法:

错误条件检查

现在让我们看看一些实现这种方法的代码。首先,确定要返回的消息状态的工人,要么返回成功状态,要么返回错误状态:

// When a message arrives, check if the provided
// data is an array. If not, post a response
// with the "error" property set. Otherwise,
// compute and respond with the result.
addEventListener('message', (e) => {
    if (!Array.isArray(e.data)) {
        postMessage({
            error: 'expecting an array'
        });
    } else {
        postMessage({
            result: e.data[0]
        });
    }
});

这个工人总是会通过发送消息来响应,但它并不总是计算结果。首先,它检查输入值是否可接受。如果它没有得到期望的数组,它就会发送一个设置错误状态的消息。否则,它会像正常一样发送结果。现在,让我们编写一些代码来使用这个工人:

// Launches the worker.
var worker = new Worker('worker.js');

// Listens for messages coming from the worker.
// If we get back an error, we log the error
// message. Otherwise, we log the successful result.
worker.addEventListener('message', (e) => {
    if (e.data.error) {
        console.error(e.data.error);
    } else {
        console.log('result', e.data.result);
    }
});

worker.postMessage([ 3, 2, 1 ]);
// → result 3

worker.postMessage({});
// → expecting an array

异常处理

即使我们在工人中明确检查错误条件,就像我们在上一个例子中所做的那样,仍然有可能抛出异常。从我们的主应用程序线程的角度来看,我们需要处理这些类型的未捕获错误。如果没有适当的错误处理机制,我们的 Web Workers 将默默地失败。有时,似乎工人甚至没有加载——处理这种无线电静默是一个噩梦,调试起来非常困难。

让我们看看一个监听 Web Worker 的error事件的例子。这里有一个尝试访问不存在属性的 Web Worker:

// When a message arrays, post a response
// that contains the "name" property of
// the input data. The what if data isn't
// defined?
addEventListener('message', (e) => {
    postMessage(e.data.name);
});

这里没有错误处理代码。我们只是在读取name属性并将其发送回去时响应消息。让我们看看一些使用这个工人并展示它如何响应在这个工人中引发的异常的代码:

// Launches our worker.
var worker = new Worker('worker.js');

// Listen to messages sent back from the worker,
// and log the result.
worker.addEventListener('message', (e) => {
    console.log('result', `"${e.data}"`);
});

// Listen to errors sent back from the worker,
// and log the error message.
worker.addEventListener('error', (e) => {
    console.error(e.message);
});

worker.postMessage(null);
// → Uncaught TypeError: Cannot read property 'name' of null

worker.postMessage({ name: 'JavaScript' });
// → result "JavaScript"

在这里,我们可以看到第一个发送给工人的消息导致工人在内部抛出异常。然而,这个异常被封装在工人内部——它并没有在我们的主线程中抛出。由于我们在主线程中监听error事件,我们可以相应地做出反应。在这种情况下,我们只是记录错误信息。然而,在其他情况下,我们可能需要采取更复杂的纠正措施,例如释放资源或向工人发送不同的消息。

摘要

在本章中,我们介绍了使用 Web Workers 进行并行执行的概念。在 Web Workers 出现之前,我们的 JavaScript 无法利用大多数硬件上找到的多个 CPU 核心。

我们从对 Web Workers 的概述开始。它们是基于操作系统的线程。从 JavaScript 的角度来看,它们是事件目标,我们可以向它们发送消息并监听message事件。工人有三种类型——专用、共享和子工人。

您学习了如何通过发送消息和监听事件与 Web Workers 进行通信。您了解到在消息中可以传递的内容存在限制。这是由于所有消息数据都在目标线程中进行序列化和重建。

我们在查看如何在 Web Workers 中处理错误和异常的章节中结束了这一章。在下一章中,我们将讨论并行化的实际方面——我们应该并行执行哪些类型的工作,以及如何最好地实现它。

第六章:实践并行性

在上一章中,我们介绍了 web workers 的基本功能。我们使用 web workers 在浏览器中实现真正的并行性,因为它们映射到真实的线程,而这些线程又映射到单独的 CPU。本章在此基础上,提供了一些设计并行代码的动机。

我们将首先简要地探讨一些从函数式编程中借鉴的思想,以及它们如何很好地适用于并发问题。然后,我们将通过决定并行计算或简单地在一个 CPU 上运行来解决并行有效性的问题。接着,我们将深入探讨一些可以从并行运行任务中受益的并发问题。我们还将讨论使用工作者线程保持 DOM 响应性的问题。

函数式编程

函数显然是函数式编程的核心。但同样重要的是流经我们应用程序的数据。实际上,程序中的数据和其流动可能和函数本身的实现一样重要,至少从应用程序设计的角度来看。

函数式编程与并发编程之间有着强烈的亲和力。在本节中,我们将探讨这是为什么,以及我们如何应用函数式编程技术,以产生更强的并发代码。

数据输入,数据输出

函数式编程与其他编程范式一样强大。它是以不同的方式解决相同问题的一种方法。我们使用不同的工具集。例如,函数是构建模块,我们将使用它们来构建围绕数据转换的抽象。另一方面,命令式编程使用诸如类等构造来构建抽象。根本的区别在于类和对象喜欢封装某物的状态,而函数是数据输入,数据输出。

例如,假设我们有一个具有enabled属性的user对象。这个想法是enabled属性在任何给定时间都有一个值,这个值可以随时改变。换句话说,用户改变了状态。如果我们把这个对象传递到我们应用程序的不同区域,那么状态也会随之传递。它被封装为一个属性。任何一个最终获得用户对象引用的组件都可以改变它,然后将其传递到另一个地方。如此等等。以下是一个说明函数如何在一个组件传递之前改变用户状态的图示:

数据输入,数据输出

在函数式编程中并不像这样。状态不是封装在对象内部并在组件之间传递;这并不是因为这样做本质上不好,而只是解决问题的一种不同方式。在面向对象编程中,状态封装是一个目标,而从 A 点到 B 点的转换以及在这个过程中对数据的转换正是函数式编程的全部内容。没有 C 点——一旦函数完成了它的任务,它就不关心数据的状况。下面是前面图表的函数式替代方案:

数据输入,数据输出

正如我们所见,函数式方法创建了一个具有更新属性值的新对象。函数以数据作为输入,并以新数据作为输出。换句话说,它不修改输入。这是一个简单但具有重大影响的概念,如不可变性。

不可变性

不可变数据是函数式编程的一个关键概念,并且非常适合并发编程。JavaScript 是一种多范式语言。也就是说,它是函数式的,但也可以是命令式的。一些函数式编程语言严格强制不可变性——你根本不能改变对象的状态。实际上,选择何时保持数据不可变以及何时不这样做是有益的。

在上一节的最后一个图表中,展示了enable()函数实际上返回了一个具有不同属性值的全新对象。这样做是为了避免修改输入值。尽管这看起来可能有些浪费——不断地创建对象,但实际上并不是这样。考虑一下,当对象从不改变时,我们不必编写所有这些记账代码。

例如,如果用户的enabled属性是可变的,那么这意味着任何使用这个对象的组件都需要不断检查enabled属性。下面是这个样子的一种想法:

不可变性

当组件想要显示用户时,需要发生这种检查。实际上,我们需要使用函数式方法执行相同的检查。然而,使用函数式方法唯一有效的起点是创建路径。如果我们的系统中的其他东西可以改变enabled属性,那么我们既要担心创建路径,也要担心修改路径。消除修改路径也消除了许多其他复杂性。这些被称为副作用。

副作用和并发性并不相容。事实上,一个对象可以改变的想法本身就让并发变得困难。例如,假设我们有两个线程想要访问我们的用户对象。他们首先需要获取对它的访问权限,它可能已经被锁定。下面是这个想法的视觉表示:

不可变性

在这里,我们可以看到第一个线程锁定用户对象,阻止其他线程访问它。第二个线程需要等待它被解锁,然后才能继续。这被称为资源竞争,它削弱了利用多个 CPU 的整个目的。如果线程正在等待访问某种资源,它们实际上并没有真正并行运行。不可变性绕过了资源竞争问题,因为不需要锁定不改变的资源。以下是使用两个线程的函数式方法的样子:

不可变性

当对象不改变状态时,任何数量的线程都可以并发访问它们,而不会因为操作顺序错误而破坏对象状态,也不会浪费宝贵的 CPU 时间等待资源。

引用透明性和时间

以不可变数据作为输入的函数有一种称为引用透明性的特性。这意味着给定相同的对象作为输入,无论它被调用多少次,函数总是会返回相同的结果。这是一个有用的特性,因为它意味着时间因素被从图中移除。也就是说,唯一可能改变函数输出结果的因素是它的输入——而不是它相对于其他函数被调用的相对时间。

换句话说,引用透明性函数不产生副作用,因为它们与不可变数据一起工作。正因为如此,由于函数输出不受时间因素的影响,它们非常适合并发环境。让我们看看一个不是引用透明的函数:

// Returns the "name" of the given user object,
// but only if it's "enabled". This means that
// the function is referentially-transparent if
// the user passed to it never update the
// "enabled" property.
function getName(user) {
    if (user.enabled) {
        return user.name;
    }
}

// Toggles the value of the passed-in "user.enabled"
// property. Functions like these that change the
// state of objects make referential transparency
// difficult to achieve.
function updateUser(user) {
    user.enabled = !user.enabled;
}

// Our user object.
var user = {
    name: 'ES6',
    enabled: false
};

console.log('name when disabled', `"${getName(user)}"`);
// → name when disabled "undefined"

// Mutates the user state. Now passing this object
// to functions means that they're no longer
// referentially-transparent, because they could
// produce different output based on this update.
updateUser(user);

console.log('name when enabled', `"${getName(user)}"`);
// → name when enabled "ES6"

getName() 函数的工作方式取决于传递给它的 user 对象的状态。如果用户对象是启用的,我们返回名称。否则,我们不返回任何内容。这意味着如果函数传递可变数据结构,它就不是引用透明的,这在先前的例子中就是这种情况。enabled 属性会改变,函数的结果也会改变。让我们修复这种情况,并使用以下代码使其具有引用透明性:

// The referentially-transparent version of "updateUser()",
// which doesn't actually update anything. It creates a
// new object with all the same property values as the
// object that was passed in, except for the "enabled"
// property value we're changing.
function updateUserRT(user) {
    return Object.assign({}, user, {
        enabled: !user.enabled
    });
}

// This approach doesn't change anything about "user",
// meaning that any functions that use "user" as input,
// remain referentially-transparent.
var updatedUser = updateUserRT(user);

// We can call referentially-transparent functions at
// any time, and expect to get the same result. When
// there's no side-effects on our data, concurrency gets
// much easier.
setTimeout(() => {
    console.log('still enabled', `"${getName(user)}"`);
    // → still enabled "ES6"
}, 1000);

console.log('updated user', `"${getName(updatedUser)}"`);
// → updated user "undefined"

如我们所见,updateUserRT() 函数实际上并没有改变数据。它创建了一个包含更新属性值的副本。这意味着我们可以安全地随时使用原始用户对象作为输入调用 updateUser()

这种函数式编程技术帮助我们编写并发代码,因为执行操作顺序不是一个因素。对异步操作进行排序是困难的。不可变数据导致引用透明性,这导致更强的并发语义。

我们需要并行化吗?

对于正确类型的问题,并行化可以给我们带来巨大的好处。创建工作者并同步他们之间的通信以执行任务并不是免费的。例如,我们可能有这样一段精心设计的并行代码,它利用了四个 CPU 核心。但结果是,执行促进这种并行性的样板代码所花费的时间超过了在单个线程中处理数据的成本。

在本节中,我们将讨论与验证我们正在处理的数据以及确定系统硬件能力相关的问题。我们总是希望对于并行执行根本不合理的场景有一个同步回退选项。当我们决定并行处理时,我们的下一个任务是弄清楚工作是如何分配给工作者的。所有这些检查都是在运行时进行的。

数据有多大?

有时候,并行处理并不值得。并行处理的想法是在更短的时间内完成更多的计算。这使我们能够更快地得到结果,最终导致更响应的用户体验。尽管如此,有些情况下,我们处理的数据根本无法证明使用线程的合理性。甚至一些大型数据集也可能无法从并行化中获得好处。

决定给定操作是否适合并行执行的两个因素是数据的大小和我们对集合中每个项目执行的操作的时间复杂度。换句话说,如果我们有一个包含数千个对象的数组,但每个对象上的计算成本很低,那么实际上没有理由去并行处理。同样,我们可能有一个包含非常少对象的数组,但操作成本很高。再次,我们可能不会从将工作细分到更小的任务并将它们分配给工作线程中受益。

静态因素是我们对单个项目进行的计算。在设计时,我们必须对代码在 CPU 周期方面的成本有一个大致的了解。这可能需要一些静态分析,一些快速基准测试,或者只是结合经验和直觉的快速浏览。当我们制定判断给定操作是否适合并行执行的标准时,我们需要将计算本身与数据的大小结合起来。

让我们看看一个例子,它使用不同的性能特征来确定是否应该并行执行给定的函数:

// This function determines whether or not an
// operation should be performed in parallel.
// It takes as arguments - the data to process,
// and a boolean flag, indicating that the task
// performed on each item in the data is expensive
// or not.
function isConcurrent(data, expensiveTask) {
    var size,
        isSet = data instanceof Set,
        isMap = data instanceof Map;

    // Figures out the size of the data, depending
    // on the type of "data".
    if (Array.isArray(data)) {
        size = data.length
    } else if (isSet || isMap) {
        size = data.size;
    } else {        
        size = Object.keys(data).length;
    }

    // Determine whether or not the size of the
    // data surpasses a the parallel processing
    // threshold. The threshold depends on the
    // "expensiveTask" value.
    return size >= (expensiveTask ? 100 : 1000);
}

var data = new Array(138);

console.log('array with expensive task',
    isConcurrent(data, true));
// → array with expensive task true

console.log('array with inexpensive task',
    isConcurrent(data, false));
// → array with inexpensive task false

data = new Set(new Array(100000)
    .fill(null)
    .map((x, i) => i));

console.log('huge set with inexpensive task',
    isConcurrent(data, false));
// → huge set with inexpensive task true

这个函数很有用,因为它为我们提供了一个简单的预飞检查——要么是并行,要么不是。如果不是,那么我们可以走捷径,简单地计算结果并将其返回给调用者。如果是并行,那么我们将进入下一个阶段,即弄清楚如何将操作细分成更小的任务。

isParallel() 函数不仅考虑了数据的大小,还考虑了在数据项上执行计算的成本。这使得我们可以微调我们应用程序的并发性。如果开销太大,我们可以提高并行处理的阈值。如果我们对代码进行了一些更改,使得之前成本较低的功能变得昂贵。在这种情况下,我们只需更改 expensiveTask 标志。

注意

当我们的代码在主线程中运行的频率与在工作线程中运行的频率相同会发生什么?这难道意味着我们必须为我们的任务代码编写两次:一次用于顺序代码,一次用于我们的工作线程?显然,我们希望避免这种情况,因此我们需要使我们的任务代码模块化。它需要在主线程和工作线程中都能使用。

硬件并发能力

在我们的并发应用程序中,我们还将执行另一个高级别的检查,即我们运行在硬件上的并发能力。这告诉我们应该创建多少个 Web 工作线程。例如,在一个只有四个 CPU 核心的系统上创建 32 个 Web 工作线程对我们来说实际上没有任何好处。在这种情况下,四个 Web 工作线程会更合适。那么,我们如何得到这个数字呢?

让我们创建一个通用的函数来为我们解决这个问题:

// Returns the the ideal number of web workers
// to create.
function getConcurrency(defaultLevel = 4) {

    // If the "navigator.hardwareConcurrency" property
    // exists, we use that. Otherwise, we return the
    // "defaultLevel" value, which is a sane guess
    // at the actual hardware concurrency level.
    return Number.isInteger(navigator.hardwareConcurrency) ?
        navigator.hardwareConcurrency : defaultLevel;
}

console.log('concurrency level', getConcurrency());
// → concurrency level 8

由于并非所有浏览器都实现了 navigator.hardwareConcurrency 属性,我们必须考虑这一点。如果我们不知道确切的硬件并发级别,我们必须做出猜测。在这里,我们说四个是我们最有可能遇到的常见 CPU 核心数。由于这是一个默认参数值,它既用于调用者的特殊情况处理,也用于简单的全局更改。

注意

有其他技术试图通过生成工作线程并采样数据返回的速率来测量并发级别。这是一个有趣的技术,但由于涉及的开销和一般的不确定性,它不适合生产应用程序。换句话说,使用一个静态值,该值涵盖了大多数用户的系统,就足够了。

创建任务和分配工作

一旦我们决定一个给定的操作应该并行执行,并且我们知道根据并发级别应该创建多少个工作线程,那么就是创建一些任务并将它们分配给工作线程的时候了。本质上,这意味着将输入数据切割成更小的块,并将这些块传递给将任务应用于数据子集的工作线程。

在前一章节中,我们看到了第一个将输入数据分割成任务的例子。一旦工作被分割,我们就创建一个新的工作线程,并在任务完成后终止它。根据我们构建的应用程序类型,创建和终止线程可能不是最佳方法。例如,如果我们偶尔运行一个可以受益于并行处理的高成本操作,那么按需创建工作线程可能是有意义的。然而,如果我们经常并行处理事物,那么在应用程序启动时创建线程,并重复使用它们来处理多种类型的任务可能更有意义。以下是许多操作如何共享同一组工作线程以执行不同任务的示意图:

创建任务和分配工作

这种配置允许操作向已运行的工作线程发送消息,并在完成后获取结果。当我们完成操作时,与创建新工作线程和清理它们相关的开销不存在。仍然存在协调的问题。我们已经将操作分割成更小的任务,每个任务都返回自己的结果。然而,操作预期返回单个结果。因此,当我们将工作分割成更小的任务时,我们还需要一种方法将任务结果合并成一个整体。

让我们编写一个通用函数,该函数处理将工作分割成任务并将结果汇总以进行协调的样板代码。在此过程中,让我们也让这个函数确定操作是否应该并行化,或者它应该在主线程中同步运行。首先,让我们看看我们将在并行运行的数据块上运行的每个任务本身:

// Simple function that returns the sum
// of the provided arguments.
function sum(...numbers) {
    return numbers
        .reduce((result, item) => result + item);
}

这个任务与我们的工作线程代码以及运行在主线程中的应用程序的其他部分是分开的。原因是我们将希望在这两个地方使用这个函数:主线程和工作线程。现在,我们将创建一个可以导入这个函数的工作线程,并使用它来处理传递给工作线程的消息中的任何数据:

// Loads the generic task that's executed by
// this worker.
importScripts('task.js') if (chunk.length) {;

addEventListener('message', (e) => {

    // If we get a message for a "sum" task,
    // then we call our "sum()" task, and post
    // the result, along with the operation ID.
    if (e.data.task === 'sum') {
        postMessage({
            id: e.data.id,
            value: sum(...e.data.chunk)
        });
    }
});

在本章的早期部分,我们实现了两个实用函数。isConcurrent() 函数用于确定将操作作为一组并行运行的小任务执行时的效用。另一个函数 getConcurrency() 用于确定我们应该运行在何种并发级别。在这里,我们将使用这两个函数,并介绍两个新的实用函数。实际上,这些是生成器,将帮助我们后续的工作。让我们看一下这个:

// This generator creates a set of workers that match
// the concurrency level of the system. Then, as the
// caller iterates over the generator, the next worker
// is yielded, until the end is reached, then we start
// again from the beginning. It's like a round-robin
// for selecting workers to send messages to.
function* genWorkers() {
    var concurrency = getConcurrency();
    var workers = new Array(concurrency);
    var index = 0;

    // Creates the workers, storing each in the "workers"
    // array.
    for (let i = 0; i < concurrency; i++) {
        workers[i] = new Worker('worker.js');

        // When we get a result back from a worker, we
        // place it in the appropriate response, based
        // on ID.
        workers[i].addEventListener('message', (e) => {
            var result = results[e.data.id];

            result.values.push(e.data.value);

            // If we've received the expected number of
            // responses, we can call the operation
            // callback, passing the responses as arguments.
            // We can also delete the response, since we're
            // done with it now.
            if (result.values.length === result.size) {
                result.done(...result.values);
                delete results[e.data.id];
            }
        });
    }

    // Continue yielding workers as long as they're
    // asked for.
    while (true) {
        yield workers[index] ?
            workers[index++] : workers[index = 0];
    }
}

// Creates the global "workers" generator.
var workers = genWorkers();

// This will generate unique IDs. We need them to
// map tasks executed by web workers to the larger
// operation that created them.
function* genID() {
    var id = 0;

    while (true) {
        yield id++;
    }
}

// Creates the global "id" generator.
var id = genID();

在这两个生成器——workersid——到位后,我们现在可以实施我们的 parallel() 高阶函数。想法是接受一个函数作为输入,以及一些其他参数,这些参数允许我们调整并行化的行为,并返回一个我们可以简单地正常调用的新函数。现在让我们看看这个函数:

// Builds a function that when called, runs the given task
// in workers by splitting up the data into chunks.
function parallel(expensive, taskName, taskFunc, doneFunc) {

    // The function that's returned takes the data to 
    // process as an argument, as well as the chunk size,
    // which has a default value.
    return function(data, size=250) {

        // If the data isn't large enough, and the
        // function isn't expensive, just run it in the
        // main thread.
        if (!isConcurrent(data, expensive)) {
            if (typeof taskFunc === 'function') {
                return taskFunc(data);
            } else {
                throw new Error('missing task function');
            }
        else {
            // A unique identifier for this call. Used
            // when reconciling the worker results.
            var operationID = id.next().value;

            // Used to track the position of the data
            // as we slice it into chunks.
            var index = 0;
            var chunk;

            // The global "results" object gets an
            // object with data about this operation.
            // The "size" property represents the
            // number of results we can expect back.
            // The "done" property is the callback
            // function that all the results are
            // passed to. And "values" holds the
            // results as they come in from the
            // workers.
            results[operationID] = {
                size: 0,
                done: doneFunc,
                values: []
            };

            while(true) {
                // Gets the next worker.
                let worker = workers.next().value;

                // Slice a chunk off the input data.
                chunk = data.slice(index, 
                    index + size);
                index += size;

                // If there's a chunk to process, we
                // can increment the size of the
                // expected results and post a
                // message to the worker. If there's
                // no chunk, we're done.
                if (chunk.length) {
                    results[operationID].size++;

                    worker.postMessage({
                        id: operationID,
                        task: taskName,
                        chunk: chunk
                    });
                } else {
                    break;
                }
            }
        }
    };
}

// Creates an array to process, filled with integers.
var array = new Array(2000)
    .fill(null)
    .map((v, i) => i);

// Creates a "sumConcurrent()" function that when called,
// will process the input data in workers.
var sumConcurrent = parallel(true, 'sum', sum,
    function(...results) {
        console.log('results', 
            results.reduce((r, v) => r + v));
    });

sumConcurrent(array);

现在我们可以使用parallel()函数构建在应用程序中所有地方都会调用的并发函数。例如,sumConcurrent()函数可以在我们需要计算大型输入的总和时使用。唯一不同的是输入数据。

注意

这里一个明显的限制是我们只能指定一个回调函数,当并行化的函数完成时调用。此外,这里还有很多需要做的工作——拥有 ID 来协调任务与它们的操作有点痛苦;这感觉就像我们正在实现承诺。这是因为这正是我们在做的事情。下一章将更详细地介绍如何将承诺与工作者结合使用,以避免混乱的抽象,就像我们刚刚实现的那样。

候选问题

在上一节中,你学会了创建一个通用函数,该函数可以即时决定如何使用工作者进行分解和征服,或者是否简单地调用主线程中的函数更有利。现在我们已经建立了一个通用的并行化机制,我们可以解决什么类型的问题?在本节中,我们将讨论最典型的并发场景,这些场景将受益于一个坚实的并发架构。

令人尴尬的并行

当一个任务显然可以分解成更小的任务时,它就是一个令人尴尬的并行问题。这些较小的任务彼此不依赖,这使得启动一个任务变得更加容易,该任务接受输入并产生输出,而不依赖于其他工作者的状态。这又回到了函数式编程,以及引用透明性和无副作用的概念。

这些是我们希望通过并发解决的问题类型——至少最初,在我们应用程序的第一个困难实现期间。这些是并发问题中的低垂之果,我们应该能够轻松解决,而不会影响我们交付功能的能力。

在上一节中我们实现的最后一个例子是一个令人尴尬的并行问题,其中我们只需要每个子任务将输入值相加并返回。当集合很大且无结构时,全局搜索也是另一个例子,我们只需付出很少的努力就能将其分解成更小的任务并将它们合并成结果。搜索大型文本输入也是一个类似的例子。映射和归约是另一个相对容易并行化的例子。

搜索集合

有些集合是有序的。这些集合可以有效地进行搜索,因为二分搜索算法能够仅基于数据已排序的前提来避免大量数据。然而,在其他时候,我们处理的是大量无结构或无序的集合。在其他情况下,时间复杂度可能为 O(n),因为集合中的每个项都需要被检查,不能做出任何假设。

大量的文本字符串是一个无结构的集合的好例子。如果我们在这个文本中搜索子字符串,就没有办法避免根据我们迄今为止找到的内容搜索文本的一部分——整个搜索空间都需要被覆盖。我们还需要计算大量文本中子字符串出现的次数。这是一个令人尴尬的并行问题。让我们编写一些代码来计算字符串输入中子字符串出现的次数。我们将重用上一节中创建的并行实用工具,特别是parallel()函数。我们将使用以下任务:

// Counts the number of times "item" appears in 
// "collection".
function count(collection, item) {
    var index = 0,
        occurrences = 0;

    while(true) {

        // Find the first index.
        index = collection.indexOf(item, index);

        // If we found something, increment the count, and
        // increment the starting index for the next
        // iteration. If nothing is found, break the loop.
        if (index > -1) {
            occurrences += 1;
            index += 1;
        } else {
            break;
        }
    }

    // Returns the number of occurrences found.
    return occurrences;
}

现在让我们创建一个用于搜索的文本块和一个用于搜索的并行函数:

// Unstructured text where we might need to find patterns.
var string = `
Lorem ipsum dolor sit amet, mei zril aperiam sanctus id, duo wisi
aeque molestiae ex. Utinam pertinacia ne nam, eu sed cibo senserit.
Te eius timeam docendi quo, vel aeque prompta philosophia id, nec
ut nibh accusamus vituperata. Id fuisset qualisque cotidieque sed,
eu verterem recusabo eam, te agam legimus interpretaris nam. Eos
graeco vivendo et, at vis simul primis.`;

// Constucts a new function - "stringCount()" using our 
// "parallel()" utility. Logs the number of string 
// occurrances by reducing the worker counts into a result.
var stringCount = parallel(true, 'count', count, 
    function(...results) {
        console.log('string',
            results.reduce((r, v) => r + v));
    });

// Kicks off the substring counting operation.
stringCount(string, 20, 'en');

在这里,我们将输入字符串分割成 20 个字符的块,并搜索输入值en。找到了 3 个结果。让我们看看我们是否可以使用这个任务,以及我们的并行工作器实用工具来计算一个数组中项出现的次数。

// Creates an array of 10,000 integers between 1 and 5.
var array = new Array(10000)
    .fill(null)
    .map(() => {
        return Math.floor(Math.random() * (5 - 1)) + 1;
    });

// Creates a parallel function that uess the "count" task,
// to count the number of occurances in the array.
var arrayCount = parallel(true, 'count', count, function(...results) {
    console.log('array', results.reduce((r, v) => r + v));
    });

// We're looking for the number 2 - there's probably lots of
//these.
arrayCount(array, 1000, 2);

由于我们正在使用随机整数生成这个 10,000 个元素的数组,所以每次运行的结果都会不同。然而,我们并行工作器实用工具的优点是,我们能够以较大的块大小调用arrayCount()

注意

你可能已经注意到我们正在过滤输入,而不是在输入中查找特定项。这是一个令人尴尬的并行问题与使用并发解决更困难的问题的例子。在前面的过滤代码中,我们的工作节点不需要相互通信。如果我们有多个工作节点在寻找单个项,我们不可避免地会面临早期终止的场景。

但为了处理早期终止,我们需要能够相互通信的工作者。这并不一定是一件坏事,只是更多的共享状态和更多的并发复杂性。这些决策在并发编程中变得相关——我们是否在其他地方进行优化以避免某些并发挑战?

映射和归约

JavaScript 中的Array原语已经有一个map()方法。正如我们所知,有两个关键因素会影响对给定输入数据运行给定操作的可扩展性和性能。这是数据的大小乘以应用于数据中每个项的任何任务的复杂性。如果我们将大量数据推入一个数组,然后使用昂贵的代码处理每个数组项,这些限制可能会给我们的应用程序带来问题。

让我们看看过去几个代码示例中使用的这种方法是否可以帮助我们将一个数组映射到另一个数组,而无需担心原生的Array.map()方法在单个 CPU 上运行——一个潜在的瓶颈。我们还将解决减少大量集合的问题。这与映射类似,只是我们使用Array.reduce()方法。以下是任务函数:

// A basic mapping that "plucks" the given
// "prop" from each item in the array.
function pluck(array, prop) {
    return array.map((x) => x[prop]);
}

// Returns the result of reducing the sum
// of the array items.
function sum(array) {
    return array.reduce((r, v) => r + v);
}

现在我们有了可以在任何地方调用的通用函数——主线程或从工作线程内部。我们不会再次查看工作线程的代码,因为它使用与之前示例相同的模式。它确定要调用的任务,并处理发送回主线程的响应格式化。让我们使用parallel()实用工具来创建一个并发映射和一个并发减少函数:

// Creates an array of 75,000 objects.
var array = new Array(75000)
    .fill(null)
    .map((v, i) => {
        return {
            id: i,
            enabled: true
        };
    });

// Creates a concurrent version of the "sum()"
// function.
var sumConcurrent = parallel(true, 'sum', sum,
    function(...results) {
        console.log('total', sum(results));
    });

// Creates a concurrent version of the "pluck()"
// function. When the parallel jobs complete, we
// pass the results to "sumConcurrent()".
var pluckConcurrent = parallel(true, 'pluck', pluck, 
    function(...results) {
        sumConcurrent([].concat(...results));
    });

// Kicks off the concurrent pluck operation.
pluckConcurrent(array, 1000, 'id');

在这里,我们创建了 75 个任务,分配给工作线程(75000/1000)。根据我们的并发级别,这意味着我们将同时从数组项中提取多个属性值。减少作业的工作方式相同;我们并发地求和映射的集合。我们仍然需要在sumConcurrent()回调中执行求和,但它非常少。

注意

在执行并发减少作业时,我们需要谨慎行事。映射是直接的,因为我们创建的实际上是对原始数组在大小和顺序上的克隆。只是值不同。减少可能依赖于当前的结果。换句话说,随着每个数组项通过减少函数,正在构建的结果可以改变最终的结果。并发使这变得困难,但在之前的例子中,问题令人尴尬地并行——并不是所有的减少作业都是并行的。

保持 DOM 响应性

到目前为止,在本章中,重点是数据驱动的——通过使用 Web Workers 来分割和征服,以输入和转换数据。这并不是工作线程的唯一用途;我们还可以使用它们来保持 DOM 对用户响应。

在本节中,我们将介绍一个在 Linux 内核开发中用于将事件分割成阶段以实现最佳性能的概念。然后,我们将解决 DOM 和我们的工作线程之间以及反之亦然的通信挑战。

底部半部分

Linux 内核有顶部半部分和底部半部分的概念。这个想法被硬件中断请求机制所使用。问题是硬件中断始终发生,而这个内核的任务是确保它们都能及时捕获和处理。为了有效地做到这一点,内核将处理硬件中断的任务分为两部分——顶部和底部。

上半部分的工作是响应外部刺激,比如鼠标点击或按键。然而,对上半部分施加了严格的限制,这是故意的。处理硬件中断请求的上半部分只能安排真正的任务——调用所有其他系统组件——在稍后时间。这项后续工作在下半部分完成。这种方法的副作用是中断在低级别迅速处理,从而在事件优先级方面提供了更大的灵活性。

内核开发与 JavaScript 和并发性有什么关系?嗯,事实证明我们可以借鉴这些想法,并将我们的“下半部分”工作委托给一个工作线程。我们的事件处理代码,响应 DOM 事件,实际上不会做任何事情,除了将消息传递给工作线程。这确保了主线程只做它绝对需要做的事情,没有任何额外的处理。这意味着如果 Web 工作线程返回一些要显示的内容,它可以立即这样做。记住,主线程包括渲染引擎,它会阻止我们的代码运行,反之亦然。

下面是上半部分和下半部分处理外部刺激的可视化:

下半部分

JavaScript 是运行到完成的,这一点我们现在已经很清楚。这意味着在上半部分花费的时间越少,我们就越有时间通过更新屏幕来响应用户。同时,JavaScript 也在 Web 工作线程中运行到完成,其中我们的下半部分运行。这意味着同样的限制也适用于这里;如果我们的工作线程在短时间内收到了 100 条消息,它们将按照先进先出FIFO)的顺序进行处理。

不同之处在于,由于这段代码没有在主线程中运行,当用户与之交互时,UI 组件仍然会做出响应。这是对高质量产品感知的一个如此关键的因素,以至于值得花时间去研究上半部分和下半部分。我们现在只需要找出一种实现方法。

翻译 DOM 操作

如果我们将 Web 工作线程视为我们应用程序的下半部分,那么我们需要一种方法来操作 DOM,同时尽可能少地在上半部分花费时间。也就是说,由工作线程来确定 DOM 树中需要改变什么,然后通知主线程。然后,主线程需要做的就是将发布的消息和所需的 DOM API 调用之间进行转换。在接收这些消息并将控制权交给 DOM 之间,没有对数据进行任何操作;毫秒在主线程中是宝贵的。

让我们看看这有多容易实现。我们将从工作线程实现开始,当它想要在 UI 中更新某些内容时,它会将 DOM 操作消息发送到主线程:

// Keeps track of how many list items we've rendered
// so far.
var counter = 0;

// Sends a message to the main thread with all the
// necessary DOM manipulation data.
function appendChild(settings) {
    postMessage(settings);

    // We've rendered all our items, we're done.
    if (counter === 3) {
        return;
    }

    // Schedule the next "appendChild()" message.
    setTimeout(() => {
        appendChild({
            action: 'appendChild',
            node: 'ul',
            type: 'li',
            content: `Item ${++counter}`
        });
    }, 1000);
}

// Schedules the first "appendChild()" message. This
// includes the data necessary to simply render the
// DOM in the main thread.
setTimeout(() => {
    appendChild({
        action: 'appendChild',
        node: 'ul',
        type: 'li',
        content: `Item ${++counter}`
    });
}, 1000);

这项工作向主线程发送了三条消息。它们使用setTimeout()进行计时,因此我们可以预期每秒渲染一个新的列表项,直到所有三个都显示出来。现在,让我们看看主线程代码是如何理解这些消息的:

// Starts the worker (the bottom-half).
var worker = new Worker('worker.js');

worker.addEventListener('message', (e) => {

    // If we get a message for the "appendChild" action,
    // then we create the new element and append it to the
    // appropriate parent - all this information is found
    // in the message data. This handler does absolutely
    // nothing but talk to the DOM.
    if (e.data.action === 'appendChild') {
        let child = document.createElement(e.data.type);
        child.textContent = e.data.content;

        document.querySelector(e.data.node)
            .appendChild(child);
            }
});

如我们所见,我们给上半部分(主线程)提供了很少的机会来形成瓶颈,这导致用户交互冻结。这很简单——这里执行的唯一代码就是 DOM 操作代码。这极大地增加了快速完成的可能性,使得屏幕能够对用户可见地更新。

那么,关于另一个方向,如何将外部事件引入系统而不干扰主线程呢?我们将在下一节探讨这个问题。

翻译 DOM 事件

一旦 DOM 事件被触发,我们希望将控制权交给我们的 Web 工作线程。这样,主线程可以继续像什么都没发生一样——每个人都满意。不幸的是,这比这还要复杂一些。例如,我们不能简单地监听每个元素上的每个事件,并将每个事件转发给工作线程,这会违背不在主线程中运行代码的目的,如果它不断地对事件做出响应。

相反,我们只想监听工作线程关心的 DOM 事件。这实际上与我们实现任何其他 Web 应用的方式没有太大区别;我们的组件监听它们感兴趣的事件。为了使用工作线程实现这一点,我们需要一个机制来告诉主线程在特定元素上设置一个 DOM 事件监听器。然后,工作线程可以简单地监听传入的 DOM 事件并相应地做出反应。让我们首先看看工作线程的实现:

// Tell the main thread that we want to be notified
// when the "input" event is triggered on "input
// elements.
postMessage({
    action: 'addEventListener',
    selector: 'input',
    event: 'input'
});

// Tell the main thread that we want to be notified
// when the "click" event is triggered on "button"
// elements.
postMessage({
    action: 'addEventListener',
    selector: 'button',
    event: 'click'
});

// A DOM event was triggered.
addEventListener('message', (e) => {
    var data = e.data;

    // Log the event differently, depending on where
    // the event was triggered from.
    if (data.selector === 'input') {
        console.log('worker', `typed "${data.value}"`);
    } else if (data.selector === 'button') {
        console.log('worker', 'clicked');
    }
});

这个工作线程请求主线程(它有权访问 DOM)设置两个事件监听器。然后,它为自己设置一个监听器,用于监听最终到达工作线程的 DOM 事件。让我们看看负责设置处理程序并将事件转发到工作线程的 DOM 代码:

// Starts the worker...
var worker = new Worker('worker.js');

// When we get a message, that means the worker wants
// to listen to a DOM event, so we have to setup
// the proxying.
worker.addEventListener('message', (msg) => {
    var data = msg.data;

    if (data.action === 'addEventListener') {
        // Find the nodes the worker is looking for.
        var nodes = 
            document.querySelectorAll(data.selector);

        // Add a new event handler for the given "event" to
        // each node we just found. When that event is
        // triggered, we simply post a message back to 
        // the worker containing relevant event data.
        for (let node of nodes) {
            node.addEventListener(data.event, (e) => {
                worker.postMessage({
                    selector: data.selector,
                    value: e.target.value
                });
            });
        }
    }
});

注意

为了简洁起见,我们只向工作线程发送了几个事件属性。由于 Web 工作线程消息中的序列化限制,我们不能发送原始的事件对象。实际上,这个相同的模式可以被使用,但我们可能会添加更多的事件属性,例如clientXclientY

摘要

上一章介绍了 Web 工作线程,突出了这些组件的强大功能。本章转换了方向,专注于并行性的“为什么”方面。我们通过查看函数式编程的一些方面以及它们如何适合 JavaScript 中的并发编程来开始。

我们研究了涉及确定在工作者之间并发执行给定操作可行性的因素。有时,将一个大任务拆分并分配给工作者作为更小的任务,会涉及大量的开销。我们实现了一些通用的实用工具,可以帮助我们实现并发函数,封装了一些相关的并发模板代码。

并非所有问题都适合并发解决方案。最佳方法是自上而下地工作,寻找那些显而易见的并行问题,因为它们是低垂的果实。然后我们将这一原则应用于多个 map-reduce 问题。

我们在章节的最后简要探讨了上下半部分的概念。这是一种策略,旨在使主线代码清晰,避免在保持用户界面响应性时引入待处理的 JavaScript 代码。当我们忙于思考我们最有可能遇到哪些并发问题以及解决这些问题的最佳方法时,我们的代码复杂性又上升了一个等级。下一章将介绍如何将我们的三个并发原则结合起来,以并发优先的方式,而不牺牲代码的可读性。

第七章。抽象并发

到目前为止,我们在代码中明确地建模了并发问题。使用承诺,我们同步了两个或多个异步操作。使用生成器,我们即时创建数据,避免了不必要的内存分配。最后,我们了解到 Web 工作者是利用多个 CPU 核心的得力助手。

在本章中,我们将把这些想法融入到应用代码的上下文中。也就是说,如果并发是默认的,那么我们需要尽可能让并发不显眼。我们将从探索各种技术开始,这些技术将帮助我们封装我们使用的组件中的并发机制。然后,我们将通过使用承诺来促进工作通信,直接改进前两章中的代码。

一旦我们能够使用承诺来抽象工作通信,我们将探讨在生成器的帮助下实现懒工作者的方法。我们还将介绍使用Parallel.js库进行工作抽象的概念,以及工作池的概念。

编写并发代码

并发编程很难做对。即使是在构造的示例应用中,大部分的复杂性都来自于并发代码。我们显然希望代码可读,同时保持并发的优势。我们希望从系统中的每个 CPU 上获取最大利益。我们只想在需要的时候计算所需的内容。我们不希望代码像意大利面一样杂乱无章地连接几个异步操作。在开发应用时,专注于所有这些并发编程的方面会分散我们真正应该关注的焦点——那些赋予应用价值的功能。

在本节中,我们将探讨我们可能使用的各种方法来隔离我们应用的其他部分,以避免棘手的并发部分。这通常意味着即使底层没有真正的并发发生,也要将并发作为默认模式。最终,我们不希望我们的代码包含 90%的并发技巧和 10%的功能。

隐藏并发机制

在整个代码中暴露并发机制的问题是它们彼此之间都有所不同。这放大了我们可能已经陷入的回调地狱。例如,并非所有的并发操作都是从远程资源获取数据的网络请求。异步数据可能来自工作者或某些本身也是异步的回调。想象一下这样一个场景:我们有三个不同的数据源用于计算我们需要的值——所有这些都是异步的。以下是问题的说明:

隐藏并发机制

此图中所示的数据是我们应用代码中关注的东西。从我们所构建的功能的角度来看,我们对其之上的任何东西都不感兴趣。因此,我们的前端架构需要封装与并发相关的复杂性。这意味着我们的每个组件都应该能够以相同的方式访问数据。除了我们所有的异步数据源之外,这里还有一个需要考虑的复杂问题——当数据不是异步的,而是来自本地源时怎么办?同步本地数据源和 HTTP 请求怎么办?我们将在下一节中介绍这个问题。

没有并发

虽然我们正在编写一个并发 JavaScript 应用,但并非每个操作都是固有的并发操作。例如,如果一个组件请求另一个组件它已经存储在内存中的数据,那么这不是一个异步操作,会立即返回。我们的应用可能充满了这样的操作,其中并发性根本不适用。这就是挑战所在——我们如何无缝地将异步操作与同步操作混合?

简单的答案是我们在每个地方都做出了并发的默认假设。承诺使这个问题变得可操作。以下是一个使用承诺封装异步和同步操作的示例:

没有并发

这看起来与之前的图表非常相似,但有两大重要区别。我们添加了一个synchronous()操作;这个操作没有回调函数,因为它不需要。它不需要等待任何其他东西,所以它会立即返回。其他两个函数与之前的图表中的函数一样;它们都依赖于回调函数将数据输入我们的应用。第二个重要区别是存在一个承诺对象。它取代了sync()操作和数据概念。或者更确切地说,它将它们融合成了同一个概念。

这就是承诺的关键特性——它们能够为我们抽象出同步问题的通用能力。这不仅适用于网络请求,也适用于 Web Worker 消息,或任何依赖回调的异步操作。我们需要稍微调整一下思维,将数据视为我们承诺它最终会到达。但是,一旦我们填补了这个心理差距,并发性就默认开启了。就我们的功能而言,并发性是默认的,而且我们在操作系统幕后所做的一切都不会造成任何干扰。

现在,让我们将注意力转向一些代码。我们将创建两个函数:一个异步函数和一个简单的函数,它只是返回一个值。我们的目标是使使用这些函数的代码相同,尽管值生成的方式存在重大差异:

// An asynchronous "fetch" function. We use "setTimeout()"
// to pass "callback()" some data after 1 second.
function fetchAsync(callback) {
    setTimeout(() => {
        callback({ hello: 'world' });
    }, 1000);
}

// The synchronous fetch simply returns the data.
function fetchSync() {
    return { hello: 'world' };
}

// A promise for the "fetchAsync()" call. We pass the
// "resolve" function as the callback.
var asyncPromise = new Promise((resolve, reject) => {
    fetchAsync(resolve);
});

// A promise for the "fetchSync()" call. This promise
// is resolved immediately with the return value.
var syncPromise = new Promise((resolve, reject) => {
    resolve(fetchSync());
});

// Creates a promise that'll wait for two promises
// to complete before resolving. This allows us
// to seamlessly mix synchronous and asynchronous
// values.
Promise.all([
    asyncPromise, 
    syncPromise 
]).then((results) => {
    var [ asyncResult, syncResult ] = results;

    console.log('async', asyncResult);
    // → async { hello: 'world' }

    console.log('sync', syncResult);
    // → sync { hello: 'world' }
});

这里的权衡是增加了承诺的复杂性,围绕着一个函数原本简单返回的值。但在现实中,这种复杂性被封装在承诺中,如果我们不是在编写并发应用程序,我们显然需要关注这些问题。好处是巨大的。当一切都是承诺值时,我们可以安全地排除导致讨厌的并发错误的矛盾。

使用承诺进行工作者通信

我们现在明白了将原始值作为承诺处理如何使我们的代码受益。现在是时候将这个概念应用到 Web 工作者上了。在前面的两章中,我们的代码开始看起来有点难以处理,因为我们实际上是在尝试模拟承诺擅长处理的许多样板工作。我们首先尝试通过创建辅助函数来解决这个问题,这些辅助函数为我们包装了工作者通信,并返回承诺。然后我们将尝试另一种涉及在较低级别扩展 Web 工作者接口的方法。最后,我们将查看一些更复杂的同步场景,这些场景涉及多个工作者,例如上一章中的那些。

辅助函数

如果我们能以承诺解决的形式获取 Web 工作者的响应,那将是理想的。但是,我们需要首先创建这个承诺——我们如何做到这一点呢?嗯,我们可以手动创建承诺,其中发送给工作者的消息是在承诺执行函数内部发送的。但是,如果我们采取这种方法,我们并没有比引入承诺之前好多少。

技巧是将发送给工作者的消息以及从工作者接收到的任何消息都封装在单个辅助函数中,就像这里所展示的那样:

辅助函数

让我们看看一个实现这种模式的示例辅助函数。首先,我们需要一个执行某些任务的工作者——我们从这个开始:

// Eat some CPU cycles...
// Taken from http://adambom.github.io/parallel.js/
function work(n) {
    var i = 0;
    while (++i < n * n) {}
    return i;
}

// When we receive a message, we post a message with the
// id, and the result of performing "work()" on "number".
addEventListener('message', (e) => {
    postMessage({
        id: e.data.id,
        result: work(e.data.number)
    });
});

在这里,有一个工作者,它会对我们传递给它的任何数字进行平方。这个work()函数故意设计得较慢,这样我们就可以看到当 Web 工作者完成任务的用时比平时长时,整个应用程序的表现。它还使用了一个 ID,就像我们在之前的 Web 工作者示例中看到的那样,这样它就可以与发送消息的代码进行协调。现在让我们实现使用这个工作者的辅助函数:

// This will generate unique IDs. We need them to
// map tasks executed by web workers to the larger
// operation that created them.
function* genID() {
    var id = 0;

    while (true) {
        yield id++;
    }
}

// Creates the global "id" generator.
var id = genID();

// This object holds the resolver functions from promises,
// as results comeback from workers, we look them up here,
// based on ID.
var resolvers = {};

// Starts our worker...
var worker = new Worker('worker.js');

worker.addEventListener('message', (e) => {
    // Finds the appropriate resolver function.
    var resolver = resolvers[e.data.id];

    // Deletes it from the "resolvers" object.
    delete resolvers[e.data.id];

    // Pass the worker data to the promise by calling
    // the resolver function.
    resolver(e.data.result);
});

// This is our helper function. It handles the posting of
// messages to the worker, and tying the promise to the
// worker responses.
function square(number) {
    return new Promise((resolve, reject) => {
        // The ID that's used to tie together a web 
        // worker response, and a resolver function.
        var msgId = id.next().value;

        // Stores the resolver so in can be used later, in
        // the web worker message callback.
        resolvers[msgId] = resolve;

        // Posts the message - the ID and the number
        // argument.
        worker.postMessage({
            id: msgId,
            number: number
        });
    });
}

square(10).then((result) => {
    console.log('square(10)', result);
    // → square(10) 100
});

square(100).then((result) => {
    console.log('square(100)', result);
    // → square(100) 10000
});

square(1000).then((result) => {
    console.log('square(1000)', result);
    // → square(1000) 1000000
});

如果我们关注square()函数的使用方式,即传递一个数字参数并返回一个承诺作为结果,我们可以看到这符合我们之前关于默认使代码并行的讨论。例如,我们可以完全从这种场景中移除工作者,只需简单地改变辅助函数解决它返回的承诺的方式,其余的代码将继续按原样运行。

辅助函数策略只是简化使用承诺的工作线程通信的一种方法。也许我们可以决定我们不一定需要维护一大堆辅助函数。接下来,我们将探讨一种比辅助函数更细粒度的方法。

扩展postMessage()

与积累大量辅助函数相比,我们可以采取更通用的路线。辅助函数没有错;它们直接且简洁。如果我们真的有数百个,它们的价值会迅速贬值。更通用的方法是一直使用worker.postMessage()

让我们看看我们是否可以使这个方法返回一个承诺,就像我们上一节中的辅助函数一样。这样,我们继续使用细粒度的postMessage()方法,但改进了我们的同步语义。首先,这是工作线程代码:

addEventListener('message', (e) => {

    // The result we're posting back to the main
    // thread - it should always contain the
    // message ID.
    var result = { id: e.data.id };

    // Based on the "action", compute the response
    // "value". The options are leave the text alone,
    // convert it to upper case, or convert it to
    // lower case.
    if (e.data.action === 'echo') {
        result.value = e.data.value
    } else if (e.data.action === 'upper') {
        result.value = e.data.value.toUpperCase();
    } else if (e.data.action === 'lower') {
        result.value = e.data.value.toLowerCase();
    }

    // Simulate a longer-running worker by waiting
    // 1 second before posting the response back.
    setTimeout(() => {
        postMessage(result);
    }, 1000);
});

这与我们迄今为止在 web 工作线程代码中看到的内容并没有什么根本的不同。现在,在主线程中,我们必须找出如何改变Worker的接口。让我们现在就做这件事。然后,我们将尝试向这个工作线程发送一些消息,并作为响应解析承诺:

// This object holds the resolver functions from promises,
// as results comeback from workers, we look them up here,
// based on ID.
var resolvers = {};

// Keep the original implementation of "postMessage()"
// so we can call it later on, in our custom "postMessage()"
// implementation.
var postMessage = Worker.prototype.postMessage;

// Replace "postMessage()" with our custom implementation.
Worker.prototype.postMessage = function(data) {
    return new Promise((resolve, reject) => {

        // The ID that's used to tie together a web worker
        // response, and a resolver function.
        var msgId = id.next().value;

        // Stores the resolver so in can be used later, in
        // the web worker message callback.
        resolvers[msgId] = resolve;

        // Run the original "Worker.postMessage()"
        // implementation, which takes care of actually
        // posting the message to the worker thread.
        postMessage.call(this, Object.assign({
            id: msgId
        }, data));
    });
};

// Starts our worker...
var worker = new Worker('worker.js');

worker.addEventListener('message', (e) => {

    // Finds the appropriate resolver function.
    var resolver = resolvers[e.data.id];

    // Deletes it from the "resolvers" object.
    delete resolvers[e.data.id];

    // Pass the worker data to the promise by calling
    // the resolver function.
    resolver(e.data.value);
});

worker.postMessage({
    action: 'echo',
    value: 'Hello World'
}).then((value) => {
    console.log('echo', `"${value}"`);
    // → echo "Hello World"
});

worker.postMessage({
    action: 'upper',
    value: 'Hello World'
}).then((value) => {
    console.log('upper', `"${value}"`);
    // → upper "HELLO WORLD"
});

worker.postMessage({
    action: 'lower',
    value: 'Hello World'
}).then((value) => {
    console.log('lower', `"${value}"`);
    // → lower "hello world"
});

好吧,这正是我们所需要的,对吧?我们可以直接将消息数据发送到工作线程,响应数据则通过承诺解析返回给我们。作为额外的奖励,如果我们愿意,实际上可以在这个新的postMessage()函数实现周围包装辅助函数。使这一切工作起来的主要技巧是存储对原始postMessage()的引用。然后,我们覆盖 web 工作线程属性postMessage,而不是函数本身。最后,我们可以重用它来添加必要的协调和承诺的好处。

同步工作线程结果

最后两个部分的代码已经足够减少我们的 web 工作线程回调地狱到一个更可容忍的水平。事实上,现在我们已经掌握了如何通过postMessage()返回承诺来封装 web 工作线程通信,我们准备开始简化任何不使用此方法的混乱工作线程代码。到目前为止,我们所查看的示例已经从承诺中受益匪浅,它们很简单;没有这些抽象并不会是世界末日。

那么,当我们映射一组数据然后减少映射集合的场景呢?我们可能会回忆起在第六章 实践并行性 中,映射/减少代码变得有些复杂。这主要是因为所有与尝试执行映射/减少操作代码纠缠在一起的工人通信样板代码。让我们看看使用我们的承诺技术是否会有所改善。首先,我们将创建一个非常基础的工作线程:

// Returns a map of the input array, by squaring
// each number in the array.
addEventListener('message', (e) => {
    postMessage({
        id: e.data.id,
        value: e.data.value.map(v => v * v)
    });
});

我们可以使用这个工作线程来传递映射数组。因此,我们将创建两个,并在两个工作线程之间分配工作量,如下所示:

function onMessage(e) {

    // Finds the appropriate resolver function.
    var resolver = resolvers[e.data.id];

    // Deletes it from the "resolvers" object.
    delete resolvers[e.data.id];

    // Pass the worker data to the promise by calling
    // the resolver function.
    resolver(e.data.value);
}

// Starts our workers...
var worker1 = new Worker('worker.js'),
    worker2 = new Worker('worker.js');

// Create some data to process.
var array = new Array(50000)
    .fill(null)
    .map((v, i) => i);

// Finds the appropriate resolver function to call,
// when the worker responds with data.
worker1.addEventListener('message', onMessage);
worker2.addEventListener('message', onMessage);

// Splits our input data in 2, giving the first half
// to the first worker, and the second half to the
// second worker. At this point, we have two promises.
var promise1 = worker1.postMessage({
    value: array.slice(0, Math.floor(array.length / 2))
});

var promise2 = worker2.postMessage({
    value: array.slice(Math.floor(array.length / 2))
});

// Using "Promise.all()" to synchronize workers is
// much easier than manually trying to reconcile
// through worker callback functions.
Promise.all([ promise1, promise2 ]).then((values) => {
    console.log('reduced', [].concat(...values)
        .reduce((r, v) => r + v));
    // → reduced 41665416675000
});

当我们只需要将数据发布到工作者,并从两个或更多工作者同步数据时,我们实际上有动力编写并发代码——现在它看起来和我们的应用代码一样。

懒工作者

是时候从不同角度审视 Web Workers 了。我们最初使用工作者的基本原因是我们想要在相同的时间内计算比过去更多的内容。正如我们现在所知,这样做涉及到消息的复杂性、分而治之的策略。我们必须将数据放入和取出工作者,通常作为一个数组。

生成器帮助我们懒加载地计算。也就是说,我们不想在真正需要之前计算某些东西或分配内存。Web Workers 会使得这种懒加载变得困难或不可能实现吗?或者我们可以利用生成器来懒加载和并行计算?

在本节中,我们将探讨与在 Web Workers 中使用生成器相关的一些想法。首先,我们将查看与 Web Workers 相关的开销问题。然后,我们将编写一些使用生成器在工作者之间传递数据的代码。最后,我们将看看我们是否可以懒加载地通过一系列生成器传递数据,所有这些生成器都位于 Web Workers 中。

减少开销

主线程可以将昂贵的操作卸载给 Web Workers,在另一个线程中运行它们。这意味着 DOM 能够绘制挂起的更新并处理挂起的用户事件。然而,我们仍然面临分配大型数组以及更新 UI 所需时间的开销。尽管使用了 Web Workers 进行并行处理,但我们的用户仍然可能面临速度减慢,因为没有更新到 UI,直到整个数据集被处理。以下是这种一般模式的可视化:

减少开销

这是一个单工作者处理数据的通用路径;当有多个工作者时,也适用同样的方法。采用这种方法,我们无法避免需要两次序列化数据,并且需要两次分配。这些开销仅仅是方便工作者之间的通信,与我们试图实现的应用功能关系不大。

由于工作者通信所需的数组和序列化开销通常不是什么大问题。然而,对于更大的集合,我们可能会面临真正的性能问题,这些问题源于我们用来提高性能的机制。因此,从另一个角度审视工作者通信是有益的,即使最初并不必要。

这里是大多数工作者采取的通用路径的一种变体。不是预先分配和序列化大量数据,而是将单个项目在工作者之间传递。这给了 UI 使用已处理的数据更新的机会,在所有处理数据到达之前。

减少开销

在工作者中生成值

如果我们想在工作者生成结果时更新 UI,那么它们不能在所有计算完成后将结果集打包为数组发送回主线程。在这个过程中,UI 会坐那里不响应用户。我们想要一个更懒惰的方法,一次生成一个值,这样 UI 就可以更早地更新。让我们构建一个示例,将输入发送到 Web 工作者,并以比我们在本书中迄今为止看到的更细粒度的方式发送结果:

首先,我们将创建一个工作者;它的代码如下:

// Eat some CPU cycles...
// Taken from http://adambom.github.io/parallel.js/
function work(n) {
    var i = 0;
    while (++i < n * n) {}
    return i;
}

// Post the result of calling "work()" back to the
// main thread.
addEventListener('message', (e) => {
    postMessage(work(e.data));
});

这里没有什么石破天惊的。这正是我们之前已经使用过的work()函数,通过不高效地平方一个数字来故意减慢我们的代码。在工作者内部没有使用实际的生成器。这是因为我们真的不需要一个,我们稍后会看到原因:

// Creates an "update()" coroutine that continuously
// updates the UI as results are generated from the
// worker.
var update = coroutine(function* () {
    var input;

    while (true) {
        input = yield;
        console.log('result', input.data);
    }
});

// Creates the worker, and assigns the "update()"
// coroutine as the "message" callback handler.
var worker = new Worker('worker.js');
worker.addEventListener('message', update);

// An array of progressively larger numbers.
var array = new Array(10)
    .fill(null)
    .map((v, i) => i * 10000);

// Iterate over the array, passing each number to the
// worker as an individual message.
for (let item of array) {
    worker.postMessage(item);
}
// → 
// result 1
// result 100000000
// result 400000000
// result 900000000
// result 1600000000
// result 2500000000
// result 3600000000
// result 4900000000
// result 6400000000
// result 8100000000

传递给我们的工作者的每个数字比前一个数字处理起来更昂贵。因此,在向用户显示任何内容之前处理整个输入数组会给人一种应用程序挂起或损坏的感觉。但在这里并非如此,因为尽管每个数字处理起来都很昂贵,但我们正在将结果作为它们可用时发送回去。

我们执行的工作量与我们通过传递数组并返回数组作为输出所执行的工作量相同。然而,这种方法只是改变了事情发生的顺序。我们已经引入了协作多任务处理——在一个任务中计算一些数据,在另一个任务中更新 UI。完成工作所需的总时间是相同的,但对用户来说,感觉要快得多。最终,我们应用程序的用户可感知性能是唯一重要的性能指标。

注意

我们将输入作为单个消息传递。我们也可以将输入作为数组传递,分别发布结果,并得到相同的效果。然而,这可能只是无用的复杂性。按照目前的模式,这种对应关系是自然的——输入项目,输出项目。如果不需要,就不要改变它。

惰性工作者链

如我们在第四章中看到的,使用生成器的惰性求值,我们可以组装生成器的链。这就是我们如何以惰性方式实现复杂功能;一个项目通过一系列在传递给下一个生成器之前转换项目的生成器函数流动,直到它达到调用者。如果没有生成器,我们可能不得不分配大量的中间数据结构,仅仅是为了将数据从一个函数传递到下一个函数。

在本节之前,我们看到了使用 Web 工作者实现类似于生成器的模式是可能的。由于我们在这里面临类似的问题,我们不希望分配大型数据结构。我们可以通过在更细粒度级别传递项来避免这样做。这还有一个额外的优点,即保持 UI 响应,因为我们能够在工作者从最后一个项到达之前更新它。鉴于我们可以通过工作者做到这一点,我们能否在此基础上构建并组装更复杂的工作者处理节点链?

例如,假设我们有一组数字和几个转换。在我们能在 UI 中显示它们之前,我们需要按照特定的顺序执行这些转换。理想情况下,我们会设置一个工作者的链,其中每个工作者负责执行其指定的转换,然后将输出传递给下一个工作者。最终,主线程会得到一个可以显示在 DOM 中的值。

这个目标的问题在于其中涉及到的复杂通信。由于专用工作者只与创建它们的线程通信,将结果发送回主线程,然后传递给链中的下一个工作者,如此等等,几乎没有什么优势。然而,事实证明,专用工作者可以直接通信,而不涉及主线程。在这里,我们可以使用一种称为通道消息的东西。这个想法很简单;它涉及到创建一个通道,该通道有两个端口——消息在一个端口上发布,在另一个端口上接收。

我们一直在使用消息通道和端口。它们是内置于 Web 工作者中的。这就是消息事件和postMessage()方法模式来源的地方。

以下是我们如何使用通道和端口连接我们的 Web 工作者的可视化:

懒工作者链

正如我们所见,每个通道使用两个消息端口。第一个端口用于发布消息,而第二个端口用于接收消息事件。唯一使用主线程的时候是当处理链首次启动时,通过向第一个通道发布消息,以及从第三个通道接收消息时。

不要让六个用于工作者通信的端口让我们感到害怕,让我们写一些代码;也许在那里它看起来会更容易接近。首先,我们将创建链中使用的工作者。实际上,它们是同一工作者的两个实例。以下是代码:

addEventListener('message', (e) => {

    // Get the ports used to send and receive messages.
    var [ port1, port2 ] = e.ports;

    // Listen for incoming messages of the first port.
    port1.addEventListener('message', (e) => {

        // Respond on the second port with the result of
        // calling "work()".
        port2.postMessage(work(e.data));
    });

    // Starts both ports.
    port1.start();
    port2.start();
});

这很有趣。在这个工作进程中,我们有可以工作的消息端口。第一个端口用于接收输入,第二个端口用于发送输出。work() 函数简单地使用我们熟悉的通过浪费 CPU 周期来观察工作进程行为的方法,对给定的数字进行平方。在我们的主线程中,我们想要创建这个工作进程的两个实例,以便我们可以将一个数字传递给第一个实例进行平方。然后,它不将结果传回主线程,而是将结果传递给下一个工作进程,数字再次被平方。通信路径应该与之前的图非常相似。让我们看看一些使用消息通道连接工作进程的代码:

// Starts our workers...
var worker1 = new Worker('worker.js');
var worker2 = new Worker('worker.js');

// Creates the message channels necessary to communicate
// between the 2 workers.
var channel1 = new MessageChannel();
var channel2 = new MessageChannel();
var channel3 = new MessageChannel();

// Our "update()" coroutine logs worker responses as they're
// delivered.
var update = coroutine(function* () {
    var input;

    while (true) {
        input = yield;
        console.log('result', input.data);
    }
});

// Connects "channel1" and "channel2" using "worker1".
worker1.postMessage(null, [
    channel1.port2,
    channel2.port1
]);

// Connects "channel2" and "channel3" using "worker2".
worker2.postMessage(null, [
    channel2.port2,
    channel3.port1
]);

// Connects our coroutine "update()" to any messages
// received on "channel3".
channel3.port2.addEventListener('message', update);
channel3.port2.start();

// Our input data - an array of numbers.
var array = new Array(25)
    .fill(null)
    .map((v, i) => i * 10);

// Posts each array item to "channel1".
for (let item of array) {
    channel1.port1.postMessage(item);
}

除了我们想要发送给工作进程的数据外,我们还可以发送一个我们想要传递给工作进程上下文的消息端口列表。这就是我们向工作进程发送前两条消息所做的事情。消息数据是 null,因为我们没有对它做任何事情。实际上,这是我们直接发送给工作进程的唯一消息。其余的通信都是通过我们创建的消息通道进行的。昂贵的计算发生在工作进程中,因为消息处理程序就驻留在那里。

使用 Parallel.js

Parallel.js 库的目标是使与 Web Workers 的交互尽可能无缝。实际上,它处理了本书的一个关键目标——隐藏并发机制,并允许我们专注于我们正在构建的应用程序。

在本节中,我们将探讨 Parallel.js 用于工作进程通信的方法以及将代码传递给工作进程的一般方法。然后,我们将通过一些使用 Parallel.js 创建新工作进程的代码进行说明。最后,我们将探索库提供的内置的 map/reduce 功能。

它是如何工作的

在本书中,我们使用过的所有工作进程都是我们自己创建的。我们在工作进程中实现了消息事件处理,计算了一些值,然后发布了响应。使用 Parallel.js,我们不实现工作进程。相反,我们实现函数,这些函数随后被传递给由库管理的工作进程。

这为我们解决了一些头疼的问题。我们所有的代码都在主线程中实现,这意味着使用我们在主线程中实现的函数更容易,因为我们不需要使用 importScripts() 将它们导入到 Web Workers 中。我们也不需要手动通过创建带有脚本路径的脚本来启动 Web Workers。相反,我们让 Parallel.js 为我们创建新的工作进程,然后,我们可以通过向它们传递函数和数据来告诉工作进程要做什么。那么,这究竟是如何工作的呢?

工作者需要一个脚本参数。如果没有有效的脚本,工作者将无法工作。Parallel.js 有一个直接的 eval 脚本。这是库创建的任何工作者接收到的脚本。然后,主线程中的 API 组装要工作者评估的代码,并在需要与工作者通信时发送它。

这是可行的,因为 Parallel.js 并不旨在通过工作者提供大量功能。相反,目标是使工作者通信机制尽可能无缝,同时提供最小功能。这使得我们能够仅构建与我们的应用程序相关的并发功能,而不是一大堆我们永远不会使用的其他功能。

下面是使用 Parallel.js 和其 eval 脚本将数据和代码传递给工作者的说明:

工作原理

启动工作者

Parallel.js 库有一个关于工作的概念。工作的主要输入是工作将要处理的数据。工作的创建并不直接与后台工作者的创建相关联。工作者与 Parallel.js 的工作不同;当我们使用库时,我们不直接与工作者交互。一旦我们有了工作实例,并且它提供了我们的数据,我们就使用工作方法来调用工作者。

最基本的方法是 spawn(),它接受一个函数作为参数,并在一个网络工作者中运行它。我们传递给它的函数可以返回结果,这些结果随后作为由 spawn() 返回的可解析对象。让我们看看一些使用 Parallel.js 通过网络工作者启动新任务的代码示例:

// An input array of numbers.
var array = new Array(2500)
    .fill(null)
    .map((v, i) => i);

// Creates a new parallel job. No workers have been
// created at this point - we only pass the constructor
// the data we're working with.
var job = new Parallel(array);

// Start a timer for our "spawn()" job.
console.time(`${array.length} items`);

// Creates a new web worker, passing it our data and
// this function. We're slowly mapping each number in
// the array to it's square.
job.spawn((coll) => {
    return coll.map((n) => {
        var i = 0;
        while (++i < n * n) {}
        return i;
    });

// The return value of "spawn()" is a thenable. Meaning
// we can assign a "then()" callback function, just as
// though a promise were returned.
}).then((results) => {
    console.timeEnd(`${array.length} items`);
    // → 2500 items: 3408.078ms
});

好吧,这真的很酷;我们不必担心任何单调的网络工作者生命周期任务。我们有一些数据和想要应用于这些数据的函数,我们希望与其他页面上的工作并行运行它。最棒的是,从 spawn() 方法返回的熟悉的可解析对象完美地融入我们的并发应用程序中,其中其他所有内容都被视为承诺。

我们记录我们的函数处理我们给出的输入数据所需的时间。为此任务,我们只启动一个网络工作者,因此结果达到的时间与在主线程中计算的时间相同。除了释放主线程以处理 DOM 事件和重绘外,没有客观的性能提升。我们将看看我们是否可以使用不同的方法来提高并发级别。

注意

当我们完成与 spawn() 创建的工作者的操作时,该工作者会立即终止。这为我们释放了内存。然而,没有并发级别来控制 spawn() 的使用,我们可以连续调用它 100 次。

映射和归约

在最后一节中,我们使用spawn()方法创建了一个工作线程。Parallel.js还有一个map()方法和一个reduce()方法。我们的想法是让事情变得更容易。通过将函数传递给map(),库将自动将其应用于工作数据中的每个项目。类似的语义也适用于reduce()方法。让我们通过编写一些代码来看看它是如何工作的:

// An input array of numbers.
var array = new Array(2500)
    .fill(null)
    .map((v, i) => i);

// Creates a new parallel job. No workers have been
// created at this point - we only pass the constructor
// the data we're working with.
var job1 = new Parallel(array);

// Start a timer for our "spawn()" job.
console.time('job1');

// The problem here is that Parallel.js will
// create a new worker for every array element, resulting
// in parallel slowdown.
job1.map((n) => {
    var i = 0;
    while (++i < n * n) {}
    return i;
}).reduce((pair) => {

    // Reduces the array items to a sum.
    return pair[0] + pair[1];
}).then((data) => {
    console.log('job1 reduced', data);
    // → job1 reduced 5205208751

    console.timeEnd('job1');
    // → job1: 59443.863ms
});

哎呀!这里的性能损失相当大——这是怎么回事?我们在这里看到的是一种称为并行减速的现象。这种减速发生在并行通信开销过多的情况下。这个特定例子中发生这种情况的原因是Parallel.jsmap()中处理数组的方式。每个数组项都会通过一个工作者。这并不意味着“创建了2500个工作者——每个数组元素一个。创建的工作者数量最多为四个或navigator.hardwareConcurrency的值——这是我们在这本书前面看到的类似语义。

真正的开销来自于发送到工作者和从工作者接收的消息——5000 条消息!这显然不是最优的,正如代码中的计时器所示。让我们看看我们是否可以在保持大致相同的代码结构的同时,对这些数字进行大幅改进:

// A faster implementation.
var job2 = new Parallel(array);

console.time('job2');

// Before mapping the array, split the array into chunks
// of smaller arrays. This way, each Parallel.js worker is
// processing an array instead of an array item. This avoids
// sending thousands of web worker messages.
job2.spawn((data) => {
    var index = 0,
        size = 1000,
        results = [];

    while (true) {
        let chunk = data.slice(index, index + size);

        if (chunk.length) {
            results.push(chunk);
            index += size;
        } else {
            return results;
        }
    }
}).map((array) => {

    // Returns a mapping of the array chunk.
    return array.map((n) => {
        var i = 0;
        while (++i < n * n) {}
        return i;
    });
}).reduce((pair) => {

    // Reduces array chunks, or numbers, to a sum.
    return (Array.isArray(pair[0]) ?
            pair[0].reduce((r, v) => r + v) : pair[0]) +
        (Array.isArray(pair[1]) ?
            pair[1].reduce((r, v) => r + v) : pair[1]);
}).then((data) => {
    console.log('job2 reduced', data);
    // → job2 reduced 5205208751

    console.timeEnd('job2');
    // → job2: 2723.661ms
});

在这里,我们可以看到产生了相同的结果,而且速度要快得多。区别在于我们首先将数组切割成更小的数组块。这些数组是传递给工作者的项目,而不是单个数字。因此,映射工作也需要稍微改变,不再是平方一个数字,而是将较小的数组映射到平方数的数组。减少逻辑稍微复杂一些,但总体上,我们的方法仍然是相同的。最重要的是,我们移除了导致第一次实现性能不佳的严重消息传递瓶颈。

注意

就像spawn()方法在返回时清理工作者一样,map()reduce()Parallel.js方法也是如此。释放工作者的缺点是每次调用这些方法时都需要重新创建它们。我们将在下一节中解决这个挑战。

工作池

本章的最后部分涵盖了工作池的概念。在前面的Parallel.js部分中,我们遇到了一个问题,即工作者(workers)频繁地被创建和终止。这造成了大量的开销。如果我们知道我们能够处理的并发级别,那么为什么不分配一个静态大小的工人池来承担工作呢?

创建工作池的第一个设计任务是分配工作者。下一步是将作业按顺序分配给池中可用的工人。最后,我们需要考虑到所有工作者都忙碌时的忙碌状态。让我们这样做。

分配池

在我们考虑分配工作线程池之前,我们需要查看总的工人池抽象。我们希望它看起来和表现如何?理想情况下,我们希望池抽象看起来和表现像一个普通的专用工人。我们可以向池发送消息并得到一个响应的承诺。因此,虽然我们不能直接扩展 Worker 原型,但我们可以创建一个新的抽象,该抽象与 Worker API 非常相似。

让我们看看一些代码。这是我们将要使用的初始抽象:

// Represents a "pool" of web worker threads, hidden behind
// the interface of a single web worker interface.
function WorkerPool(script) {

    // The level of concurrency, or, the number of web
    // workers to create. This uses the 
    // "hardwareConcurrency" property if it exists.
    // Otherwise, it defaults to 4, since this is
    // a reasonable guess at the most common CPU topology.
    var concurrency = navigator.hardwareConcurrency || 4;

    // The worker instances themselves are stored in a Map,
    // as keys. We'll see why in a moment.
    var workers = this.workers = new Map();

    // The queue exists for messages that are posted while,
    // all workers are busy. So this may never actually be
    // used.
    var queue = this.queue = [];

    // Used below for creating the worker instances, and 
    // adding event listeners.
    var worker;

    for (var i = 0; i < concurrency; i++) {
        worker = new Worker(script);
        worker.addEventListener('message', function(e) {

            // We use the "get()" method to lookup the
            // "resolve()" function of the promise. The
            // worker is the key. We call the resolver with
            // the data returned from the worker, and
            // can now reset this to null. This is important
            // because it signifies that the worker is free
            // to take on more work.
            workers.get(this)(e.data);
            workers.set(this, null);

            // If there's queued data, we get the first
            // "data" and "resolver" from the queue. Before
            // we call "postMessage()" with the data, we
            // update the "workers" map with the new
            // "resolve()" function.
            if (queue.length) {
                var [ data, resolver ] = queue.shift();
                workers.set(this, resolver);
                this.postMessage(data);
            }
        }.bind(worker));

        // This is the initial setting of the worker, as a
        // key, in the "workers" map. It's value is null,
        // meaning there's no resolve function, and it can
        // take on work.
        this.workers.set(worker, null);
    }
}

当创建一个新的 WorkerPool 时,给定的脚本用于在池中启动所有工作线程。workers 属性是一个 Map 实例,工作线程实例本身是键。我们之所以将工作线程存储为映射键,是为了能够轻松查找要调用的适当解析函数。

当给定的工作线程响应时,我们添加到每个工作线程的 message 事件处理程序被调用,这就是我们找到等待调用的解析函数的地方。由于给定的工作线程在完成当前任务之前不会接受新的工作,所以我们不可能调用错误的解析函数。

调度任务

现在我们将实现 postMessage() 方法。调用者将使用此方法向池中的某个工作线程发送消息。调用者不知道哪个工作线程会满足他们的请求,也不关心。他们获得一个承诺作为返回值,并且当工作线程响应时,承诺被解决:

WorkerPool.prototype.postMessage = function(data) {

    // The "workers" Map instance, where all the web workers
    // are stored.
    var workers = this.workers;

    // The "queue" where messages are placed when all the
    // workers are busy.
    var queue = this.queue;

    // Try finding an available worker.
    var worker = this.getWorker();

    // The promise is immediately passed back to the caller,
    // even if there's no worker available.
    return new Promise(function(resolve) {

        // If a worker is found, we can update the map,
        // using the worker as the key, and the "resolve()"
        // function as the value. If there's no worker, then
        // the message data, along with the "resolve()"
        // function get pushed to the "queue".
        if (worker) {
            workers.set(worker, resolve);
            worker.postMessage(data);
        } else {
            queue.push([ data, resolve ]);
        }
    });
};

这是承诺执行函数,它负责找到第一个可用的工人并在此处发送我们的消息。当找到可用的工人时,我们也在我们的 workers 映射中设置工人的解析函数。如果没有可用的 workers 在池中,发送的消息将进入 queue。这是因为在 message 事件处理程序中清空队列。这是因为当工作线程带着消息回来时,意味着工作线程可以接受更多的工作,并且在返回空闲状态之前会检查是否有任何排队的内容。

getWorker() 方法是一个简单的辅助函数,用于为我们找到下一个可用的工人。我们知道,如果 workers 映射中的 resolver 函数设置为 null,则工作线程可以接受任务。最后,让我们看看这个工作池的实际应用:

// Create a new pool, and a workload counter.
var pool = new WorkerPool('worker.js');
var workload = 0;

document.getElementById('work')
    .addEventListener('click', function(e) {

        // Get the data we're going to pass to the
        // worker, and create a timer for this workload.
        var amount = +document.getElementById('amount').value,
            timer = 'Workload ' + (++workload);

        console.time(timer);

        // Pass the message to the pool, and when the promise
        // resolves, stop the timer.
        pool.postMessage(amount).then(function(result) {
            console.timeEnd(timer);
        });

        // If messages are getting queued, our pool is 
        // overworked display a warning.
        if (pool.queue.length) {
            console.warn('Worker pool is getting busy...');
        }
    });

在这个使用场景中,我们有一些表单控件将参数化工作发送到工作线程。数字越大,工作所需的时间越长;它使用我们的标准 work() 函数,该函数会缓慢地平方数字。如果我们使用一个大的数字并频繁地点击发送消息到池的按钮,那么最终我们会耗尽池中的资源。如果出现这种情况,我们将显示一个警告。然而,这只是为了故障排除目的——当池忙碌时,发送的消息并没有丢失,它们只是被排队。

摘要

本章的重点一直是从我们的代码中移除显眼的并发语义。这仅仅提高了我们应用程序成功的可能性,因为我们会有易于维护和构建的代码。我们首先解决的问题是通过使一切并发来编写并发代码。当没有猜测的成分时,我们的代码是一致的,并且对并发错误的抵抗力更小。

然后,我们探讨了我们可以采取的各种方法来抽象 Web 工作者的通信。辅助函数是一个选择,扩展postMessage()方法也是一个选择。当我们需要 UI 保持响应时,我们解决了一些 Web 工作者的局限性。尽管我们的大数据集处理得更快,但我们仍然有更新 UI 的问题。这是通过将 Web 工作者视为生成器来完成的。

我们不必自己编写所有这些 JavaScript 并行化工具。我们花了一些时间来查看Parallel.js库的各种功能和限制。我们通过查看 Web 工作池来结束本章,这些工作池减少了与工作创建和终止相关的许多开销,并且极大地简化了任务分配和结果协调的方式。

前端并发主题的内容就到这里。现在是我们转换方向,用 NodeJS 来探讨后端 JavaScript 并发的时候了。

第八章:使用 Node.js 的事件驱动 I/O

Node.js 利用 Chrome JavaScript 引擎 V8,提供高性能的服务器环境。Node.js 的适用范围不仅限于 Web 服务器——这只是它最初被构思的问题空间。实际上,它是为了解决全球 Web 程序员面临的某些复杂的并发问题而创建的。

本章的目的是解释 Node 如何处理并发,以及我们需要如何编写 Node.js 代码以充分利用这个环境。Node 与其他 Web 服务器环境最明显的区别是它使用单个线程来处理请求,并依赖于事件驱动的 I/O 以实现高并发。然后我们将深入探讨为什么在 Web 环境中采用事件驱动的 I/O 方法是有意义的。

由于 I/O 事件循环基于网络和文件操作,我们将在本章的剩余部分探讨各种网络和文件 I/O 示例。

单线程 I/O

Node.js 的一个常见误解是它实际上仅限于一个 CPU,无法实现真正的并行处理。事实是 Node 经常使用多个控制线程。我们将在本章后面探讨这些概念。也许是因为 I/O 事件循环具有误导性,因为它确实是在单个线程、单个 CPU 上运行的。

本节的目标是介绍 I/O 循环的概念,为什么它对大多数 Web 应用后端来说是个好主意,以及它是如何克服多线程方法在并发中面临的挑战的。

注意

以下章节涵盖了更高级的 Node 并发主题,包括事件循环可能对我们造成的影响。虽然事件循环是一个新颖的想法,但它并不完美;针对任何给定的并发问题,每个解决方案都有其负面的权衡。

I/O 操作缓慢

某个 Web 应用基础设施中最慢的部分是网络 I/O 和存储 I/O。这些操作相对较快,主要归功于过去几年物理硬件的改进,但与在 CPU 上进行的软件任务相比,I/O 就像乌龟一样慢。从性能的角度来看,Web 应用之所以具有挑战性,是因为有很多 I/O 操作发生。我们不断地从数据库中读取和写入,并将数据传输到客户端浏览器。I/O 性能是 Web 应用领域的一个主要难题。

事件驱动 I/O 的基本突破在于它实际上利用了 I/O 操作缓慢的事实。例如,假设我们有 10 个 CPU 任务排队等待,但首先我们需要将某些内容写入磁盘。如果我们必须等待写入操作完成才能开始任务,那么这些任务将比实际需要的时间长得多。在事件驱动 I/O 中,我们发出写入命令,但不会等待低级操作系统的 I/O 写入操作完成。相反,我们在 I/O 进行的同时继续执行我们的 10 个 CPU 任务。

下面是一个 CPU 任务在单个线程中运行,而 I/O 任务在后台发生的示意图:

IO 很慢

不论给定任务需要执行哪种类型的 IO,它都不会阻止其他任务运行。这就是事件驱动 IO 架构能够在单线程中运行的原因。NodeJS 擅长这种类型的并发——并行执行大量的 IO 工作。然而,我们确实需要了解在操作系统级别发生的这些 IO 操作的状态。接下来,我们将看看 Node 是如何使用这些事件来解决特定文件描述符的状态的。

IO 事件

我们的应用程序代码需要某种方式知道 IO 操作已完成。这就是 IO 事件发挥作用的地方。例如,如果在我们 JavaScript 代码的某个地方启动了一个异步读取操作,操作系统将处理实际的文件读取。当读取完成,内容在内存中时,操作系统触发一个 IO 事件,表示 IO 操作已完成。

所有主要操作系统都以某种形式支持这些类型的 IO 事件。NodeJS 使用低级的C库来管理这些事件,并且它还考虑了各种平台差异。以下是 node IO 事件循环的示意图,将各种 IO 任务发送到操作系统并监听相应的 IO 事件:

IO 事件

如此图表所示,任何 IO 操作都在事件循环之外处理。事件循环本身只是一个包含要运行的 JavaScript 代码任务的队列。这些通常是 IO 相关的任务。正如我们所见,IO 事件的产物是一个被推入队列的回调函数。在 Node 中,JavaScript 不会等待 IO 完成。前端类似的情况是渲染引擎不会等待在 web worker 中完成较慢的计算任务。

大部分这些操作对我们来说是透明的,发生在用于执行 IO 的 NodeJS 模块内部。我们只需要关注回调函数。如果回调函数听起来不吸引人,那么我们刚刚花费了几个章节来解决与回调地狱相关的并发问题是个好事。这些思想在 Node 中主要适用;此外,我们将在下一章中讨论一些 Node 特有的同步技术。

多线程挑战

多年来,如果服务 Web 请求的主要方法一直是多线程,那么关于事件驱动 IO 的争议究竟是什么?此外,将所有 JavaScript 代码运行在单个 CPU 上几乎无法充分利用我们可能运行的具有多核的系统。即使我们在虚拟化环境中运行,我们也可能拥有并行化的虚拟硬件。简短的回答是,这两种方法都没有问题,因为它们都使用不同的策略来解决类似的问题。当我们向任一方向极端发展时,我们都需要重新思考我们的方法;例如,我们开始处理更多的 IO 或更多的计算。

在 Web 环境中,常见的情况是在执行 IO 操作上花费的时间比昂贵的 CPU 燃烧活动更多。当我们的应用程序的用户与之交互时,我们通常需要在网络上进行 API 调用,然后我们需要从文件系统读取或写入。然后,我们需要通过网络进行响应。除非这些请求在其计算中进行一些重数的计算,否则大部分时间都花在 IO 操作上。

那么,是什么使得 IO 密集型应用程序不适合多线程方法呢?好吧,如果我们想要生成新的线程或者使用线程池,将会涉及到大量的内存开销。想象一下,一个处理请求的线程就像是一个拥有自己内存块的过程。如果我们有很多传入的请求,那么我们可以并行处理它们。然而,我们仍然需要进行 IO。在没有事件循环的情况下进行 IO 同步要复杂一些,因为我们必须在等待 IO 操作完成的同时保持当前正在服务的请求的线程打开。

当我们开始处理非常大的 IO 量时,这种模型很难进行扩展。但是,对于普通应用程序来说,没有必要放弃它。同样,如果我们的应用程序转变为需要大量 CPU 功率的任何请求,单线程的事件循环可能就不够了。现在我们已经基本了解了什么使得 IO 事件循环成为 IO 密集型 Web 应用程序的一个强大概念,是时候看看事件循环的其他特性了。

连接越多,问题越多

在本节中,我们将探讨构建运行在互联网连接世界中的应用程序所面临的挑战。在这个动荡的环境中,意外的事情可能会发生;主要是,大量的用户使用导致大量的同时用户连接。在本节中,我们将探讨在部署到面向互联网的环境时需要关注的事物类型。然后我们将探讨 C10K 问题——10,000 个用户连接到具有有限硬件资源的应用程序。我们将以更深入地查看实际在 NodeJS 事件循环中运行的的事件处理器来结束本节。

部署到互联网

互联网是一个部署我们应用程序的有利可图且残酷的环境。我们的用户只需要一个浏览器和一个 URL。如果我们提供人们想要的东西,并且对这种东西的需求持续增长,我们很快就会面临连接挑战。这可能是一种逐渐增加的流行度,或者是一种突然的激增。在两种情况下,我们处理这些可扩展性挑战的责任。

由于我们的应用程序面向公众,我们很可能有专注于社交的功能,这些功能在计算上有所疏漏。另一方面,这通常意味着“有大量的连接,每个都在执行自己的 I/O 密集型操作。”这似乎非常适合 IO 事件循环,就像在 NodeJS 中找到的那样。

互联网实际上是测试我们应用程序灵活性的完美环境。如果有一个想要更多而付出更少的观众群体,你就能在这里找到。假设我们的应用程序是有用且受欢迎的,我们可以亲自看到我们如何应对数万个连接。我们可能也没有庞大的基础设施作为后盾,因此我们必须对我们的硬件资源负责。

NodeJS 的并发能否高效地应对这样的环境?当然可以,但要注意;这个观众群体对失败的请求或甚至次优性能零容忍。

C10K 问题

丹·凯格尔(Dan Kegel)首次思考 C10K 问题是在 1999 年(www.kegel.com/c10k.html)。因此,最初的构想已经接近 20 岁了;自那时以来,硬件已经取得了长足的进步。然而,将 10,000 个并发用户连接到应用程序的想法至今仍然相关。事实上,也许现代版本的问题应该是 C25K,因为对于大多数人认为负担得起的服务器硬件或虚拟硬件来说,我们可以挤出比 1999 年更多的性能。

问题范围扩大的第二个原因是互联网用户群体的增长。与 1999 年相比,连接的人和设备数量增加了一个数量级。C10K 的本质没有改变,那就是对于大量连接,不需要庞大的基础设施来支持它的高速性能。例如,这里有一个图表显示传入请求被映射到系统上的线程:

C10K 问题

随着连接用户数量的增加,请求的数量也在增加。我们很快就需要使用这种方法扩展我们的物理基础设施,因为它本质上依赖于并行处理请求。事件驱动的 IO 循环也并行处理请求,但使用不同的策略,如下所示:

C10K 问题

当我们的应用程序因为 CPU 数量无法处理连接数量时,这一点在这里有很大的不同。这是因为我们的 JavaScript 代码在一个线程、一个 CPU 上线性运行。然而,我们在 IO 循环中运行的 JavaScript 代码的类型起着重要作用,正如我们接下来将要看到的。

轻量级事件处理器

对于 NodeJS,我们的假设是,相对而言,我们不会花费太多时间执行 JavaScript 代码。换句话说,当一个请求到达 Node 应用程序时,处理该请求的 JavaScript 代码是短暂的。它确定所需的 I/O 操作,可能通过从文件系统中读取某些内容并退出,将控制权交还给 I/O 循环。

然而,并没有什么可以强制我们的 JavaScript 代码小而高效。有时,由于应用程序功能的改变或引入了将产品引向另一个方向的新功能,CPU 密集型代码是不可避免的。如果这种情况发生,我们必须采取必要的纠正设计步骤,因为一个失控的 JavaScript 处理器可能会破坏我们所有的连接。

让我们来看看 Node 事件循环、适合 JavaScript 任务类型以及可能引起问题的类型:

// Eat some CPU cycles...
// Taken from http://adambom.github.io/parallel.js/
function work(n) {
    var i = 0;
    while (++i < n * n) {}
    return i;
}

// There's no handlers in the queue, so this is
// executed immediately.
process.nextTick(() => {
    console.log('first handler');
});

// The previous handler was quick to exit, so this
// handler is executed without delay.
process.nextTick(() => {
    console.log('second handler');
});

// Starts immediately because the previous handler
// exited quickly. However, this handler executes
// some CPU intensive code.
process.nextTick(() => {
    console.log('hogging the CPU...');
    work(100000);
});

// This handler isn't run immediately, because the
// handler before this one takes a while to complete.
process.nextTick(() => {
    console.log('blocked handler');
});

process.nextTick() 函数是 Node I/O 事件循环的入口点。实际上,这个函数在核心 Node 模块中被广泛使用。每个事件循环迭代都被称为一个 tick。所以,通过调用这个函数并传递一个回调,我们实际上是在说——把这个函数添加到下一个循环迭代中要调用的函数队列中。

在给定的循环迭代中,可能会有数百甚至数千个回调需要处理。这并不重要,因为这些回调中没有任何一个是等待 I/O 的。因此,单个线程足以处理 Web 请求,除非我们开始执行消耗大量 CPU 周期的任务。前一个示例中的一个处理器就做了这件事。它需要几秒钟才能返回,而在这个过程中,事件循环会卡住。在 CPU 资源密集型处理器之后添加的处理程序不会运行。当有数千个连接的客户端正在等待响应时,后果是灾难性的。

注意

我们将在下一章深入探讨这个问题,当我们查看创建具有各自事件循环的 Node 进程集群时。

事件驱动的网络 I/O

NodeJS 在处理 HTTP 请求方面表现出色。这是因为给定的请求生命周期在客户端和服务器之间传输的时间很长。在这段时间里,Node 处理其他请求。在本节中,我们将探讨 Node 的 HTTP 网络功能,以及它们如何适应 I/O 事件循环。

我们将从基本 HTTP 请求开始,探讨它们如何成为许多 Node 模块和项目的基础。然后,我们将转向向客户端发送流式响应,而不是一次性发送大量数据。最后,我们将探讨 Node 服务器如何代理请求到其他服务。

处理 HTTP 请求

NodeJS 中的http模块负责处理创建和设置 HTTP 服务器时所有琐碎的细节。毫不奇怪,这个模块被许多创建 Web 服务器的 Node 项目广泛使用。它甚至有一个辅助函数,可以为我们创建服务器,并设置用于响应传入请求的回调函数。这些回调函数接收一个request参数和一个response参数。请求包含从客户端发送的信息,我们通常从这个对象中读取。响应包含发送回客户端的信息,我们通常写入这个对象。以下是一个将这些概念放入 IO 事件循环上下文的可视化:

处理 HTTP 请求

起初,客户端直接与事件循环通信可能看起来有些不合常理。实际上,这实际上是对真正发生的事情的一个很好的近似。requestresponse对象只是我们 JavaScript 代码中可访问的抽象。它们的存在是为了帮助我们读取和写入正确的套接字数据。这些抽象将正确数据传递给套接字或读取正确的套接字数据。在两种情况下,我们的代码都推迟到事件循环,在那里真正的客户端通信发生:

现在我们来看一些基本的 HTTP 服务器代码。

// We need the "http" module for HTTP-related
// code.
var http = require('http');

// Creates the server instance, and sets of the
// callback function that's called on every request
// event for us.
var server = http.createServer((req, res) => {

    // The response header is always going to be plain
    // text.
    res.setHeader('Content-Type', 'text/plain');

    // If the request URL is "hello" or "world", we
    // respond with some text immediately. Otherwise,
    // if the request URL is "/", we simulate a slow
    // response by using "setTimeout()" to finish the
    // request after 5 seconds.
    if (req.url === '/hello') {
        res.end('Hello');
    } else if (req.url === '/world') {
        res.end('World');
    } else {
        setTimeout(() => {
            res.end('Hello World');
        }, 5000);
    }
});

// Starts the server.
server.listen(8081);
console.log('listening at http://localhost:8081');

在这个例子中,我们向浏览器发送纯文本。我们对 URL 进行快速检查,并相应地调整内容。但是默认路径中有些有趣的地方,我们使用setTimeout()将响应延迟 5 秒。所以如果我们访问http://localhost/,页面会旋转 5 秒后才显示任何内容。这里的想法是展示事件循环的异步性。当这个请求等待发生某些事情时,所有其他请求都会立即得到服务。我们可以通过在另一个标签页中加载/hello URL 或/world URL 来测试这一点。

流式响应

在上一个例子中,我们通过一次调用就写完了整个 HTTP 响应内容。这通常是可行的,特别是在我们的情况下,因为我们只向连接的套接字写入了一小部分字符。对于某些应用程序,对给定请求的响应可能会比这大得多。例如,如果我们实现了一个 API 调用,并且客户端请求了一个实体集合,而每个实体都有几个属性呢?

当我们从请求处理器向客户端传输大量数据时,我们可能会遇到麻烦。即使我们不是在进行 CPU 密集型计算,我们仍然会消耗 CPU,并在将大量数据写入响应时阻塞其他请求处理器。以下是这个问题的说明:

流式响应

问题不一定在于响应这些大型响应,而在于当有很多这样的响应时。在本章的早期,我们讨论了建立和维护大量连接用户的问题,因为这是我们应用程序一个非常可能的情况。所以,返回相对大量数据的问题在于应用程序整体性能的下降。每个用户都会体验到非最佳性能,这绝对不是我们想要的。

我们可以使用流技术来解决这个问题。我们不必一次性写出整个响应,而是可以分块写出。当数据块被写入响应流时,事件循环可以自由处理队列中的请求。总的来说,我们可以避免任何一个请求处理程序从事件循环中占用比绝对必要更长的时间。让我们看看一个例子:

// We need the "http" module.
var http = require('http');

// Creates some sample data, an array of 
// numbers.
var array = new Array(1000)
    .fill(null)
    .map((v, i) => i);

// Creates the HTTP server, and the request
// callback function.
var server = http.createServer((req, res) => {
    var size = 25,
        i = 0;

    // This function is called when we need to
    // schedule a chunk of data to be written to
    // the response.
    function schedule() {

        // Here's the actual scheduling, 
        // "process.nextTick()" let's other handlers, 
        // if any, run while we're streaming our writes
        // to the response.
        process.nextTick(() => {
            let chunk = array.slice(i, i + size);

            // If there's a chunk of data to write, 
            // write it, then schedule the next round by
            // calling "schedule()". Otherwise, we can
            // "end()" the response.
            if (chunk.length) {
                res.write(chunk.toString() + '\n');
                i += size;
                schedule();
            } else {
                res.end();
            }   
        }); 
    }   

    // Kicks off the stream writing scheduler.
    schedule();
});

// Starts the server.
server.listen(8081);
console.log('listening at http://localhost:8081');

这个例子通过返回纯文本中的数字列表来响应用户请求。如果我们在这个浏览器中查看这个页面,我们实际上可以看到数字是如何分块的,因为它们由换行符分隔。这只是为了说明目的;在实践中,我们可能会将响应作为一个大列表使用。重要的是,我们的请求处理程序不再贪婪,因为通过使用流方法,我们与其他请求处理程序共享事件循环。

代理网络请求

我们的主要 NodeJS 网络服务器不需要满足每个请求的每一个方面。相反,我们的处理程序可以联系构成我们应用程序骨干的其他系统,并请求它们的数据。这是一种微服务的形式,这也是这个讨论范围之外的话题。让我们把这些服务视为帮助我们组合更大应用程序整体的独立部分。

在 Node 请求处理程序中,我们可以创建其他 HTTP 请求,这些请求与这些外部服务进行通信。这些请求使用与创建它们的处理程序相同的事件循环。例如,当服务响应数据时,它触发一个 IO 事件,并运行相应的 JavaScript 代码。以下插图显示了这种设置是如何工作的:

代理网络请求

让我们看看我们能否编写一个请求处理程序,它实际上是位于不同服务器上的其他服务的组合。我们首先实现一个用户服务,它允许我们检索特定用户信息。然后,我们将实现一个偏好服务,它允许我们获取特定用户设置的偏好。以下是用户服务代码:

var http = require('http');

// Our sample user data.
var users = [
    { name: 'User 1' },
    { name: 'User 2' },
    { name: 'User 3' },
    { name: 'User 4' }
];

var server = http.createServer((req, res) => {

    // We'll be returning JSON data.
    res.setHeader('Content-Type', 'application/json');

    var id = /\/(\d+)/.exec(req.url),
        user;

    // If a user is found from the ID in the URL, return
    // a JSON string of it. Otherwise, respond with a 404.
    if (id && (user = users[+id[1]])) {
        res.end(JSON.stringify(user));
    } else {
        res.statusCode = 404;
        res.statusReason = http.STATUS_CODES[404];
        res.end();
    }

});

server.listen(8082);
console.log('Users service at http://localhost:8082');

这非常直接。我们有一些示例用户数据存储在数组中,当请求到达时,我们尝试根据 ID(数组索引)找到特定的用户对象。然后,我们以 JSON 字符串的形式响应。偏好服务使用完全相同的方法。以下是代码:

注意

注意,每个服务器都在不同的端口上启动。如果您通过在本书中运行代码来跟进,此示例需要在命令行上启动三个网络服务器。如果支持,例如在 OS X 上,打开三个终端标签页(或打开三个终端窗口)可能最容易。

// Our sample preference data.
var preferences = [
    { spam: false },
    { spam: true },
    { spam: false },
    { spam: true }
];

var server = http.createServer((req, res) => {

    // We'll be returning JSON data.
    res.setHeader('Content-Type', 'application/json');

    var id = /\/(\d+)/.exec(req.url),
        preference;

    // If the ID in the URL finds a sample preference,
    // return the JSON string for it. Otherwise,
    // respond with a 404.
    if (id && (preference = preferences[+id[1]])) {
        res.end(JSON.stringify(preference));
    } else {
        res.statusCode = 404;
        res.statusMessage = http.STATUS_CODES[404];
        res.end();
    }
});

server.listen(8083);
console.log('Preference service: http://localhost:8083');

现在,我们可以编写我们的主要服务器,其中包含请求处理器,这些处理器会调用这些服务。以下是代码的示例:

var http = require('http');

var server = http.createServer((req, res) => {

    // Looks for a user ID in the URL.
    var id = /\/(\d+)/.exec(req.url);

    // If there's no ID in the URL, don't
    // even try handling the request.
    if (!id) {
        res.end();
        return;
    }

    // This promise is resolved when the call to
    // the "users" service responds with data. This
    // service is another server, running on port
    // 8082.
    var user = new Promise((resolve, reject) => {
        http.get({
            hostname: 'localhost',
            port: 8082,
            path: `/${id[1]}`
        }, (res) => {
            res.on('data', (data) => {
                resolve(JSON.parse(data.toString()));
            });
        });
    });

    // This promise is resolved when the call to
    // the "preference" service responds with data. This
    // service is just another web server, running
    // on port 8082.
    var preference = new Promise((resolve, reject) => {
        http.get({
            hostname: 'localhost',
            port: 8083,
            path: `/${id[1]}`
        }, (res) => {
            res.on('data', (data) => {
                resolve(JSON.parse(data.toString()));
            });
        });
    });

    // Once both the user and the preference services have
    // responded, we have all the data we need to render 
    // the page.
    Promise.all([ user, preference ]).then((results) => {
        let user = results[0],
            preference = results[1];

        res.end(`
            <p><strong>Name:</strong> ${user.name}</p>
            <p><strong>Spam:</strong> ${preference.spam}</p>
        `);
    });
});

server.listen(8081);
console.log('Listening at http://localhost:8081');

现在,我们需要确保所有三个服务都在运行——用户服务、偏好服务以及用户直接交互的主服务。它们都在不同的端口上运行,因为它们都在同一台机器上作为网络服务器运行。在实践中,这些服务可以在任何地方运行——这是它们吸引力的一部分。

事件驱动的文件输入/输出

现在我们对 NodeJS 中的网络输入/输出有了相当好的了解,是时候将我们的注意力转向文件系统输入/输出了。在本节之后,我们将看到文件和网络套接字在事件循环中是如何被同等对待的。Node 会为我们处理这些细微的差异,这意味着我们可以编写一致的代码。

首先,我们将查看从文件中读取,然后是向文件写入。我们将通过查看从文件到文件的流式传输来结束本节,其中在之间进行数据转换。

从文件读取

让我们从读取整个文件内容到内存中的简单示例开始。这将帮助我们了解如何进行异步文件输入/输出:

// We need the "fs" module to read files.
var fs = require('fs');
var path = require('path');

// The file path we're working with.
var filePath = path.join(__dirname, 'words');

// Starts the timer for reading our "words" file.
console.time('reading words');

// Reads the entire file into memory, then fires
// a callback with the data.
fs.readFile(filePath, (err, data) => {
    console.timeEnd('reading words');
    // → reading words: 5ms

    console.log('size',
        `${(data.length / 1024 / 1024).toFixed(2)}MB`);
    // →  size 2.38MB
});

在我们传递给 fs.readFile() 的回调函数中,我们可以访问包含文件内容的内存中的 Buffer 对象。当操作系统实际进行文件读取,并且缓冲区被填充结果时,IO 事件循环中的其他处理器会继续运行。这就像从网络套接字读取一样,也是为什么会有一个回调被添加到事件队列中,一旦数据被读取就会调用它。

以这种方式一次性读取文件的问题在于,这可能会在 Node 之外在操作系统级别产生影响。我们在这里用作示例的文件大小相当适中,但如果我们尝试从一个更大的文件中读取呢?如果有几个请求处理器尝试读取同一个文件呢?也许我们不应该一次性读取整个文件,而是每次只读取数据块?如果存在任何资源竞争,这将减轻竞争。让我们看看一种替代方法:

// Creates a promise that's resolved once all the 
// file chunks have been read into memory.
var contents = new Promise((resolve, reject) => {

    // Opens the "filePath" for reading. The file
    // descriptor, like a file identifier, is needed
    // when we call "fs.read()" later on.
    fs.open(filePath, 'r', (err, fd) => {

        // Set up some variables needed for reading
        // a file one chunk at a time. We need to know
        // how big the file is, that does in "size". The
        // "buffer" is where the chunks go as they're
        // read. And we have the "chunk" size, and the
        // number of "bytes" read so far.
        var size = fs.fstatSync(fd).size,
            buffer = new Buffer(size),
            chunk = 1024,
            read = 0;

        // We wrap this reading iteration in a named
        // function because it's recursive.
        function schedule() {

            // The reading of a chunk always happens in
            // the next tick of the IO loop. This gives
            // other queued handlers a chance to run while 
            // we're reading this file.
            // process.nextTick(() => {

                // Makes sure the last chunk fits evenly
                // into the buffer.
                if ((read + chunk) > size) {
                    chunk = size - read;
                }

                // Reads the chunk of data into the buffer,
                // and increments the "read" counter.
                fs.read(fd, buffer, read, chunk, read);
                read += chunk;

                // Check if there's still data to read. If
                // yes, "schedule()" the next "read()". If
                // no, resolve the promise with the "buffer".
                if (read < size) {
                    schedule();
                } else {
                    resolve(buffer);
                }
            });
        }

        // Kicks off the reading and scheduling process.
        schedule();
    });
});

// When the promise is resolved, show how many words
// were read into the buffer by splitting them by
// newlines.
contents.then((buffer) => {
    console.log('words read',
        buffer.toString().split('\n').length);
    // → words read 235887
});

这里,我们得到完全相同的结果,只是我们将单个 fs.readFile() 调用分解为几个更小的 fs.read()。我们还在这里使用一个承诺来使回调处理更加直接。

注意

你可能想知道为什么我们不使用循环来遍历块并发出fs.read()调用。相反,我们使用process.nextTick()来安排读取调用。如果我们遍历块,每个read()调用都会按顺序添加到事件队列中。因此,我们最终会有一系列连续的read()调用,没有任何其他处理程序被调用。这违背了拆分fs.readFile()的目的。相反,process.nextTick()允许在read()调用之间运行其他处理程序。

写入文件

将数据写入文件的工作方式与从文件中读取非常相似。实际上,写入稍微容易一些,因为我们不需要在内存中维护任何数据;我们只需担心将内存中的数据写入磁盘即可。让我们先看看一些使用一次调用将数据块写入文件的代码。这在反向读取整个文件时是等效的:

// We need the "fs" and the "path" modules for
// working with files.
var fs = require('fs');
var path = require('path');

// The two files we'll be working with.
var filePath1 = path.join(__dirname, 'output1'),
    filePath2 = path.join(__dirname, 'output2');

// The sample array we'll be writing to files.
var array = new Array(1000)
    .fill(null)
    .map((v, i) => i);

// Starts a timer for writing the entire array to
// the file in one shot.
console.time('output1');

// Performs the file write and stops the timer when
// it's complete.
fs.writeFile(filePath1, array.toString(), (err) => {
    console.timeEnd('output1');
});

看看,没什么难的。我们使用fs.writeFile()将我们数组的字符串表示写入文件。然而,这有可能在操作系统级别阻止其他事情发生;特别是如果我们一次写入大量数据。让我们尝试将写入操作分解成几个更小的调用,就像我们在之前的读取示例中所做的那样:

// Creates a promise that's resolved when all chunks
// have been written to file.
var written = new Promise((resolve, reject) => {

    // Opens the file for writing, and the callback
    // starts writing chunks.
    fs.open(filePath2, 'w', (err, fd) => {
        var chunk = 50,
            i = 0;

        // The recursive scheduler places the call
        // to perform the write into the IO event loop
        // queue.
        function schedule() {
            process.nextTick(() => {

                // The chunk of data from "array" to 
                // write.
                let slice = array.slice(i, i + chunk);

                // If there's a chunk to write, write it.
                // If not, close the file and resolve the
                // promise.
                if (slice.length) {
                    fs.write(fd, slice.toString(), i);
                    i += chunk;
                    schedule();
                } else {
                    fs.close(fd);
                    resolve();
                }
            });
        }

        // Kicks of the chunk/write scheduler.
        schedule();
    });
});

// When the promise is resolved, it means the file has been
// written.
written.then(() => {
    console.log('finished writing');
});

这与我们在分块读取中采取的方法相同。主要区别在于我们写入一个文件,并且涉及的组件更少。此外,承诺在没有值的情况下解决,这是可以接受的,因为调用者可以将此值视为null,并仍然知道文件已成功写入磁盘。在下一节中,我们将查看读取和写入文件的更简洁版本。

流式读取和写入

到目前为止,我们已经讨论了分块读取文件以及将数据分成块并逐个写入磁盘。其优势在于,在读取或写入数据时,我们将控制权交给了其他代码,可能是其他操作系统调用。优势在于,当我们处理大量数据时,一个硬件资源永远不会被读取或写入操作所垄断。

实际上,我们正在实现流式读取和写入。在本节中,我们将查看 NodeJS 为各种组件(包括文件)实现的流式接口。我们之前编写的用于流式读取和写入的代码在某些地方有点冗长。正如我们所知,我们不想在可以避免的地方使用样板并发代码。我们尤其不想在代码库中到处散布。让我们看看一种不同的方法来流式读取和写入文件:

// All the modules we need.
var fs = require('fs');
var path = require('path');
var stream = require('stream');

// Creates a simple upper-case transformation
// stream. Each chunk that's passed in is
// "pushed" to the next stream in upper-case.
var transform = new stream.Transform({
    transform: function(chunk) {
        this.push(chunk.toString().toUpperCase());
    }
});

// The file names we're using.
var inputFile = path.join(__dirname, 'words'),
    outputFile = path.join(__dirname, 'output');

// Creates an "input" stream that reads from
// "inputFile" and an "output" stream that writes
// to "outputFile".
var input = fs.createReadStream(inputFile),
    output = fs.createWriteStream(outputFile);

// Starts the IO by building the following 
// pipeline: input -> transform -> output.
input.pipe(transform);
transform.pipe(output);

我们基本上是将一个文件复制到另一个文件中,并在过程中对数据进行一些小的修改。幸运的是,NodeJS 的流式处理功能使得执行这种转换变得容易,无需编写大量的样板代码来读取输入然后再写入输出。几乎所有这些操作都在 Transform 类中被抽象化。以下是我们之前代码创建的管道的示例:

流式读取和写入

摘要

本章向您介绍了 NodeJS 中的并发。前提是 Node 应用程序将执行大量的 I/O 操作,并且相对于内存中发生的其他计算,I/O 操作较慢。Node 实现了一个 I/O 循环,这是一种机制,当给定的 I/O 资源准备好输入或输出时,它会通知我们的代码。

我们看到了这种模型的一些优缺点。另一种方法涉及在 CPU 级别依赖并行性,当存在大量慢速 I/O 操作时,这可能会带来挑战。相反,当存在大量 I/O 时,I/O 循环方法不会受到相同的影响,但当有昂贵的 CPU 任务要执行时,它会受到很大的影响。

我们在本章的剩余部分查看了一些网络和文件 I/O 示例。在下一章中,我们将继续探索 NodeJS,通过查看一些更高级的主题,其中一些可以帮助我们在应用程序计算成本增加时进行扩展。

第九章。高级 NodeJS 并发

在 第八章 中,使用 NodeJS 的事件驱动 IO,你学习了 NodeJS 应用程序中核心的并发机制——IO 事件循环。在本章中,我们将深入研究一些既补充事件循环又与事件循环相反的更高级的主题。

我们将从讨论在 Node 中使用 Co 库实现协程开始。接下来,我们将探讨创建子进程以及与这些进程通信。之后,我们将深入研究 Node 内置的创建进程集群的能力,每个集群都有自己的事件循环。我们将以查看创建大规模 Node 服务器集群的集群结束本章。

使用 Co 的协程

我们已经在 第四章 中看到了一种在前端使用生成器实现协程的方法,使用生成器的懒加载评估。在本节中,我们将使用 Co 库 (github.com/tj/co) 来实现协程。这个库也依赖于生成器和承诺。

我们将首先概述 Co 的一般原理,然后编写一些使用承诺等待异步值的代码。接下来,我们将探讨如何将已解析的值从承诺传递到我们的协程函数中,异步依赖关系,以及创建协程实用函数。

生成承诺

在其核心,Co 库使用一个 co() 函数来创建协程。实际上,其基本用法与我们在本书早期创建的协程函数看起来很相似。下面是它的样子:

co(function* () {
    // TODO: co-routine amazeballs.
});

Co 库和我们的早期协程实现之间的另一个相似之处是,值通过 yield 语句传递。然而,与调用返回的函数来传递值不同,这个协程使用承诺来传递值。效果是相同的——异步值被传递到同步代码中,如图所示:

生成承诺

实际上,异步值来自承诺。已解析的值进入协程。我们将在稍后深入了解其工作原理的机制。即使我们没有产生承诺,比如说我们产生了一个字符串,Co 库也会为我们将其包装成一个承诺。但是,这样做就违背了在同步代码中使用异步值的目的。

注意

当我们发现像 Co 这样的工具,它封装了混乱的同步语义时,这对我们程序员来说是多么有价值。我们在协程内部的代码是同步的且可维护的。

等待值

co() 函数创建的协程工作方式与 ES7 异步函数非常相似。async 关键字将一个函数标记为异步——意味着它在其内部使用异步值。await 关键字与承诺一起使用,暂停函数的执行,直到值解析。如果这感觉与生成器所做的工作非常相似,那是因为它确实就是生成器所做的工作。以下是 ES7 语法的样子:

// This is the ES7 syntax, where the function is
// marked as "async". Then, the "await" calls
// pause execution till their operands resolve.
(async function() {
    var result;
    result = await Promise.resolve('hello');
    console.log('async result', `"${result}"`);
    // → async result "hello"

    result = await Promise.resolve('world');
    console.log('async result', `"${result}"`);
    // → async result "world"
}());

在这个例子中,承诺立即解析,所以实际上没有必要暂停执行。然而,即使承诺解析一个需要几秒钟的网络请求,它也会等待。我们将在下一节更深入地探讨解析承诺。鉴于这是 ES7 语法,如果我们可以今天使用相同的方法那就太好了。以下是使用 Co 实现相同方法的示例:

// We need the "co()" function.
var co = require('co');

// The differences between the ES7 and "co()" are
// subtle, the overall structure is the same. The
// function is a generator, and we pause execution
// by yielding generators.
co(function*() {
    var result;
    result = yield Promise.resolve('hello');
    console.log('co result', `"${result}"`);
    // → co result "hello"

    result = yield Promise.resolve('world');
    console.log('co result', `"${result}"`);
    // → co result "world"
});

毫不奇怪,Co 库正朝着 ES7 的方向发展;Co 的作者们做得很好。

解析值

在给定的 Co 协程中至少有两个地方会解析承诺。首先,有一个或多个从生成器函数中产生的承诺,我们将将其传递给 co()。如果没有在这个函数中产生任何承诺,使用 Co 就没有太多意义。调用 co() 时的返回值是另一个承诺,这相当酷,因为它意味着协程可以作为其他协程的依赖项。我们稍后将更深入地探讨这个想法。现在,让我们看看如何解析承诺以及如何实现。以下是协程承诺解析顺序的说明:

解析值

承诺的解析顺序与它们命名的顺序相同。例如,第一个承诺会导致协程中的代码暂停执行,直到其值解析。然后,在等待第二个承诺时再次暂停执行。从 co() 返回的最后一个承诺使用生成器函数的返回值解析。现在让我们看看一些代码:

var co = require('co');

co(function* () {

    // The promise that's yielded here isn't resolved
    // till 1 second later. That's when the yield statement
    // returns the resolved value.
    var first = yield new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve([ 'First1', 'First2', 'First3' ]);
        }, 1000);
    });

    // Same idea here, except we're waiting 2 seconds
    // before the "second" variable gets it's value.
    var second = yield new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve([ 'Second1', 'Second2', 'Second3' ]);
        }, 2000);
    });

    // Both "first" and "second" are resolved at this
    // point, so we can use both to map a new array.
    return first.map((v, i) => [ v, second[i] ]);

}).then((value) => {
    console.log('zipped', value);
    // → 
    // [ 
    //   [ 'First1', 'Second1' ],
    //   [ 'First2', 'Second2' ],
    //   [ 'First3', 'Second3' ] 
    // ]
});

如我们所见,生成器的返回值最终成为解析的承诺值。回想一下,从生成器返回将返回与使用 valuedone 属性产生相同的对象。Co 知道使用 value 属性解析承诺。

异步依赖

当协程中的某个操作依赖于稍后异步值时,使用 Co 创建的协程表现得非常好。否则,原本会是回调和状态混乱的一团糟,而现在只需将赋值放置在正确的顺序即可。依赖的操作只有在值解析后才会被调用。以下是这个想法的说明:

异步依赖

现在让我们编写一些代码,其中包含两个异步操作,第二个操作依赖于第一个操作的结果。即使使用承诺,这也可能很棘手:

var co = require('co');

// A simple user collection.
var users = [
    { name: 'User1' },
    { name: 'User2' },
    { name: 'User3' },
    { name: 'User4' }
];

co(function* () {

    // The "userID" value is asynchronous, and execution
    // pause at this yield statement till the promise
    // resolves.
    var userID = yield new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(1);
        }, 1000);
    });

    // At this point, we have a "userID" value. This
    // nested co-routine will look up the user based
    // on this ID. We nest coroutines like this because
    // "co()" returns a promise.
    var user = yield co(function* (id) {
        let user = yield new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve(users[id]);
            }, 1000);
        });

        // The "co()" promise is resolved with the
        // "user" value.
        return user;
    }, userID);

    console.log(user);
    // → { name: 'User2' }
});

在这个例子中,我们使用了嵌套协程,但它可以是任何需要参数并返回 Promise 的函数类型。这个例子如果不是其他的话,至少可以突出 Promise 在并发环境中的多功能性。

包装协程

我们将要查看的最后一个Co示例使用wrap()实用工具将一个普通的协程函数包装成一个可重复调用的函数。正如其名所示,协程只是被包装在一个函数中。当我们向协程传递参数时,这特别有用。让我们看看我们构建的代码示例的修改版:

var co = require('co');

// A simple user collection.
var users = [
    { name: 'User1' },
    { name: 'User2' },
    { name: 'User3' },
    { name: 'User4' }
];

// The "getUser()" function will create a new
// co-routine whenever it's called, forwarding
// any arguments as well.
var getUser = co.wrap(function* (id) {
    let user = yield new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(users[id]);
        }, 1000);
    });

    // The "co()" promise is resolved with the
    // "user" value.
    return user;
});

co(function* () {

    // The "userID" value is asynchronous, and execution
    // pause at this yield statement till the promise
    // resolves.
    var userID = yield new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(1);
        }, 1000);
    });

    // Instead of a nested co-routine, we have a function
    // that can now be used elsewhere.
    var user = yield getUser(userID);

    console.log(user);
    // → { name: 'User2' }
});

因此,我们不是使用嵌套协程,而是使用co.wrap()创建一个可重复使用的协程函数。也就是说,每次调用时都会创建一个新的协程,传递给它函数获取的所有参数。实际上,这并没有什么更多,但收益是明显的,值得的。我们不再有一个嵌套的协程函数,而是一个可能被组件间共享的东西。

子进程

我们知道 NodeJS 使用事件驱动的 IO 循环作为其主要并发机制。这是基于我们的应用程序进行大量 IO 操作和很少的 CPU 密集型工作的假设。这可能适用于我们代码中大多数处理器的多数情况。然而,总会有一些特殊情况需要比通常更多的 CPU 时间。

在本节中,我们将讨论处理器如何阻塞 IO 循环,以及为什么只需要一个不良处理器就足以破坏其他所有人的体验。然后,我们将探讨通过创建新的 Node 子进程来绕过这种限制的方法。我们还将探讨如何启动其他非 Node 进程以获取我们所需的数据。

阻塞事件循环

在第八章,“使用 NodeJS 的事件驱动 IO”,我们看到了一个示例,演示了一个处理器如何在执行昂贵的 CPU 操作时阻塞整个 IO 事件循环。我们在这里重申这一点,以突出问题的全貌。我们阻塞的不仅仅是单个处理器,而是所有处理器。这可能是数百个,也可能是数千个,具体取决于应用程序及其使用方式。

由于我们在硬件级别不是并行处理请求,这与多线程方法的情况不同——只需要一个昂贵的处理器就可以阻塞所有处理器。如果有一个请求能够触发这个昂贵的处理器运行,那么我们很可能会接收到几个这样的昂贵请求,使我们的应用程序陷入停滞。让我们看看一个阻塞其后所有其他处理器的处理器:

// Eat some CPU cycles...
// Taken from http://adambom.github.io/parallel.js/
function work(n) {
    var i = 0;
    while (++i < n * n) {}
    return i;
}

// Adds some functions to the event loop queue.
process.nextTick(() => {
    var promises = [];

    // Creates 500 promises in the "promises"
    // array. They're each resolved after 1 second.
    for (let i = 0; i < 500; i++) {
        promises.push(new Promise((resolve) => {
            setTimeout(resolve, 1000);
        }));
    }

    // When they're all resolved, log that
    // we're done handling them.
    Promise.all(promises).then(() => {
        console.log('handled requests');
    });
});

// This takes a lot longer than the 1 second
// it takes to resolve all the promises that
// get added to the queue. So this handler blocks
// 500 user requests till it finishes..
process.nextTick(() => {
    console.log('hogging the CPU...');
    work(100000);
});

第一次调用 process.nextTick() 通过安排函数在一秒后运行来模拟实际客户端请求。所有这些都导致一个单一的承诺得到解决;并记录了所有请求都已处理的事实。下一次调用 process.nextTick() 是昂贵的,并且完全阻塞了这 500 个请求。这绝对不利于我们的应用程序。在 NodeJS 内运行 CPU 密集型代码的唯一方法就是跳出单进程方法。这个话题将在下一部分进行讨论。

进程创建

我们的应用程序已经到了一个点,没有其他办法可以绕过。我们有一些相对昂贵的请求需要处理。我们需要在硬件层利用并行性。在 Node 中,这意味着只有一件事——在主进程之外创建子进程来处理 CPU 密集型工作,以便正常请求可以不间断地进行。以下是这种策略的示意图:

进程创建

现在,让我们编写一些使用 child_process.fork() 函数来生成新的 Node 进程的代码,当我们需要处理一个 CPU 密集型请求时。首先,主模块:

// We need the "child_process" to fork new
// node processes.
var child_process = require('child_process');

// Forks our worker process.
var child = child_process.fork(`${__dirname}/child`);

// This event is emitted when the child process
// responds with data.
child.on('message', (message) => {

    // Displays the result, and kills the child
    // process since we're done with it.
    console.log('work result', message);
    child.kill();
});

// Sends a message to the child process. We're
// sending a number on this end, and the
// "child_process" ensures that it arrives as a
// number on the other end.
child.send(100000);
console.log('work sent...');

// Since the expensive computation is happening in
// another process, normal requests flow through
// the event loop like normal.
process.nextTick(() => {
    console.log('look ma, no blocking!');
});

我们现在面临的最大开销实际上是生成新进程的开销,与我们需要执行的实际工作相比微不足道。我们可以清楚地看到,主 I/O 循环没有被阻塞,因为主进程没有占用 CPU。另一方面,子进程正在猛烈地敲击 CPU,但这没关系,因为它可能发生在不同的核心上。以下是我们的子进程代码的样子:

// Eat some CPU cycles...
// Taken from http://adambom.github.io/parallel.js/
function work(n) {
    var i = 0;
    while (++i < n * n) {}
    return i;
}

// The "message" event is emitted when the parent
// process sends a message. We then respond with
// the result of performing expensive CPU operations.
process.on('message', (message) => {
    process.send(work(message));
});

生成外部进程

有时候,我们的 Node 应用程序需要与其他程序通信,而这些程序不是 Node 进程。这些可能是我们用不同平台或基本系统命令编写的其他应用程序。我们可以生成这些类型的进程并与它们通信,但它们的工作方式与创建另一个节点进程不同。以下是这种差异的示意图:

生成外部进程

如果我们愿意,可以使用 spawn() 创建一个子 Node 进程,但这在某些情况下会让我们处于不利地位。例如,我们得不到 fork() 自动为我们设置的消息传递基础设施。然而,最佳通信路径取决于我们想要实现的目标,而且大多数时候,我们实际上并不需要消息传递。

让我们看看一些生成进程并读取该进程输出的代码:

// Our required modules...
var child_process = require('child_process');
var os = require('os');

// Spawns our child process - the "ls" system
// command. The command line flags are passed
// as an array.
var child = child_process.spawn('ls', [
    '-lha',
    __dirname
]);

// Our output accumulator is an empty string
// initially.
var output = '';

// Adds output as it arrives from process.
child.stdout.on('data', (data) => {
    output += data;
});

// We're done getting output from the child
// process - so log the output and kill it.
child.stdout.on('end', () => {
    output = output.split(os.EOL);
    console.log(output.slice(1, output.length - 2));
    child.kill();
});

注意

我们生成的 ls 命令在 Windows 系统上不存在。在这里,我没有其他安慰性的智慧之词——这只是一个事实。

进程间通信

在我们刚才看到的例子中,子进程被生成,我们的主进程收集了输出,杀死了进程;但是,当我们编写服务器和其他类型的长期程序时,我们会怎么做?在这种情况下,我们可能不想不断地生成和杀死子进程。相反,可能更好的是让进程与主程序一起保持活跃,并继续向其发送消息,就像这里所展示的:

进程间通信

即使工作者正在同步处理请求,它仍然对我们的主应用程序有好处,因为它不会阻止它服务请求。例如,不需要 CPU 进行繁重操作的任务可以继续提供快速响应。现在让我们看看一个代码示例:

var child_process = require('child_process');

// Forks our "worker" process and creates a "resolvers"
// object to store our promise resolvers.
var worker = child_process.fork(`${__dirname}/worker`),
    resolvers = {};

// When the worker responds with a message, pass
// the message output to the appropriate resolver.
worker.on('message', (message) => {
    resolversmessage.id;
    delete resolvers[message.id];  
});

// IDs are used to map responses from the worker process
// to the promise resolver functions.
function* genID() {
    var id = 0;

    while (true) {
        yield id++;
    }
}

var id = genID();

// This function sends the given "input" to the worker,
// and returns a promise. The promise is resolved with
// the return value of the worker.
function send(input) {
    return new Promise((resolve, reject) => {
        var messageID = id.next().value;

        // Store the resolver function in the "resolvers"
        // map.
        resolvers[messageID] = resolve;

        // Sends the "messageID" and the "input" to the
        // worker.
        worker.send({
            id: messageID,
            input: input
        });
    });
}

var array;

// Builds an array of numbers to send to the worker
// individually for processing.
array = new Array(100)
    .fill(null)
    .map((v, i) => (i + 1) * 100);

// Sends each number in "array" to the worker process
// as a message. When each promise is resolved, we can
// reduce the results.
var first = Promise.all(array.map(send)).then((results) => {
    console.log('first result', 
        results.reduce((r, v) => r + v));
    // → first result 3383500000
});

// Creates a smaller array, with smaller numbers - it 
// should take less time to process than the previous 
// array.
array = new Array(50)
    .fill(null)
    .map((v, i) => (i + 1) * 10);

// Process the second array, log the reduced result.
var second = Promise.all(array.map(send))
    .then((results) => {
        console.log('second result',
            results.reduce((r, v) => r + v));
        // → second result 4292500
});

// When both arrays have finished being processed, we need
// to kill the worker in order to exit our program.
Promise.all([ first, second ]).then(() => {
    worker.kill();
});

现在让我们看看从主模块分叉的worker模块:

// Eat some CPU cycles...
// Taken from http://adambom.github.io/parallel.js/
function work(n) {
    var i = 0;
    while (++i < n * n) {}
    return i;
}

// Respond to the main process with the result of
// calling "work()" and the message ID.
process.on('message', (message) => {
    process.send({
        id: message.id,
        output: work(message.input)
    });
});

我们创建的数组中的每个数字都会传递给执行繁重工作的工作者进程。结果会传回主进程,并用于解决一个承诺。这种技术与我们用第七章中的网络工作者采取的承诺方法非常相似,抽象并发

我们在这里试图计算两个结果——一个用于first数组,另一个用于second。第一个数组比第二个数组有更多的数组项,数字也更大。这意味着这将需要更长的时间来计算,事实上也是如此。但是,如果我们运行这段代码,我们不会在第一个完成之前看到第二个数组的输出。

这是因为尽管第二个任务所需的 CPU 时间更少,但它仍然因为发送给工作者的消息顺序被保留而被阻塞。换句话说,在开始处理第二个数组之前,必须先处理来自第一个数组的所有 100 条消息。乍一看,这似乎是个坏消息,因为它实际上并没有为我们解决问题。好吧,这并不完全正确。

被阻塞的只有到达工作者进程的队列消息。因为工作者正忙于 CPU,所以它不能像消息到达时那样立即处理它们。然而,这个工作者的目的是从需要它的网络请求处理器中移除繁重的处理。并不是每个请求处理器都有这种类型的繁重负载,你知道吗?它们可以继续正常运行,因为没有在进程中占用 CPU 的资源。

然而,随着我们的应用程序因为添加的功能以及它们与其他功能交互的方式而变得更大、更复杂,我们将需要一个更好的方法来处理昂贵的请求处理器,因为我们将有更多的处理器。这就是我们将在下一节中要讨论的内容。

进程集群

在上一节中,我们介绍了 NodeJS 中的子进程创建。当请求处理器开始消耗越来越多的 CPU 时,这是 Web 应用程序所必需的措施,因为这种方式可能会阻塞系统中的其他所有处理器。在本节中,我们将在此基础上进行扩展,但我们将维护一个通用进程池,它能够处理任何请求。

我们将首先重申手动管理这些帮助我们处理 Node 中并发场景的流程所面临的挑战。然后,我们将探讨 Node 的内置进程集群功能。

进程管理的挑战

在我们的应用程序中手动编排进程的明显问题是,并发代码就在那里,公之于众,与我们的其他应用程序代码交织在一起。实际上,我们在本书早期实现 Web Workers 时就遇到了完全相同的问题。如果没有封装同步和一般的工作者管理,我们的代码主要由并发样板代码组成。一旦发生这种情况,就很难将并发机制与使我们的产品独特的功能代码区分开来。

使用 Web Workers 的一个解决方案是创建一个工作进程池并在其后隐藏一个统一的 API。这样,需要并行执行操作的功能代码可以这样做,而不会在我们的编辑器中散布并发同步语义。

结果表明,NodeJS 解决了利用大多数系统上可用的硬件并行性的问题,这与我们使用 Web Workers 所做的是类似的。接下来,我们将深入了解这是如何工作的。

抽象进程池

我们可以使用 child_process 模块手动分叉 Node 进程以实现真正的并行性。这在进行可能阻塞主进程的 CPU 密集型工作时非常重要,从而阻塞主 IO 事件循环,该循环处理传入的请求。我们可以将并行级别提升到仅单个工作进程之上,但这将需要我们进行大量的手动同步逻辑。

cluster 模块需要一点设置代码,但工作进程和主进程之间的实际通信编排对我们的代码来说是完全透明的。换句话说,它看起来我们只是在运行一个单独的 Node 进程来处理我们的传入 Web 请求,但事实上,有几个克隆进程来处理它们。由 cluster 模块负责将这些请求分配给工作节点,默认情况下,它使用轮询方法,这对于大多数情况来说已经足够好了。

在 Windows 上,默认不是轮询方式。我们可以手动更改我们想要使用的方法,但轮询方法使事情变得简单且平衡。唯一的挑战是我们有请求处理器比大多数处理器运行成本高得多时。那时,我们可能会将请求分配给过载的工作进程。这只是在调试此模块时需要注意的事情。

这是一张显示工作节点进程相对于主节点进程的视觉图:

抽象进程池

在集群场景中,主进程有两个职责。首先,它需要与工作进程建立通信通道。其次,它需要接受传入的连接并将它们分配给工作进程。这实际上很难绘制,所以没有在图中表示。在我进一步解释之前,让我们看看一些代码:

// The modules we need...
var http = require('http');
var cluster = require('cluster');
var os = require('os');

// Eat some CPU cycles...
// Taken from http://adambom.github.io/parallel.js/
function work(n) {
    var i = 0;
    while (++i < n * n) {}
    return i;
}

// Check which type of process this is. It's either
// a master or a worker.
if (cluster.isMaster) {

    // The level of parallelism that goes into
    // "workers".
    var workers = os.cpus().length;

    // Forks our worker processes.
    for (let i = 0; i < workers; i++) {
        cluster.fork();
    }

    console.log('listening at http://localhost:8081');
    console.log(`worker processes: ${workers}`);

// If this process isn't the master, then it's
// a worker. So we create the same HTTP server as
// every other worker.
} else {
    http.createServer((req, res) => {
        res.setHeader('Content-Type', 'text/plain');
        res.end(`worker ${cluster.worker.id}: ${work(100000)}`);
    }).listen(8081);
}

这种并行化我们的请求处理器的做法真正令人愉快的是,并发代码并不显眼。总共有大约 10 行。一眼望去,我们就可以轻松地看到这段代码做了什么。如果我们想看到这个应用程序的实际运行情况,我们可以打开几个浏览器窗口并将它们同时指向服务器。由于请求处理器在 CPU 周期上成本较高,我们应该能够看到每个页面都响应了计算出的值以及计算它的工作进程 ID。如果我们没有分叉这些工作进程,那么我们可能还在等待每个浏览器标签加载。

唯一有点棘手的部分是我们实际创建 HTTP 服务器的那部分。因为每个工作进程都会运行相同的代码,所以在同一台计算机上使用相同的宿主和端口——这怎么可能呢?好吧,这实际上并不是正在发生的事情。net 模块,http 模块使用的低级网络库,实际上是集群感知的。这意味着当我们要求 net 模块监听一个套接字以接收传入的请求时,它会首先检查它是否是工作节点。如果是,那么它实际上会共享主进程使用的相同的套接字句柄。这很酷。有很多复杂的后勤工作需要将请求分配给工作进程并实际传递请求,所有这些都被 cluster 模块为我们处理了。

服务器集群

通过启用通过进程管理实现并行性来扩展运行我们的 NodeJS 应用程序的单台机器是一回事。这是一个充分利用我们的物理硬件或虚拟硬件的好方法——它们都花钱。然而,仅扩展一台机器存在固有的局限性——它只能走这么远。在我们扩展问题的某个维度达到某个阈值之前,我们会遇到障碍。在这之前,我们需要考虑将 Node 应用程序扩展到多台机器。

在本节中,我们将介绍将我们的 Web 请求代理到其他机器而不是在它们到达的机器上处理它们的概念。然后,我们将探讨实现微服务以及它们如何帮助构建合理的应用程序架构。最后,我们将实现一些针对我们应用程序定制的负载均衡代码,以及它是如何处理请求的。

代理请求

NodeJS 中的请求代理正是其名称所暗示的。请求到达服务器,在那里它被 Node 进程处理。然而,请求并不是在这里得到满足——它是代理到另一台机器。所以问题是,为什么要使用代理呢?为什么不直接去响应我们请求的目标机器呢?

这个想法的问题在于,Node 应用程序通常响应来自浏览器的 HTTP 请求。这意味着我们通常需要一个单一的入口点进入后端。另一方面,我们并不一定希望这个单一的入口点是一个单独的 Node 服务器。当我们的应用程序变得更大时,这会变得有点限制性。相反,我们希望有扩展我们的应用程序或水平扩展它们的能力,就像他们说的那样。代理服务器消除了地理限制;我们的应用程序的不同部分可以部署在世界上的不同部分,同一个数据中心的不同部分,甚至作为不同的虚拟机。关键是,我们有权改变应用程序组件的存放位置以及它们的配置,而不会影响应用程序的其他部分。

通过代理分发 Web 请求的另一个有趣方面是,我们实际上可以编写我们的代理处理程序来修改请求和响应。因此,虽然我们的代理所依赖的各个服务可以实施我们应用程序的一个特定方面,但代理可以实施适用于每个请求的通用部分。以下是代理服务器和实际满足每个请求的 API 端点的可视化:

代理请求

促进微服务

根据我们构建的应用程序类型,我们的 API 可以是一个单体服务,也可以由几个微服务组成。一方面,单体 API 对于没有大量功能和数据的较小应用程序来说,维护起来通常更容易。另一方面,大型应用程序的 API 往往会变得极其复杂,以至于难以维护,因为有许多区域都相互交织在一起。如果我们将它们拆分成微服务,那么将它们部署到适合它们需求的具体环境中会容易得多,并且可以有一个专门的团队专注于一个运行良好的服务。

注意

微服务架构是一个非常大的主题,显然超出了这本书的范围。这里的重点是微服务启用——机制比设计更重要。

我们将使用 node-http-proxy (github.com/nodejitsu/node-http-proxy)模块来实现我们的代理服务器。这不是 Node 的核心模块,因此我们的应用程序需要将其作为npm依赖项包含在内。让我们看看一个基本的示例,该示例将请求代理到适当的服务:

注意

此示例启动了三个网络服务器,每个服务器在不同的端口上运行。

// The modules we need...
var http = require('http'),
    httpProxy = require('http-proxy');

// The "proxy" server is how we send
// requests to other hosts.
var proxy = httpProxy.createProxyServer();

http.createServer((req, res) => {

    // If the request is for the site root, we
    // return some HTML with some links'.
    if (req.url === '/') {
        res.setHeader('Content-Type', 'text/html');
        res.end(`
            <html>
                <body>
                    <p><a href="hello">Hello</a></p>
                    <p><a href="world">World</a></p>
                </body>
            </html>
        `);

    // If the URL is "hello" or "world", we proxy
    // the request to the appropriate micro-service
    // using "proxy.web()".
    } else if (req.url === '/hello') {
        proxy.web(req, res, {
            target: 'http://localhost:8082'
        });
    } else if (req.url === '/world') {
        proxy.web(req, res, {
            target: 'http://localhost:8083'
        });
    } else {
        res.statusCode = 404;
        res.end();
    }
}).listen(8081);
console.log('listening at http://localhost:8081');

这两个服务 hello 和 world 实际上并没有列在这里,因为它们对任何请求都只返回一行纯文本。它们分别监听端口80828083http-proxy模块使我们能够使用最少的逻辑简单地将请求转发到适当的服务。

信息负载均衡

在本章的早期,我们探讨了进程聚类。这是我们在其中使用cluster模块创建一个进程池的地方,每个进程都能够处理来自客户端的请求。在这种情况下,主进程充当代理,默认情况下以轮询的方式将请求分配给工作进程。我们可以使用http-proxy模块做类似的事情,但采用比轮询更不简单的方法。

例如,假设我们运行了相同微服务的两个实例。其中一个服务可能会比另一个更繁忙,这会导致服务失去平衡,因为繁忙的节点将继续接收请求,即使它不能立即处理它们。等到服务可以处理请求时再保留请求是有意义的。首先,我们将实现一个随机花费一段时间才能完成的服务:

var http = require('http');

// Get the port as a command line argument,
// so we can run multiple instances of the
// service.
var port = process.argv[2];

// Eat some CPU cycles...
// Taken from http://adambom.github.io/parallel.js/
function work() {
    var i = 0,
        min = 10000,
        max = 100000,
        n = Math.floor(Math.random() * (max - min)) + min;
    while (++i < n * n) {}
    return i;
}

// Responds with plain text, after blocking
// the CPU for a random interval.
http.createServer((req, res) => {
    res.setHeader('Content-Type', 'text/plain');
    res.end(work().toString());
}).listen(port);

console.log(`listening at http://localhost:${port}`); 

现在我们可以启动这些进程的两个实例,监听不同的端口。在实践中,这些进程将在不同的机器上运行,但在此阶段我们只是测试这个想法。现在我们将实现需要确定给定请求将发送到哪个服务工作者的代理服务器:

var http = require('http'),
    httpProxy = require('http-proxy');

var proxy = httpProxy.createProxyServer();

// These are the service targets. They have a "host",
// and a "busy" property. Initially they're
// not busy because we haven't sent any work.
var targets = [
    {
        host: 'http://localhost:8082',
        busy: false
    }
    {
        host: 'http://localhost:8083',
        busy: false
    }
];

// Every request gets queued here, in case all
// our targets are busy.
var queue = [];

// Process the request queue, by proxying requests
// to targets that aren't busy.
function processQueue() {

    // Iterates over the queue of messages.
    for (let i = 0; i < queue.length; i++) {

        // Iterates over the targets.
        for (let target of targets) {

            // If the target is busy, skip it.
            if (target.busy) {
                continue;
            }

            // Marks the target as busy - from this
            // point forward, the target won't accept
            // any requests untill it's unmarked.
            target.busy = true;

            // Gets the current item out of the queue.
            let item = queue.splice(i, 1)[0];

            // Mark the response, so we know which service
            // worker the request went to when it comes
            // back.
            item.res.setHeader('X-Target', i);

            // Sends the proxy request and exits the
            // loop.
            proxy.web(item.req, item.res, {
                target: target.host
            });

            break;
        }
    }
}

// Emitted by the http-proxy module when a response
// arrives from a service worker.
proxy.on('proxyRes', function(proxyRes, req, res) {

    // This is the value we set earlier, the index
    // of the "targets" array.
    var target = res.getHeader('X-Target');

    // We use this index to unmark it. Now it'll
    // except new proxy requests.
    targets[target].busy = false;

    // The client doesn't need this internal
    // information, so remove it.
    res.removeHeader('X-Target');

    // Since a service worker just became available,
    // process the queue again, in case there's pending
    // requests.
    processQueue();
});

http.createServer((req, res) => {

    // All incoming requests are pushed onto the queue.
    queue.push({
        req: req,
        res: res
    });

    // Reprocess the queue, leaving the request there
    // if all the service workers are busy.
    processQueue();
}).listen(8081);

console.log('listening at http://localhost:8081');

关于这种代理工作方式的关键点是,只有当服务不忙于处理请求时,才会将请求代理到服务。这是信息部分——代理知道服务器何时可用,因为它会响应它正忙于处理的最后一个请求。当我们知道哪些服务器正在忙碌时,我们就知道不要让它们过载更多的工作。

摘要

在本章中,我们探讨了 NodeJS 中事件循环作为并发机制之外的内容。我们首先使用Co库实现协程。从那里,我们学习了启动新进程,包括在 Node 进程之间 fork 和在其他非 Node 进程中 spawn 之间的区别。然后,我们探讨了使用cluster模块管理并发的另一种方法,该方法使并行处理 Web 请求尽可能透明。最后,我们通过查看使用node-http-proxy模块在机器级别并行化我们的 Web 请求来结束本章。

这就结束了 JavaScript 并发主题。我们在浏览器和 Node 中覆盖了很多内容。但是,这些想法和组件是如何组合在一起形成一个并发应用的?在这本书的最后一章,我们将探讨一个并发应用的实现过程。

第十章. 构建并发应用程序

现在我们已经涵盖了 JavaScript 在并发方面提供的主要领域。我们看到了浏览器以及 JavaScript 解释器如何适应这个环境。我们研究了帮助编写并发代码的少量语言机制,并学习了如何在后端编写并发 JavaScript。在本章中,我们将通过构建一个简单的聊天应用程序来尝试将这些内容综合起来。

值得注意的是,这并不是对前面章节中涵盖的各个主题的基本重复,这没有任何实际意义。相反,我们将更多地关注在应用程序初始实施过程中必须做出的并发决策,并在适当的地方调整本书中学习到的早期想法。我们代码中使用的并发语义的设计比实际使用的机制更为重要。

我们将从实施前的简要探索开始。然后,我们将查看我们构建的应用程序更详细的需求。最后,我们将通过实际实施过程,这个过程分为前端和后端两部分。

入门

通过查看带有代码片段的示例来介绍一个特定主题是一个很好的途径。这正是我们在本书中处理 JavaScript 并发时所做的大部分工作。在第一章中,我们介绍了一些并发原则。我们应该并行化我们的代码以利用并发硬件。我们应该无干扰地同步并发操作。我们应该通过尽可能推迟计算和分配来节省 CPU 和内存。在整个章节中,我们看到了这些原则如何应用于 JavaScript 并发的不同领域。它们也适用于开发初期,当我们没有应用程序或试图修复应用程序时。

我们将从这个观点开始,即并发是默认模式。当并发是默认的,一切都是并发的。我们将再次探讨为什么这是一个如此重要的系统特性。然后,我们将看看这些原则是否适用于已经存在的应用程序。最后,我们将探讨我们可能构建的应用程序类型,以及它们如何影响我们对并发的处理方式。

并发优先

如我们所知,并发是困难的。无论我们如何粉饰它或我们的抽象多么稳固,它都与我们大脑的工作方式背道而驰。这听起来不可能,不是吗?这绝对不是事实。与任何困难问题一样,正确的做法几乎总是分而治之的变体。在 JavaScript 并发的情况下,我们希望将问题分解为不超过几个真正小、易于解决的问题。一个简单的方法是在我们真正坐下来编写任何代码之前,仔细审查潜在的并发问题。

例如,假设我们基于这样的假设:我们很可能会在代码的各个阶段频繁遇到并发问题。这意味着我们可能需要花费大量时间进行前期并发设计。像生成器和承诺这样的概念在开发的早期阶段是有意义的,并且让我们更接近最终目标。但其他想法,如函数式编程、map/reduce 和 Web Workers,可以解决更大的并发问题。这难道意味着我们想在尚未真正在我们的应用程序中遇到的问题上花费大量设计时间吗?

另一种方法是减少在前期并发设计上的时间投入。这并不是说我们可以忽视并发;那样做会违背本书的整个前提。相反,我们基于这样的假设:我们目前还没有任何并发问题,但将来很可能会有。换一种说法,我们继续编写默认并发的代码,而不投资于尚未存在的并发问题解决方案。本书中一直使用的原则,再次帮助我们首先解决重要问题。

例如,我们希望在可能的情况下并行化我们的代码,以便充分利用系统上的多个 CPU。思考这个原则迫使提出问题——我们真的关心利用八个 CPU 来处理一个一个 CPU 就能轻松处理的事情吗?只需一点努力,我们就可以以这种方式构建我们的应用程序,这样我们就不会因为对不真实的并发问题进行无谓的争论而使自己瘫痪。想想如何在开发的早期阶段促进并发。想想,这种实现如何使未来的并发问题难以处理,什么是一个更好的方法?在本章的后面部分,我们的演示应用程序将致力于以这种方式实现代码。

并发改造

既然在前期过多思考并发问题是不明智的,那么一旦这些问题发生,我们该如何着手解决它们呢?在某些情况下,这些问题可能是严重的问题,以至于使接口无法使用。例如,如果我们尝试处理大量数据,我们可能会通过尝试分配过多的内存来使浏览器标签崩溃,或者 UI 可能会简单地冻结。这些都是需要立即关注的问题,而且通常没有时间上的奢侈。

我们可能遇到的另一种情况是,在不太关键的情况下,并发实现可以从客观上改善用户体验,但如果我们不立即修复它,应用程序也不会失败。例如,假设我们的应用程序在初始页面加载时进行了三次 API 调用。每次调用都等待前一个调用完成。但,实际上这些调用之间没有实际的依赖关系;它们不需要从彼此那里获取响应数据。将这些调用修复为并行执行相对风险较低,并且可以提高加载时间,可能超过一秒。

这些更改如何容易或困难地重构到我们的应用程序中,最终取决于应用程序的编写方式。正如前文所述,我们不想花太多时间去思考那些不存在的并发问题。相反,我们的初始重点应该是默认促进并发。因此,当这些情况出现,我们需要实现一个解决实际问题的并发解决方案时,这并不困难。因为我们已经是在以代码编写的方式思考并发了。

我们同样可能会遇到一个没有考虑并发性的应用程序。在尝试修复需要并发解决方案的问题时,这些情况处理起来会更复杂。我们经常会发现,我们只需要对大量代码进行重构才能修复一些基本问题。当我们在时间紧迫的情况下,这会变得很困难,但一般来说,这可以是一件好事。如果一个遗留应用程序开始逐个重构以更好地促进并发,那么我们会更好。这仅仅使得下一个并发问题更容易解决,并且促进了良好的编码风格——默认并发。

应用程序类型

在实施初期阶段,我们需要密切关注的是我们正在构建的应用程序类型。没有通用的编写代码以促进并发的途径。这是因为每个应用程序都以自己独特的方式并发。显然,并发场景之间存在一些重叠,但一般来说,我们可以肯定我们的应用程序将需要自己的特殊处理。

例如,花大量时间和精力去设计围绕 Web Workers 的抽象是否合理?如果我们应用程序几乎不做任何 Web 请求,去考虑实现 API 响应的承诺值就没有意义。最后,如果我们没有高请求/连接率,我们真的想要在我们的 Node 组件中考虑进程间通信的设计吗?

技巧不是忽略这些低优先级的项目,因为一旦我们忽略了应用程序中并发的一些维度,下周一切都会改变,我们将完全无法准备应对这种情况。相反,我们不应该在并发环境中完全忽略这些应用程序维度,我们需要针对常见情况进行优化。最有效的方法是深入思考我们应用程序的本质。通过这样做,我们可以轻松地找到在并发方面我们代码中要解决的最佳候选问题。

要求

现在是时候将我们的注意力转向实际构建一个并发应用程序了。在本节中,我们将简要概述我们将要构建的聊天应用程序,从应用程序的总体目标开始。然后,我们将其他要求分解为“API”和“UI”。我们很快就会进入一些代码,请放心。

总体目标

首先,为什么还要另一个聊天应用程序?好吧,有两个原因;首先,它不是一个真正的应用程序,我们不是为了重新发明轮子而构建它;我们是为了在应用程序的背景下学习并发 JavaScript 而构建它。其次,聊天应用程序有很多动态部分,可以帮助你展示你在本书中学到的某些并发机制。话虽如此,这将是一个非常简单的聊天应用程序——我们在一章中只有这么多的空间。

我们将要实现的聊天概念与大多数其他熟悉的聊天应用程序相同。有聊天本身,带有主题标签,还有用户和消息。我们将实现这些,而不会做太多其他的事情。甚至 UI 本身也将是一个典型的聊天窗口的简化版本。再次强调,这是为了将代码示例保持在并发环境中的相关内容。

为了进一步简化事情,我们实际上不会将聊天持久化到磁盘;我们只是在内存中保留一切。这样,我们可以将注意力集中在应用程序中的其他并发问题上,而且很容易运行而无需设置存储或处理磁盘空间。我们还将跳过聊天的一些其他常见功能,如打字通知、表情符号等。它们与我们试图学习的内容无关。即使移除了所有这些功能,我们也会看到并发设计和实现可以变得多么复杂;更大的项目更具挑战性。

最后,而不是使用身份验证,这个聊天应用程序将更多地服务于短暂的用途场景,其中用户想要快速创建一个不需要注册的聊天。因此,聊天创建者将创建一个聊天,这将创建一个可以与参与者共享的唯一 URL。

API

我们的聊天应用 API 将使用简单的 Node HTTP 服务器实现。它不使用任何 Web 框架,只使用几个小型库。这样做没有其他原因,只是因为应用程序足够简单,使用框架不会以任何方式增强本章的示例。在现实世界中,无论如何,请使用简化你代码的 Node Web 框架——本书的教训——包括本章——仍然适用。

响应将是我们的聊天数据的 JSON 字符串。只有对应用程序基本功能至关重要的最基本 API 端点将被实现。以下是 API 端点所需的内容:

  • 创建一个新的聊天

  • 加入现有聊天

  • 在现有聊天中发布一条新消息

  • 获取现有聊天

很简单,对吧?这看似简单。由于没有过滤功能,这需要在前端处理。这是故意的;缺少功能的 API 很常见,前端的一个并发解决方案可能是结果。当我们开始构建 UI 时,我们将再次讨论这个话题。

注意

为此示例应用程序实现的 NodeJS 代码还包括用于提供静态文件的处理器。这实际上更多的是一种便利措施,而不是反映生产环境中应该发生的事情。你能够轻松运行此应用程序并与之互动比复制生产环境中静态文件的服务方式更重要。

用户界面

我们的聊天应用的用户界面将仅由一个 HTML 文件和一些辅助的 JavaScript 代码组成。HTML 文档中包含三个页面——只是简单的div元素,具体如下:

  • 创建聊天:用户提供主题和他们的名字。

  • 加入聊天:用户提供他们的名字并被重定向到聊天。

  • 查看聊天:用户可以查看聊天消息并发送新消息。

这些页面的作用相当直观。最复杂的页面是查看聊天,但这并不太难。它显示来自任何参与者的所有消息列表,包括我们自己,以及用户列表。我们将必须实现一个轮询机制,以保持此页面的内容与聊天数据同步。在样式方面,我们不会做太多,只是做一些非常基本的布局和字体调整。

最后,由于用户可能会频繁加入聊天,它们是短暂的、临时的。毕竟,如果我们每次创建或加入聊天时都不必总是输入我们的用户名,那就太好了。我们将添加一个功能,以保持用户名在浏览器本地存储中。

好的,是时候写一些代码了,准备好了吗?

构建 API

我们将从 NodeJS 后端开始实施。这是我们构建必要 API 端点的地方。我们并不一定必须首先构建后端。实际上,很多时候,UI 设计驱动 API 设计。不同的开发机构有不同的方法;我们之所以首先做后端,没有特别的原因。

我们将首先实现基本的 HTTP 服务和请求路由机制。然后,我们将探讨使用协程作为处理函数。我们将通过查看每个处理函数的实现来结束本节。

HTTP 服务器和路由

我们不会使用比核心的 http 节点模块更多的东西来处理 HTTP 请求。在实际应用中,我们更有可能使用一个负责为我们处理大量模板代码的 Web 框架,那么我们可能会有一个可用的路由组件。我们的需求与这些路由器中找到的需求非常相似,所以为了简单起见,我们在这里自己实现。

我们将使用 commander 库来解析命令行选项,但这实际上并不那么简单。这个库很小,在我们项目的早期引入它只是为了更容易地添加新的配置选项到我们的服务器。让我们看看一个图表,展示我们的主程序如何适应环境:

HTTP 服务器和路由

我们主模块的职责是启动 HTTP 服务器并设置一个处理路由的处理函数。路由本身是正则表达式到处理函数的静态映射。正如我们所见,处理函数存储在单独的模块中。那么现在让我们看看我们的主程序:

// The core Node modules we'll need.
var http = require('http');

// Commander is an "npm" package, and is very helpful
// with parsing command line arguments.
var commander = require('commander');

// Our request handler functions that respond to
// requests.
var handlers = require('./handlers');

// The routes array contains route-handler parings. That 
// is, when a given route RegExp matches against the 
// request URL, the associated handler function is 
// called.
var routes = [
    [ /^\/api\/chat\/(.+)\/message/i, 
        handlers.sendMessage ],
    [ /^\/api\/chat\/(.+)\/join$/i, handlers.joinChat ],
    [ /^\/api\/chat\/(.+)$/i, handlers.loadChat ],
    [ /^\/api\/chat$/i, handlers.createChat ],
    [ /^\/(.+)\.js$/i, handlers.staticFile ],
    [ /^\/(.*)$/i, handlers.index ]
];

// Adds command line options using the "commander" library,
// and parses them. We're only interested in the "host" and
// the "port" values right now. Both options have default
// values.
commander
    .option('-p, --port <port>', 
        'The port to listen on', 8081)
    .option('-H --host <host>', 
        'The host to serve from', 'localhost')
    .parse(process.argv);

// Creates an HTTP server. This handler will iterate over
// our "routes" array, and test for a match. If found, the
// handler is called with the request, the response, and
// the regular expression result.
http.createServer((req, res) => {
    for (let route of routes) {
        let result = route[0].exec(req.url);

        if (result) {
            route1;
            break;
        }
    }
}).listen(commander.port, commander.host);

console.log(`listening
at http://${commander.host}:${commander.port}`);

这就是我们的处理路由机制的范围。我们所有的路由都定义在 routes 变量中,随着我们的应用程序随时间变化,这里的路由变化也会发生。我们还可以看到,使用 commander 从命令行获取选项相当简单。在这里添加新选项也很容易。

我们给 HTTP 服务器提供的请求处理函数可能永远不会需要改变,因为它实际上并不满足任何请求。它所做的只是遍历路由,直到路由正则表达式与请求 URL 匹配。当这种情况发生时,请求被传递给处理函数。所以,让我们把注意力转向实际的处理函数实现。

协程作为处理函数

正如我们在本书的前几章中看到的,在我们的前端 JavaScript 代码中引入回调地狱并不需要太多。这正是承诺派上用场的地方,因为它们允许我们封装讨厌的同步语义。结果是,在我们的组件中,我们尝试实现产品功能时,代码既干净又易于阅读。我们是否在 Node HTTP 请求处理函数中也遇到了同样的问题?

在更简单的处理程序中,不,我们不会面临这个挑战。我们只需要查看请求,弄清楚如何处理它,然后执行它,并在发送响应之前更新响应。在更复杂的场景中,在我们能够响应之前,我们必须在请求处理程序中进行各种异步活动。换句话说,如果我们不小心,回调地狱是不可避免的。例如,我们的处理程序可能需要从其他网络服务获取一些数据,它可能执行数据库查询,或者它可能写入磁盘。在这些所有情况下,我们需要在异步操作完成时执行回调;否则,我们永远不会完成我们的响应。

在第九章,“高级 NodeJS 并发”中,我们探讨了使用Co库在 Node 中实现协程。如果我们能在我们的请求处理程序函数中做类似的事情会怎么样?也就是说,让它们成为协程而不是普通的可调用函数。最终目标将是产生以下类似的东西:

作为处理程序的协程

在这里,我们可以看到我们从这些服务中获得的价值在我们的代码中表现为简单的变量。它们不必是服务;然而,它们可以是任何异步操作。例如,我们的聊天应用程序需要解析从 UI 发布的表单数据。它将使用formidable库来完成这项工作,这是一个异步操作。解析后的表单字段被传递给回调函数。让我们将这个操作包裹在一个承诺中,看看它是什么样子:

// This function returns a promise, which is resolved
// with parsed form data as an object.
function formFields(req) {
    return new Promise((resolve, reject) => {

        // Use the "IncomingForm" class from the
        // "formidable" lib to parse the data. This
        // "parse()" method is async, so we resolve or
        // reject the promise in the callback.
        new formidable.IncomingForm()
            .parse(req, (err, fields) => {
                if (err) {
                    reject(err);
                } else {
                    resolve(fields);
                }
            });
    });
}

当我们想要表单字段时,我们有一个承诺要与之合作,这是好的。但现在,我们需要在协程的上下文中使用该函数。让我们逐一查看我们的请求处理程序,看看如何使用formFields()函数将承诺值作为同步值处理。

创建聊天处理程序

创建聊天处理程序负责创建一个新的聊天。它期望一个主题和一个用户。它将使用formFields()函数来解析发送到这个处理程序的表单数据。在它将新的聊天存储在全局chat对象中(记住,这个应用程序将所有内容存储在内存中)之后,处理程序以 JSON 字符串的形式响应聊天数据。让我们看看处理程序代码:

// The "create chat" API. This endpoint
// creates a new chat object and stores it in memory.
exports.createChat = co.wrap(function* (req, res) {
    if (!ensureMethod(req, res, 'POST')) {
        return;
    }

    // Yield the promise returned by "formFields()".
    // This pauses the execution of this handler because
    // it's a co-routine, created using "co.wrap()".
    var fields = yield formFields(req);

    // The ID for the new chat.
    var chatId = id();

    // The timestamp used for both the chat, and the
    // added user.
    var timestamp = new Date().getTime();

    // Creates the new chat object and stores it. The
    // "users" array is populated with the user that
    // created the chat. The "messages" array is empty
    // by default.
    var chat = chats[chatId] = {
        timestamp: timestamp,
        topic: fields.topic,
        users: [{
            timestamp: timestamp,
            name: fields.user
        }],
        messages: []
    };

    // The response is the JSON encoded version of the
    // chat object. The chat ID is added to the response
    // since it's stored as a key, not a chat property.
    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify(Object.assign({
        id: chatId
    }, chat)));
});

我们可以看到createChat()函数从这个模块导出,因为它被我们的路由器在主应用程序模块中使用。我们还可以看到处理程序函数是一个生成器,并且它被co.wrap()包裹。这是因为我们希望它成为一个协程而不是一个普通函数。对formFields()的调用说明了我们在上一节中讨论的思想。注意,我们生成了承诺,并返回了解决后的值。函数在发生时阻塞,这非常重要,因为这正是我们能够保持我们的代码干净且没有过多的回调的原因。

注意

我们的手册程序使用了一些实用函数。出于页面空间的考虑,这里没有涵盖这些函数。然而,它们包含在这本书的代码中,并在注释中进行了说明。

加入聊天处理程序

加入聊天处理程序是用户能够加入其他用户创建的聊天的方式。用户首先需要与他们共享的聊天 URL。然后,他们可以提供他们的名字,并将消息发送到这个端点,该端点的聊天 ID 作为 URL 的一部分编码。这个处理程序的工作是将新的用户推送到聊天的用户数组中。现在让我们看看处理程序代码:

// This endpoint allows a user to join an existing
// chat that's been shared with them (a URL).
exports.joinChat = co.wrap(function* (req, res, id) {
    if (!ensureMethod(req, res, 'POST')) {
        return;
    }

    // Load the chat from the memory - the "chats"
    // object.
    var chat = chats[id[1]];

    if (!ensureFound(req, res, chat)) {
        return;
    }

    // Yield to get the parsed form fields. This
    // function is a co-routine created using "co.wrap()".
    var fields = yield formFields(req);

    chat.timestamp = new Date().getTime();

    // Adds the new user to the chat.
    chat.users.push({
        timestamp: chat.timestamp,
        name: fields.user
    });

    // Responds with the JSON encoded chat string. We
    // need to add the ID separately as it's not a
    // chat property.
    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify(Object.assign({
        id: id[1],
    }, chat)));
});

我们可能注意到这个处理程序和创建聊天处理程序之间有很多相似之处。我们检查正确的 HTTP 方法,返回 JSON 响应,并将处理程序函数包装为协程,这样我们就可以以完全避免回调函数的方式解析表单。主要区别在于我们更新现有的聊天,而不是创建一个新的。

注意

将新的user对象推送到users数组的代码会被认为是存储聊天。在实际应用中,这意味着以某种方式将数据写入磁盘——很可能是调用数据库库。这意味着需要发起异步请求。幸运的是,我们可以遵循与我们的表单解析相同的技巧——让它返回一个承诺,并利用已经存在的协程。

加载聊天处理程序

加载聊天处理程序的工作正好符合其名称——使用在 URL 中找到的 ID 加载指定的聊天,并以该聊天的 JSON 字符串响应。以下是执行此操作的代码:

// This endpoint loads a chat. This function
// isn't wrapped as a co-routine because there's
// no asynchronous actions to wait for.
exports.loadChat = function(req, res, id) {

    // Lookup the chat, using the "id" from the URL
    // as the key.
    var chat = chats[id[1]];

    if (!ensureFound(req, res, chat)) {
        return;
    }

    // Respond with the JSON encoded string version
    // of the chat.
    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify(chat));
};

这个函数没有co.wrap()调用,也没有生成器。这是因为不需要。并不是说这个函数作为生成器被包装为协程是有害的,只是浪费。

注意

这实际上是我们,即开发者,有意识地决定避免在不合理的地方使用并发的一个例子。将来这个处理程序可能会有所改变,如果确实如此,我们就会有工作要做。然而,权衡的结果是我们现在有更少的代码,运行速度更快。这对阅读它的人来说是有益的,因为它看起来不像一个异步函数,而且它也不应该被当作这样的函数来对待。

发送消息处理程序。

我们需要实现的最后一个主要 API 端点是发送消息。这是任何给定聊天中的用户能够发布一条消息,其他所有聊天参与者都可以消费的方式。这与加入聊天处理程序类似,除了我们将新的消息对象推送到消息数组。让我们看看处理程序代码;现在这个模式应该开始变得熟悉了:

// This handler posts a new message to a given chat. It's
// also a co-routine function since it needs to wait for
// asynchronous actions to complete.
exports.sendMessage = co.wrap(function* (req, res, id) {
    if (!ensureMethod(req, res, 'POST')) {
        return;
    }

    // Load the chat and ensures that it's found.
    var chat = chats[id[1]];

    if (!ensureFound(req, res, chat)) {
        return;
    }

    // Get's the parsed form fields by yielding the
    // promise returned from "formFields()".
    var fields = yield formFields(req);

    chat.timestamp = new Date().getTime();

    // Pushes the new message object to the "messages" 
    // property.
    chat.messages.push({
        timestamp: chat.timestamp,
        user: fields.user,
        message: fields.message
    });

    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify(chat));
});

当加入聊天时,同样的想法适用。在真实应用中,修改聊天对象可能是一个异步操作,而现在,我们的协程处理程序模式已经为我们准备好了,以便在适当的时候进行这个更改。这就是这些协程处理程序的关键,使得向处理程序添加新的异步操作变得容易,而不是非常困难。

静态处理程序

构成我们聊天应用最后一批处理程序的最后一组是静态内容处理程序。它们的工作是从文件系统向浏览器提供静态文件,例如index.html文档和我们的 JavaScript 源文件。通常,这是在 node 应用之外处理的,但我们将包括它们,因为在某些时候,直接使用现成的电池包会更简单:

// Helper function used to serve static files.
function serveFile(req, res, file) {

    // Creates a stream to read the file.
    var stream = fs.createReadStream(file);

    // End the response when there's no more input.
    stream.on('end', () => {
        res.end();
    });

    // Pipe the input file to the HTTP response,
    // which is a writable stream.
    stream.pipe(res);
}

// Serves the requested path as a static file.
exports.staticFile = function(req, res) {
    serveFile(req, res,
        path.join(__dirname, req.url));
};

// By default, we want to serve the "index.html" file.
exports.index = function index(req, res) {
    res.setHeader('ContentType', 'text/html');

    serveFile(req, res,
        path.join(__dirname, 'index.html'));
};

构建用户界面

我们现在有一个 API 要针对;是时候开始构建我们聊天应用的用户界面了。我们将从思考如何与刚刚构建的 API 通信开始,然后实现这一部分。接下来,我们将构建实际需要的 HTML 来渲染这个应用使用的三个页面。从这里,我们将转向前端可能最具挑战性的部分——构建 DOM 事件处理程序和操作器。最后,我们将看看是否可以通过添加一个 Web Worker 来提高应用响应性。

与 API 通信

我们 UI 中的 API 通信路径本质上是并发的——它们通过网络连接发送和接收数据。因此,为了我们应用架构的最佳利益,我们最好花时间将同步机制从整个系统中尽可能隐藏起来。为了与我们的 API 通信,我们将使用XMLHttpRequest类的实例。然而,正如我们在本书的前几章中看到的,这个类可能会引导我们走向回调地狱。

我们知道,解决方案是使用一个承诺来支持对所有 API 数据的统一接口。这并不意味着我们需要反复抽象XMLHttpRequest类。我们创建了一个简单的实用函数,它为我们处理并发封装,然后我们创建了几个针对相应 API 端点的特定小函数。下面是一个说明这个想法的图示:

与 API 通信

与异步 API 端点通信的这种方法具有良好的可扩展性,因为添加新功能只需要添加一个小函数。所有的同步语义都被封装在一个api()函数中。现在让我们看看代码:

// A generic function used to send HTTP requests to the
// API. The "method" is the HTTP method, the "path" is
// the request path, and the "data" is the optional
// request payload.
function api(method, path, data) {

    // Returns a promise to the called, resolved with
    // the API response, or failure.
    return new Promise((resolve, reject) => {
        var request = new XMLHttpRequest();

        // Resolves the promise using the parsed JSON
        // object - usually a chat.
        request.addEventListener('load', (e) => {
            resolve(JSON.parse(e.target.responseText));
        });

        // Rejects the promise when there's a problem with
        // the API.
        request.addEventListener('error', (e) => {
            reject(e.target.statusText || 'unknown error');
        });

        request.addEventListener('abort', resolve);

        request.open(method, path);

        // If there's no "data", we can simply "send()"
        // the request. Otherwise, we have to create a
        // new "FormData" instance to properly encode
        // the form data for the request.
        if (Object.is(data, undefined)) {
            request.send();
        } else {
            var form = new FormData();

            Object.keys(data).forEach((key) => {
                form.append(key, data[key]);
            });

            request.send(form);
        }
    });
}

这个函数非常容易使用,支持我们所有的 API 使用场景。我们很快将要实现的较小的 API 函数可以简单地返回这个api()函数返回的承诺。没有必要比这更复杂。

然而,这里还有另一件事我们需要考虑。如果我们回顾这个应用的要求,API 没有任何过滤功能。这对 UI 来说是个问题,因为我们不会重新渲染整个聊天对象。消息可以频繁发布,如果我们重新渲染大量消息,屏幕在渲染 DOM 元素时可能会出现闪烁。因此,我们显然需要在浏览器中过滤聊天消息和用户;但这件事应该在何处发生?

让我们在并发的背景下思考这个问题。假设我们决定在一个直接操作 DOM 的组件中执行过滤。这在某种程度上是好的,因为它意味着我们可以有多个独立组件使用相同的数据,但以不同的方式过滤。当数据转换与 DOM 非常接近时,调整并发的任何类型都很难。例如,我们的应用程序不需要灵活性。只有一个组件渲染过滤后的数据。但是,它可能从并发中受益。以下图表说明了另一种方法,其中我们实现的 API 功能执行过滤:

与 API 交流

采用这种方法,API 函数与 DOM 的隔离程度足够高。如果我们愿意,我们可以在稍后引入并发。现在,让我们看看一些具体的 API 函数,以及我们可以根据需要附加到给定 API 调用的过滤机制:

// Filters the "chat" object to include only new users
// and new messages. That is, data with a newer
// "timestamp" than when we last checked.
function filterChat(chat) {
    Object.assign(chat, {

        // Assigns the filtered arrays to the
        // corresponding "chat" properties.
        users: chat.users.filter(
            user => user.timestamp > timestamp
        ),
        messages: chat.messages.filter(
            message => message.timestamp > timestamp
        )
    });

    // Reset the "timestamp" so we can look for newer
    // data next time around. We return the modified
    // chat instance.
    timestamp = chat.timestamp;
    return chat;
}

// Creates a chat using the given "topic" and "user".
// The returned promise is resolved with the created
// chat data.
function createChat(topic, user) {
    return api('post', 'api/chat', {
        topic: topic,
        user: user
    });
}

// Joins the given "user" to the given chat "id".
// The returned promise is resolved with the
// joined chat data.
function joinChat(id, user) {
    return api('post', `api/chat/${id}/join`, {
        user: user
    }).then(filterChat);
}

// Loads the given chat "id". The returned promise
// is resolved with filtered chat data.
function loadChat(id) {
    return api('get', `api/chat/${id}`)
        .then(filterChat);
};

// Posts a "message" from the given "user" to the given
// chat "id". The returned promise is resolved with
// filtered chat data.
function sendMessage(id, user, message) {
    return api('post', `api/chat/${id}/message`, {
        user: user,
        message: message
    }).then(filterChat);
}

filterChat() 函数足够简单。它只是修改给定的 chat 对象,只包含新用户和消息。新消息是指那些时间戳大于这里使用的 timestamp 变量的消息。过滤完成后,timestamp 会根据聊天的 timestamp 属性更新。如果没有变化,这个值可能保持不变,但如果有所变化,这个值会更新,以确保不会返回重复的值。

我们可以看到,在我们的特定 API 函数中,filterChat() 函数被传递给承诺作为解析器。因此,我们在这里仍然保持了一定的灵活性。例如,如果不同的组件需要以不同的方式过滤聊天,我们可以引入一个新的函数,使用相同的方法,并添加一个不同的承诺解析器函数,以相应地进行过滤。

实现 HTML

我们的 UI 需要一些 HTML 才能渲染。聊天应用程序足够简单,只需要一个 HTML 页面。我们可以将 DOM 结构组织成三个div元素,每个元素代表我们的页面。每个页面的元素本身很简单,因为在这个开发阶段没有多少动态部分。我们的首要任务是功能性——构建功能正常的功能。同时,我们应该考虑并发设计。这些项目肯定比考虑,比如说,小部件和虚拟 DOM 渲染库更相关。这些是重要的考虑因素,但它们也比处理有缺陷的并发设计更容易解决。

让我们看看与我们的 UI 一起使用的 HTML 源。为这些元素定义了一些 CSS 样式。然而,它们是微不足道的,这里没有涵盖。例如,隐藏类用于切换给定页面的可见性。默认情况下,一切都被隐藏。处理这些元素的显示是我们的事件处理器的责任——我们将在下一部分介绍这些:

<div id="create" class="hide">
    <h1>Create Chat</h1>
    <p>
        <label for="topic">Topic:</label>
        <input name="topic" id="topic" autofocus/>
    </p>
    <p>
        <label for="create-user">Your Name:</label>
        <input name="create-user" id="create-user"/>
    </p>
    <button>Create</button>
</div>
<div id="join" class="hide">
    <h1>Join Chat</h1>
    <p>
        <label for="join-user">Your Name:</label>
        <input name="join-user" id="join-user" autofocus/>
    </p>
    <button>Join</button>
</div>
<div id="view" class="hide">
    <h1></h1>
    <div>
        <div>
            <ul id="messages"></ul>
            <input placeholder="message" autofocus/>
        </div>
        <ul id="users"></ul>
    </div>
</div>

DOM 事件和操作

我们现在已经建立了一些 API 通信机制和 DOM 元素。让我们将注意力转向我们应用程序的事件处理器,以及它们如何与 DOM 交互。我们需要解决的最复杂的 DOM 操作活动是绘制聊天。也就是说,显示参与聊天的消息和用户。让我们从这里开始。我们将实现一个drawChat()函数,因为它可能将在多个地方使用:

// Updates the given "chat" in the DOM.
function drawChat(chat) {

    // Our main DOM components. "$users" is the
    // list of users in the chat. "$messages" is the
    // list of messages in the chat. "$view" is the
    // container element for both lists.
    var $users = document.getElementById('users'),
    $messages = document.getElementById('messages'),
    $view = document.getElementById('view');

    // Update the document title to reflect the chat
    // "topic", display the chat container by removing
    // the "hide" class, and update the title of the
    // chat in bold heading.
    document.querySelector('title')
        .textContent = chat.topic;
    $view.classList.remove('hide');
    $view.querySelector('h1')
        .textContent = chat.topic;

    // Iterates over the messages, making no assumptions
    // about filtering or anything like that.
    for (var message of chat.messages) {

        // Constructs the DOM elements we'll need for
        // the user portion of the message.
        var $user = document.createElement('li'),
            $strong = document.createElement('strong'),
            $em = document.createElement('em');

        // Assemble the DOM structure...
        $user.appendChild($strong);
        $user.appendChild($em);
        $user.classList.add('user');

        // Add content - the user name, and time the message
        // was posted.
        $strong.textContent = message.user + ' ';
        $em.textContent = new Date(message.timestamp)
            .toLocaleString();

        // The message itself...
        var $message = document.createElement('li');
        $message.textContent = message.message;

        // Attach the user portion and the message portion,
        // to the DOM.
        $messages.appendChild($user);
        $messages.appendChild($message);
    }

    // Iterates over the users in the chat, making no
    // assumptions about the data, only displaying it.
    for (var user of chat.users) {
        var $user = document.createElement('li');
        $user.textContent = user.name;

        $users.appendChild($user);
    }

    // Make sure that the user can see the newly-rendered
    // content.
    $messages.scrollTop = $messages.scrollHeight;

    // Return the chat so that this function can be used
    // as a resolver in a promise resolution chain.
    return chat;
}

关于drawChat()函数有两个重要的事项需要注意。首先,这里没有进行聊天过滤。它假设任何消息和用户都是新的,并且简单地将其追加到 DOM 中。其次,我们在渲染 DOM 之后实际上返回了聊天对象。一开始这可能看起来是不必要的,但我们实际上打算将这个函数用作承诺解析器。这意味着如果我们想在then()链中添加更多的解析器,我们必须通过返回它来传递数据。

让我们看看加载事件来强调前面的观点。在聊天渲染后,我们需要做一些额外的工作。为此,我们可以简单地通过另一个then()调用链来链下下一个函数:

// When the page loads...
window.addEventListener('load', (e) => {

    // The "chatId" comes from the page URL. The "user"
    // might already exist in localStorage.
    var chatId = location.pathname.slice(1),
        user = localStorage.getItem('user'),
        $create = document.getElementById('create'),
        $join = document.getElementById('join');

    // If there's no chat ID in the URL, then we display
    // the create chat screen, populating the user
    // input if it was found in localStorage.
    if (!chatId) {
        $create.classList.remove('hide');

        if (user) {
            document.getElementById('create-user')
               .value = user;
        }

        return;
    }

    // If there's no user name found in localStorage,
    // we display the join screen which allows them
    // to enter their name before joining the chat.
    if (!user) {
        $join.classList.remove('hide');
        return;
    }

    // We load the chat, draw it using drawChat(), and
    // start the chat polling process.
    api.postMessage({
        action: 'loadChat',
        chatId: chatId
    }).then(drawChat).then((chat) => {

        // If the user isn't part of the chat already,
        // we join it. This happens when the user name
        // is cached in localStorage. If the user creates
        // a chat, then loads it, they'll already belong
        // to the chat.
        if (chat.users.map(u => u.name).indexOf(user) < 0) {
            api.postMessage({
                action: 'joinChat',
                chatId: chatId,
                user: user
            }).then(drawChat).then(() => {
                poll(chatId);
            });
        } else {
            poll(chatId);
         }
    });
});

当页面首次加载时,此处理器会被调用,首先需要根据当前 URL 检查是否有聊天要加载。如果有,我们就通过使用drawChat()作为解析器来调用 API 加载聊天。但是,我们还需要执行一些额外的功能,并且这些功能被添加到链中的下一个then()解析器中。它的任务是确保用户实际上是聊天的一部分,为此,它需要我们从 API 加载的聊天,这是通过drawChat()传递的。在必要时,我们进行进一步的 API 调用以将用户添加到聊天中后,我们开始轮询机制。这就是我们如何保持 UI 与新的消息和加入聊天的用户保持同步的方式:

// Starts polling the API for the given chat "id".
function poll(chatId) {
    setInterval(() => {
        api.postMessage({
            action: 'loadChat',
            chatId: chatId
        }).then(drawChat);
    }, 3000);
}

你可能已经注意到,我们使用了一种奇怪的调用方式,几乎就像一个 Web Worker——api.postMessage()。这是因为它是一个 Web Worker,这就是我们接下来要讨论的内容。

注意

为了节省空间,我们省略了与创建聊天、加入聊天和发送消息相关的其他三个 DOM 事件处理器。与刚刚介绍过的加载处理器相比,它们在并发方面没有不同。

添加 API 工作线程

在早期实现 API 通信函数时,我们决定从并发角度来看,将过滤组件与 API 而不是 DOM 耦合更有意义。现在是时候从这个决定中受益,并将我们的 API 代码封装在 Web Worker 中。我们想要这样做的主要原因是filterChat()函数有可能锁定响应性。换句话说,对于较大的聊天,这需要更长的时间来完成,文本输入将停止响应用户输入。例如,在我们尝试渲染更新后的消息列表时,没有理由阻止用户发送消息。

首先,我们需要扩展工作线程 API,使postMessage()返回一个承诺。这与我们在第七章中做的相同,即抽象并发。看看下面的代码:

// This will generate unique IDs. We need them to
// map tasks executed by web workers to the larger
// operation that created them.
function* genID() {
    var id = 0;

    while (true) {
        yield id++;
    }
}

// Creates the global "id" generator.
var id = genID();

// This object holds the resolver functions from promises,
// as results come back from workers, we look them up here,
// based on ID.
var resolvers = {};

var rejectors = {};

// Keep the original implementation of "postMessage()"
// so we can call it later on, in our custom "postMessage()"
// implementation.
var postMessage = Worker.prototype.postMessage;

// Replace "postMessage()" with our custom implementation.
Worker.prototype.postMessage = function(data) {
    return new Promise((resolve, reject) => {

        // The ID that's used to tie together a web worker
        // response, and a resolver function.
        var msgId = id.next().value;

        // Stores the resolver so in can be used later, in
        // the web worker message callback.
        resolvers[msgId] = resolve;

        rejectors[msgId] = reject;

        // Run the original "Worker.postMessage()"
        // implementation, which takes care of 
        // actually posting the message to the 
        // worker thread.
        postMessage.call(this, Object.assign({
            msgId: msgId,
        }, data));
    });
};

// Starts our worker...
var api = new Worker('ui-api.js');

// Resolves the promise that was returned by
// "postMessage()" when the worker responds.
api.addEventListener('message', (e) => {

    // If the data is in an error state, then
    // we want the rejector function, and we call
    // that with the error. Otherwise, call the
    // regular resolver function with the data returned
    // from the worker.
    var source = e.data.error ? rejectors : resolvers,
        callback = source[e.data.msgId],
        data = e.data.error ? e.data.error : e.data;

    callback(data);

    // Don't need'em, delete'em.
    delete resolvers[e.data.msgId];
    delete rejectors[e.data.msgId];
});

在第七章中,我们没有涵盖使用拒绝承诺的这种技术的一个细节。例如,如果 API 调用由于某种原因失败,我们必须确保主线程中等待工作线程的承诺被拒绝;否则,会出现奇怪的错误。

现在,我们需要在我们的ui-api.js模块中添加一个附加项,其中定义了所有 API 函数,以适应它在 Web Worker 中运行的事实。我们只需要添加以下事件处理器:

// Listens for messages coming from the main thread.
addEventListener('message', (e) => {

    // The generic promise resolver function. It's
    // job is to post data back to the main thread
    // using "postMessage()". It also returns the
    // data so that it may be used further down in
    // the promise resolution chain.
    function resolve(data) {
        postMessage(Object.assign({
            msgId: e.data.msgId
        }, data));

        return data;
    }

    // The generic rejector function posts data back
    // to the main thread. The difference here is that
    // it marks the data as an error. This allows the
    // promise on the other end to be rejected.
    function reject(error) {
        postMessage({
            msgId: e.data.msgId,
            error: error.toString()
        });

        return error;
    }

    // This switch decides which function to call based
    // on the "action" message property. The "resolve()"
    // function is passed as the resolver to each returned 
    // promise.
    switch (e.data.action) {
        case 'createChat':
            createChat(e.data.topic, e.data.user)
                .then(resolve, reject);
            break;
        case 'joinChat':
            joinChat(e.data.chatId, e.data.user)
                .then(resolve, reject);
            break;
        case 'loadChat':
            loadChat(e.data.chatId)
                .then(resolve, reject)
            break;
        case 'sendMessage':
            sendMessage(
                e.data.chatId,
                e.data.user,
                e.data.message
            ).then(resolve, reject);
            break;
    }
});

这个message事件处理器是我们与主线程通信的方式。action属性是我们确定要调用哪个 API 端点的方法。因此,现在,每当我们在聊天消息上执行任何昂贵的过滤操作时,它都在一个单独的线程中。

引入这个工作线程的另一个后果是,它将 API 功能封装成一个统一的整体。现在,可以将 API 网络工作线程组件视为整个 UI 中更小的一个应用程序。

增加和改进

这就是我们将在我们的聊天应用程序开发上进行的全部覆盖。我们没有逐行分析代码,但这就是为什么代码作为本书的配套资料提供,以便可以查看其全部内容。前几节的内容是通过编写并发 JavaScript 代码的视角来进行的。我们没有利用这一章之前的所有例子,这会违背并发解决导致用户体验不佳的问题的整个目的。

聊天应用示例的重点是促进并发。这意味着在需要实现并发代码时才实现并发代码,而不是为了实现并发代码而实现并发代码。后者不会使我们的应用比现在更好,也不会使我们处于更好的位置来解决以后出现的并发问题。

我们将本章结束于一些可能值得考虑的聊天应用领域。鼓励读者,也就是你,与聊天应用代码一起工作,看看以下哪些点适用。你将如何支持它们?我们需要改变我们的设计吗?重点是,在我们的 JavaScript 应用中的并发设计不是一次性的,而是一个不断发展的设计任务,它随着我们的应用而变化。

集群 API

在第九章《高级 NodeJS 并发》中,你被介绍了 NodeJS 中的 cluster 模块。这个模块可以透明地扩展我们 HTTP 服务器的请求处理能力。该模块通过将 node 进程分叉成几个子进程来实现。由于它们各自是独立的进程,它们各自有自己的事件循环。此外,不需要额外的通信同步代码。

在我们的app.js模块中添加这些集群功能并不需要我们付出太多努力。但问题是——我们何时决定集群是有价值的?我们是等到实际出现性能问题,还是自动将其打开?这些事情很难提前知道。现实是,这取决于我们的请求处理程序有多大的 CPU 密集度。这些变化通常是由于软件中添加了新功能而引起的。

我们的聊天应用是否需要集群?也许,将来某天会需要。但处理程序实际上并没有进行任何工作。这可能会改变。也许我们可以继续实现集群功能,同时添加一个选项,让我们可以选择关闭它。

清理聊天记录

我们的聊天应用没有持久存储;它将所有聊天数据保存在内存中。这对于我们的特定用例来说是可以的,因为它是为那些想要启动临时聊天以便与他人分享链接而不必经历注册过程的用户而设计的。问题在于,在聊天不再被使用很长时间后,其数据仍然占用内存。最终,这将对我们的 Node 进程造成致命打击。

如果我们决定实现一个清理服务,其任务是在给定时间内定期遍历聊天数据和未修改的聊天记录,那么这些聊天记录将被删除?这将只保留内存中的活跃聊天。

异步入口点

我们早期就决定在大多数请求处理器中使用协程。这些处理器使用的唯一异步操作是表单解析行为。然而,任何给定处理器中只保留这一唯一异步操作的可能性很小。特别是随着我们应用的增长,我们将开始依赖更多的核心 NodeJS 功能,这意味着我们将想要将更多异步回调风格的代码包装在承诺中。我们可能也会开始依赖外部服务,无论是我们自己的还是第三方软件。

我们能否将我们的异步架构再向前迈进一步,并为希望扩展系统的用户提供进入这些处理器的入口点?例如,如果请求是创建聊天请求,向任何提供的创建聊天扩展发送请求。这样的任务相当艰巨,且容易出错。但对于具有许多移动部件的大型系统,其中所有部件都是异步的,最好是在系统中标准化异步入口点。

谁在输入?

我们在聊天应用中遗漏了一个功能,那就是特定用户的输入状态。这是通知所有其他聊天成员特定用户正在输入消息并存在于几乎每个现代聊天系统中的机制。

在我们当前的设计下,要实现这样的功能需要什么?轮询机制是否足以处理这种不断变化的状态?数据模型是否需要做出很大改变,这样的改变会不会给我们的请求处理器带来问题?

离开聊天

我们聊天应用中缺失的一个功能是移除不再参与聊天的用户。例如,其他聊天参与者看到聊天中实际上并不存在的用户,这真的有意义吗?仅仅监听卸载事件并实现一个新的离开聊天 API 端点是否足够,还是还有更多需要考虑的地方?

轮询超时

我们刚刚构建的聊天应用几乎没有进行错误处理。特别值得修复的一个案例是在轮询超时时终止轮询机制。通过这种方式,我们是在谈论防止客户端重复失败的请求尝试。假设服务器宕机,或者处理器因为引入的 bug 而简单地失败;我们希望轮询器无限期地空转吗?我们不想让它这样做,而且可能有一些事情可以解决它。

例如,我们需要在轮询开始时通过调用setInterval()取消设置的间隔。同样,我们需要一种方法来跟踪连续失败的尝试次数,这样我们就会知道何时关闭它。

摘要

希望这次对愚蠢的聊天应用程序的介绍,让你对设计端到端并发 JavaScript 应用程序所涉及的内容有了新的认识。这本书一开始就提供了一个关于并发是什么的高层次概述,特别是在 JavaScript 应用程序的背景下,因为这与其他编程语言环境不同。然后,我们介绍了一些指导原则,以帮助我们在这个过程中前进。

我们对各种语言和环境并发机制进行了有纪律的审视的章节,实际上只是达到目的的一种手段。对我们这些 JavaScript 程序员和架构师来说,最终的目标是一个没有并发问题的应用程序。这是一个宽泛的陈述,但最终,我们在 Web 应用程序中面临的大多数问题都是由于并发设计不足的直接结果。

因此,请使用这些原则。使用 JavaScript 中可用的出色的并发功能。将这两者结合起来,制作出超出用户期望的出色应用程序。当我们编写默认并发的代码时,许多 JavaScript 编程挑战就会消失。

posted @ 2025-09-29 10:35  绝不原创的飞龙  阅读(22)  评论(0)    收藏  举报