第十一章异步编程
计算机的核心部分,执行构成我们程序的单个步骤的部分,被称为处理器。 我们目前见过的程序会一直占用处理器,直到它们完成工作。 例如,像操作数字的循环这样的东西的执行速度几乎完全取决于处理器的速度。
但是很多程序与处理器之外的事物进行交互。 例如,它们可能通过计算机网络进行通信,或者从硬盘请求数据——这比从内存获取数据要慢得多。
当发生这样的事情时,让处理器闲置就太可惜了——它可能在同时做一些其他的工作。 部分原因在于你的操作系统,它会在多个正在运行的程序之间切换处理器。 但是当我们想要一个单个程序能够在等待网络请求时继续执行时,这并不能解决问题。
异步性
在同步编程模型中,事情一次发生一件事。 当你调用一个执行长时间运行操作的函数时,它只有在操作完成并且可以返回结果时才会返回。 这会使你的程序在操作执行的时间内停止。
异步模型允许同时发生多件事。 当你开始一个操作时,你的程序会继续运行。 当操作完成时,程序会收到通知并获取结果(例如,从磁盘读取的数据)。
我们可以用一个小例子来比较同步和异步编程:一个从网络获取两个资源然后合并结果的程序。
在同步环境中,请求函数只有在完成工作后才会返回,执行此任务的最简单方法是依次进行请求。 这样做的缺点是,第二个请求只有在第一个请求完成之后才会开始。 总共花费的时间至少是两次响应时间之和。
在同步系统中,解决这个问题的方法是启动额外的控制线程。 线程是另一个正在运行的程序,其执行可以由操作系统与其他程序交织在一起——由于大多数现代计算机都包含多个处理器,多个线程甚至可以在不同的处理器上同时运行。 第二个线程可以启动第二个请求,然后两个线程都等待其结果返回,之后它们重新同步以合并其结果。
在下图中,粗线表示程序正常运行所花费的时间,细线表示等待网络所花费的时间。 在同步模型中,网络花费的时间是特定控制线程的时间线的一部分。 在异步模型中,启动网络操作在概念上会导致时间线的分割。 启动操作的程序会继续运行,操作会与它一起发生,并在操作完成时通知程序。
另一种描述差异的方式是,在同步模型中,等待操作完成是隐式的,而在异步模型中,它是显式的,在我们控制之下。
异步性是双方面的。 它使表达不符合直线控制模型的程序变得更容易,但也可能使表达遵循直线的程序变得更笨拙。 我们将在本章后面的内容中看到解决这种笨拙的一些方法。
这两个重要的 JavaScript 编程平台——浏览器和 Node.js——都使可能需要一段时间才能完成的操作异步化,而不是依赖线程。 由于用线程进行编程非常困难(当程序同时做多件事时,理解程序的行为会变得更加困难),这通常被认为是一件好事。
乌鸦科技
大多数人都知道乌鸦是非常聪明的鸟。 它们可以使用工具,提前计划,记住事情,甚至在它们之间互相交流这些事情。
大多数人不知道的是,它们能够做很多事情,而这些事情对我们来说是隐藏得很好的。 我曾被一位信誉良好(尽管有些古怪)的鸦科专家告知,乌鸦的科技并不落后于人类的科技,并且它们正在赶上来。
例如,许多乌鸦文化都有建造计算设备的能力。 这些设备不是像人类的计算设备那样是电子设备,而是通过微小的昆虫的行为来运作,这个物种与白蚁密切相关,它们已经与乌鸦形成了共生关系。 鸟类为它们提供食物,作为回报,这些昆虫建造并运营它们复杂的菌落,在菌落内部的生物的帮助下,进行计算。
这种菌落通常位于寿命长的大巢穴中。 鸟类和昆虫共同努力建造一个由球状粘土结构组成的网络,隐藏在巢穴的树枝之间,昆虫在其中生活和工作。
为了与其他设备进行通信,这些机器使用光信号。 乌鸦在特殊的通信茎中嵌入反光材料,这些昆虫会将这些材料对准以反射到另一个巢穴的光,将数据编码为一系列快速闪烁。 这意味着只有具有无障碍视觉连接的巢穴才能进行通信。
我们这位鸦科专家朋友绘制了罗讷河畔埃耶尔-苏尔-安比村庄的乌鸦巢穴网络图。 这张地图显示了巢穴及其连接
在一个令人惊叹的趋同进化例子中,乌鸦计算机运行 JavaScript。 在本章中,我们将为它们编写一些基本的网络功能。
回调
异步编程的一种方法是让执行缓慢操作的函数接受一个额外的参数,一个回调函数。 操作会启动,并在完成时调用回调函数,并将结果作为参数传递给它。
例如,setTimeout
函数(在 Node.js 和浏览器中都可用)会等待给定的毫秒数(一秒是 1000 毫秒),然后调用一个函数。
setTimeout(() => console.log("Tick"), 500);
等待通常不是一项非常重要的工作,但在执行某些操作时(例如更新动画或检查某件事是否花费的时间超过给定时间)它很有用。
使用回调函数依次执行多个异步操作意味着你必须不断传递新的函数来处理操作完成后的计算继续。
大多数乌鸦巢穴计算机都有一个长期数据存储球,其中信息被刻在树枝上以便日后检索。 刻录或查找数据需要一些时间,因此长期存储的接口是异步的,并使用回调函数。
存储球以名称存储可以 JSON 编码的数据片段。 一只乌鸦可能会将它隐藏食物的地方的信息存储在名为 "food caches"
的名称下,该名称可以保存指向其他数据片段的名称数组,描述实际的缓存。 为了在大橡树巢穴的存储球中查找食物缓存,一只乌鸦可以运行如下代码
import {bigOak} from "./crow-tech"; bigOak.readStorage("food caches", caches => { let firstCache = caches[0]; bigOak.readStorage(firstCache, info => { console.log(info); }); });
这种编程风格是可行的,但每个异步操作都会使缩进级别增加,因为你最终会进入另一个函数。 执行更复杂的操作,例如同时运行多个操作,可能会变得有点麻烦。
乌鸦巢穴计算机被设计为使用请求-响应对进行通信。 这意味着一个巢穴向另一个巢穴发送一条消息,该巢穴会立即发送一条消息回复,确认收据,并可能包含对消息中提出的问题的回复。
每条消息都带有类型标签,用于确定如何处理它。 我们的代码可以为特定的请求类型定义处理程序,当收到此类请求时,会调用处理程序以生成响应。
"./crow-tech"
模块导出的接口提供了基于回调的通信功能。 巢穴有一个 send
方法可以发送请求。 它需要目标巢穴的名称、请求的类型和请求的内容作为前三个参数,并且它需要一个在收到响应时调用的函数作为第四个也是最后一个参数。
bigOak.send("Cow Pasture", "note", "Let's caw loudly at 7PM", () => console.log("Note delivered."));
但是为了让巢穴能够接收该请求,我们首先必须定义一个名为 "note"
的请求类型。 处理请求的代码必须不仅在本巢穴计算机上运行,而且必须在所有能够接收此类型消息的巢穴上运行。 我们假设一只乌鸦会飞过去并在所有巢穴上安装我们的处理程序代码。
import {defineRequestType} from "./crow-tech"; defineRequestType("note", (nest, content, source, done) => { console.log(`${nest.name} received note: ${content}`); done(); });
defineRequestType
函数定义了一种新的请求类型。该示例添加了对 "note"
请求的支持,该请求只是向给定巢发送一条便笺。我们的实现调用 console.log
,以便我们可以验证请求是否已到达。巢有一个 name
属性,它保存它们的名称。
传递给处理程序的第四个参数 done
是一个回调函数,它必须在完成请求时调用。如果我们将处理程序的返回值用作响应值,这意味着请求处理程序本身不能执行异步操作。执行异步工作的函数通常在工作完成之前返回,并安排在完成时调用回调。因此,我们需要某种异步机制(在本例中为另一个回调函数)来指示何时可用响应。
从某种程度上说,异步性是传染性的。任何调用执行异步工作的函数的函数本身必须是异步的,使用回调或类似机制来传递其结果。调用回调比简单地返回值更复杂且容易出错,因此需要以这种方式构建程序的大部分代码并不理想。
承诺
当这些概念可以用值表示时,处理抽象概念通常更容易。在异步操作的情况下,你可以用一个表示未来事件的对象代替安排在未来某个时间点调用函数。
这就是标准类 Promise
的用途。承诺是一个可能在某个时间点完成并生成值的异步操作。它能够通知任何感兴趣的人其值何时可用。
创建承诺的最简单方法是调用 Promise.resolve
。此函数确保你提供的 value 被包装在一个 promise 中。如果它已经是 promise,则直接返回,否则你会得到一个新的 promise,它立即完成,并将你的 value 作为结果。
let fifteen = Promise.resolve(15); fifteen.then(value => console.log(`Got ${value}`)); // → Got 15
要获取 promise 的结果,可以使用其 then
方法。这会注册一个回调函数,当 promise 解析并生成值时调用。你可以向单个 promise 添加多个回调,即使你在 promise 已经解析(完成)后添加它们,它们也会被调用。
但这并不是 then
方法所做的全部。它返回另一个 promise,该 promise 解析为处理程序函数返回的值,或者如果该函数返回 promise,则等待该 promise,然后解析为其结果。
将 promise 视为将值移动到异步现实中的设备是很有用的。一个普通的值只是在那里。一个承诺的值是一个可能已经在那里或可能在未来的某个时间点出现的 value。用 promise 定义的计算作用于这些包装的 value,并在 value 可用时异步执行。
要创建 promise,可以使用 Promise
作为构造函数。它有一个有点奇怪的接口,构造函数期望一个函数作为参数,它会立即调用该函数,并将一个函数传递给它,该函数可以用来解析 promise。它以这种方式工作,而不是例如使用 resolve
方法,这样只有创建 promise 的代码才能解析它。
以下是如何为 readStorage
函数创建基于 promise 的接口
function storage(nest, name) { return new Promise(resolve => { nest.readStorage(name, result => resolve(result)); }); } storage(bigOak, "enemies") .then(value => console.log("Got", value));
此异步函数返回一个有意义的值。这是 promise 的主要优势——它们简化了异步函数的使用。你不必再传递回调,基于 promise 的函数看起来类似于普通函数:它们将输入作为参数并返回其输出。唯一的区别是输出可能尚未可用。
失败
常规 JavaScript 计算可能会因抛出异常而失败。异步计算通常需要类似的东西。网络请求可能会失败,或者异步计算的一部分代码可能会抛出异常。
基于回调的异步编程中最紧迫的问题之一是它使确保错误正确报告给回调变得极其困难。
一个广泛使用的约定是,回调的第一个参数用于指示操作失败,第二个参数包含操作成功时生成的值。此类回调函数必须始终检查它们是否收到异常,并确保它们引起的任何问题(包括它们调用的函数抛出的异常)都被捕获并传递给正确的函数。
承诺使这变得更容易。它们可以被解析(操作成功完成)或拒绝(操作失败)。解析处理程序(如使用 then
注册的处理程序)仅在操作成功时调用,而拒绝会自动传播到由 then
返回的新的 promise。当处理程序抛出异常时,这会自动导致其 then
调用产生的 promise 被拒绝。因此,如果异步操作链中的任何元素失败,整个链的结果将标记为拒绝,并且不会调用超过失败点的任何成功处理程序。
与解析 promise 提供 value 类似,拒绝 promise 也提供 value,通常称为拒绝的原因。当处理程序函数中的异常导致拒绝时,异常值用作原因。类似地,当处理程序返回一个被拒绝的 promise 时,该拒绝会流入下一个 promise。有一个 Promise.reject
函数创建一个新的、立即被拒绝的 promise。
要显式地处理此类拒绝,promise 具有 catch
方法,该方法会注册一个处理程序,在 promise 被拒绝时调用,类似于 then
处理程序如何处理正常解析。它也与 then
非常相似,因为它返回一个新的 promise,如果 promise 正常解析,该 promise 解析为原始 promise 的值;否则解析为 catch
处理程序的结果。如果 catch
处理程序抛出错误,则新的 promise 也会被拒绝。
作为简写,then
还接受拒绝处理程序作为第二个参数,因此你可以在单个方法调用中安装两种类型的处理程序。
传递给 Promise
构造函数的函数接收第二个参数,与解析函数一起,它可以用来拒绝新的 promise。
由对 then
和 catch
的调用创建的 promise 值链可以被视为一个管道,异步值或失败通过它移动。由于此类链是通过注册处理程序创建的,因此每个链接都与一个成功处理程序或一个拒绝处理程序(或两者)相关联。与结果类型(成功或失败)不匹配的处理程序将被忽略。但那些匹配的处理程序会被调用,它们的结果决定了下一个值是什么类型——如果返回一个非 promise 值,则是成功;如果抛出异常,则是拒绝;如果返回一个 promise,则是 promise 的结果。
new Promise((_, reject) => reject(new Error("Fail"))) .then(value => console.log("Handler 1")) .catch(reason => { console.log("Caught failure " + reason); return "nothing"; }) .then(value => console.log("Handler 2", value)); // → Caught failure Error: Fail // → Handler 2 nothing
与未捕获的异常由环境处理类似,JavaScript 环境可以检测到 promise 拒绝未被处理,并将其报告为错误。
网络很困难
偶尔,乌鸦的镜像系统没有足够的灯光来传输信号,或者某些东西阻塞了信号的路径。信号可能会被发送但从未被接收。
照目前的情况来看,这只会导致传递给 send
的回调永远不会被调用,这很可能会导致程序停止,甚至没有注意到存在问题。如果在经过一段时间的没有响应后,请求会超时并报告失败,那就太好了。
通常,传输失败是随机事故,就像汽车的车灯干扰光信号一样,简单地重试请求可能会导致它成功。因此,当我们在做这件事的时候,让我们让我们的请求函数在放弃之前自动重试发送请求几次。
而且,既然我们已经确定 promise 是件好事,我们还将让我们的请求函数返回一个 promise。就它们可以表达的内容而言,回调和 promise 是等效的。基于回调的函数可以被包装以公开基于 promise 的接口,反之亦然。
即使请求及其响应已成功传递,响应也可能指示失败——例如,如果请求尝试使用尚未定义的请求类型,或者处理程序抛出错误。为了支持这一点,send
和 defineRequestType
遵循之前提到的约定,其中传递给回调的第一个参数是失败原因(如果有的话),第二个参数是实际结果。
这些可以通过我们的包装器转换为 promise 解析和拒绝。
class Timeout extends Error {} function request(nest, target, type, content) { return new Promise((resolve, reject) => { let done = false; function attempt(n) { nest.send(target, type, content, (failed, value) => { done = true; if (failed) reject(failed); else resolve(value); }); setTimeout(() => { if (done) return; else if (n < 3) attempt(n + 1); else reject(new Timeout("Timed out")); }, 250); } attempt(1); }); }
由于 promise 只能被解析(或拒绝)一次,因此这将起作用。第一次调用 resolve
或 reject
将决定 promise 的结果,而由请求在另一个请求完成之后返回而导致的后续调用将被忽略。
要构建一个异步循环(用于重试),我们需要使用递归函数——常规循环不允许我们停止并等待异步操作。attempt
函数尝试发送一次请求。它还会设置一个超时,如果在 250 毫秒后没有收到响应,则会启动下一个尝试,或者如果这是第三次尝试,则会使用 Timeout
实例作为原因拒绝 promise。
每隔四分之一秒重试一次,如果在四分之三秒内没有收到响应就放弃,这当然有点武断。如果请求确实通过了,但处理程序只是花费了更长的时间,也可能导致请求被多次传递。我们会牢记这个问题编写处理程序——重复消息应该无害。
总的来说,我们今天不会构建一个世界级的、强大的网络。但这没关系——乌鸦对计算的期望还没有很高。
为了完全隔离回调,我们将继续定义一个 defineRequestType
的包装器,它允许处理函数返回一个 Promise 或普通值,并将其连接到我们的回调。
function requestType(name, handler) { defineRequestType(name, (nest, content, source, callback) => { try { Promise.resolve(handler(nest, content, source)) .then(response => callback(null, response), failure => callback(failure)); } catch (exception) { callback(exception); } }); }
Promise.resolve
用于将 handler
返回的值转换为 Promise(如果它还不是 Promise)。
请注意,对 handler
的调用必须包装在 try
块中,以确保它引发的任何直接异常都会传递给回调。这很好地说明了使用原始回调正确处理错误的难度——很容易忘记正确地将异常路由到那里,如果你没有这样做,故障将不会报告给正确的回调。Promise 使此过程变得几乎自动,从而降低了出错的可能性。
Promise 集合
每个巢穴计算机在其 neighbors
属性中保留一个在传输距离内的其他巢穴的数组。要检查其中哪些当前可达,您可以编写一个函数,该函数尝试向每个巢穴发送一个 "ping"
请求(一个只要求响应的请求),并查看哪些请求返回。
在处理同时运行的 Promise 集合时,Promise.all
函数可能很有用。它返回一个 Promise,该 Promise 等待数组中的所有 Promise 解决,然后解决为这些 Promise 生成的值的数组(与原始数组的顺序相同)。如果任何 Promise 被拒绝,Promise.all
的结果本身就被拒绝。
requestType("ping", () => "pong"); function availableNeighbors(nest) { let requests = nest.neighbors.map(neighbor => { return request(nest, neighbor, "ping") .then(() => true, () => false); }); return Promise.all(requests).then(result => { return nest.neighbors.filter((_, i) => result[i]); }); }
当邻居不可用时,我们不希望整个组合 Promise 失败,因为那样我们仍然一无所知。因此,映射到邻居集以将其转换为请求 Promise 的函数会附加处理程序,使成功的请求生成 true
,而被拒绝的请求生成 false
。
在组合 Promise 的处理程序中,filter
用于从 neighbors
数组中删除其对应值为 false 的元素。这利用了 filter
将当前元素的数组索引作为第二个参数传递给其过滤函数这一事实(map
、some
和类似的高阶数组方法也是如此)。
网络泛洪
为了将信息广播到整个网络,一种解决方案是建立一种类型的请求,该请求会自动转发给邻居。然后,这些邻居依次将其转发给他们的邻居,直到整个网络都收到消息。
import {everywhere} from "./crow-tech"; everywhere(nest => { nest.state.gossip = []; }); function sendGossip(nest, message, exceptFor = null) { nest.state.gossip.push(message); for (let neighbor of nest.neighbors) { if (neighbor == exceptFor) continue; request(nest, neighbor, "gossip", message); } } requestType("gossip", (nest, message, source) => { if (nest.state.gossip.includes(message)) return; console.log(`${nest.name} received gossip '${ message}' from ${source}`); sendGossip(nest, message, source); });
为了避免将相同的消息永远发送到网络中,每个巢穴都保留一个它已经看到的八卦字符串数组。为了定义此数组,我们使用 everywhere
函数(它在每个巢穴上运行代码)向巢穴的 state
对象添加一个属性,我们将在其中保留巢穴本地状态。
当一个巢穴收到重复的八卦消息时(这很有可能发生,因为每个人都盲目地重新发送它们),它会忽略它。但是,当它收到一条新消息时,它会兴奋地告诉所有邻居,除了发送消息给它的那个邻居。
这将导致新的八卦像墨水在水中扩散一样传播到整个网络。即使某些连接当前无法工作,如果有通往某个特定巢穴的替代路线,八卦也会通过那里到达该巢穴。
这种网络通信方式称为 *泛洪*——它用信息泛洪网络,直到所有节点都收到它。
我们可以调用 sendGossip
来查看消息在村庄中的流动情况。
sendGossip(bigOak, "Kids with airgun in the park");
消息路由
如果某个节点想要与另一个节点通信,泛洪就不是一种非常有效的方法。特别是当网络很大时,这会导致大量无用的数据传输。
另一种方法是建立一种方法,使消息从一个节点跳到另一个节点,直到它们到达目的地。困难在于它需要了解网络的布局。要将请求发送到遥远巢穴的方向,必须知道哪个邻居巢穴可以使其更接近目的地。将其发送到错误的方向不会有什么帮助。
由于每个巢穴只知道其直接邻居,因此它没有计算路线所需的信息。我们必须以某种方式将有关这些连接的信息传播到所有巢穴,最好以一种允许它随着时间的推移而改变的方式,当巢穴被废弃或新巢穴被建造时。
我们可以再次使用泛洪,但现在我们不检查给定消息是否已被接收,而是检查给定巢穴的新邻居集是否与其当前拥有的集匹配。
requestType("connections", (nest, {name, neighbors}, source) => { let connections = nest.state.connections; if (JSON.stringify(connections.get(name)) == JSON.stringify(neighbors)) return; connections.set(name, neighbors); broadcastConnections(nest, name, source); }); function broadcastConnections(nest, name, exceptFor = null) { for (let neighbor of nest.neighbors) { if (neighbor == exceptFor) continue; request(nest, neighbor, "connections", { name, neighbors: nest.state.connections.get(name) }); } } everywhere(nest => { nest.state.connections = new Map(); nest.state.connections.set(nest.name, nest.neighbors); broadcastConnections(nest, nest.name); });
比较使用 JSON.stringify
,因为 ==
在对象或数组上只会在两个值完全相同时返回 true,而这正是我们不需要的。比较 JSON 字符串是比较其内容的一种粗略但有效的方法。
这些节点立即开始广播其连接,这应该(除非某些节点完全无法访问)迅速为每个节点提供当前网络图的映射。
在图形中可以做的一件事是在其中查找路线,正如我们在 第 7 章 中看到的。如果我们有一条通往消息目的地的路线,我们就知道应该将消息发送到哪个方向。
此 findRoute
函数(与 第 7 章 中的 findRoute
非常相似)搜索到达网络中给定节点的方法。但它不会返回整个路线,而是只返回下一步。该下一个巢穴将使用其当前有关网络的信息,自行决定将消息发送到哪里。
function findRoute(from, to, connections) { let work = [{at: from, via: null}]; for (let i = 0; i < work.length; i++) { let {at, via} = work[i]; for (let next of connections.get(at) || []) { if (next == to) return via; if (!work.some(w => w.at == next)) { work.push({at: next, via: via || next}); } } } return null; }
现在我们可以构建一个可以发送远程消息的函数。如果消息的地址是直接邻居,则按常规传递。如果不是,则将其打包到一个对象中,并使用 "route"
请求类型发送给更靠近目标的邻居,这将导致该邻居重复相同的行为。
function routeRequest(nest, target, type, content) { if (nest.neighbors.includes(target)) { return request(nest, target, type, content); } else { let via = findRoute(nest.name, target, nest.state.connections); if (!via) throw new Error(`No route to ${target}`); return request(nest, via, "route", {target, type, content}); } } requestType("route", (nest, {target, type, content}) => { return routeRequest(nest, target, type, content); });
现在我们可以向教堂塔楼的巢穴发送一条消息,它距离我们四个网络跳跃。
routeRequest(bigOak, "Church Tower", "note", "Incoming jackdaws!");
我们在一个原始的通信系统之上构建了几个功能层,以使其使用起来方便。这是一个很好的(尽管简化了)模型,说明了真实计算机网络的工作方式。
计算机网络的一个显著特点是它们不可靠——在它们之上构建的抽象可以提供帮助,但你不能消除网络故障。因此,网络编程通常很大程度上是关于预测和处理故障的。
异步函数
为了存储重要信息,乌鸦会将其复制到多个巢穴中。这样,当一只鹰摧毁一个巢穴时,信息就不会丢失。
为了检索某个巢穴计算机在其自身存储灯泡中没有的特定信息,它可能会咨询网络中的其他随机巢穴,直到找到一个拥有该信息的巢穴。
requestType("storage", (nest, name) => storage(nest, name)); function findInStorage(nest, name) { return storage(nest, name).then(found => { if (found != null) return found; else return findInRemoteStorage(nest, name); }); } function network(nest) { return Array.from(nest.state.connections.keys()); } function findInRemoteStorage(nest, name) { let sources = network(nest).filter(n => n != nest.name); function next() { if (sources.length == 0) { return Promise.reject(new Error("Not found")); } else { let source = sources[Math.floor(Math.random() * sources.length)]; sources = sources.filter(n => n != source); return routeRequest(nest, source, "storage", name) .then(value => value != null ? value : next(), next); } } return next(); }
因为 connections
是一个 Map
,所以 Object.keys
无法对它使用。它有一个 keys
*方法*,但它返回的是迭代器而不是数组。迭代器(或可迭代值)可以使用 Array.from
函数转换为数组。
即使使用 Promise,这仍然是一些相当笨拙的代码。多个异步操作以非明显的方式链接在一起。我们再次需要一个递归函数 (next
) 来模拟遍历巢穴。
而代码实际执行的操作是完全线性的——它总是等待前一个操作完成,然后再开始下一个操作。在同步编程模型中,表达起来会更简单。
好消息是 JavaScript 允许你编写伪同步代码来描述异步计算。一个 async
函数是一个隐式返回 Promise 的函数,它可以在其主体中 await
其他 Promise,以一种看起来是同步的方式。
async function findInStorage(nest, name) { let local = await storage(nest, name); if (local != null) return local; let sources = network(nest).filter(n => n != nest.name); while (sources.length > 0) { let source = sources[Math.floor(Math.random() * sources.length)]; sources = sources.filter(n => n != source); try { let found = await routeRequest(nest, source, "storage", name); if (found != null) return found; } catch (_) {} } throw new Error("Not found"); }
一个 async
函数在 function
关键字之前用 async
标记。方法也可以通过在它们的名字之前写 async
来使其成为 async
。当调用这样的函数或方法时,它会返回一个 Promise。一旦主体返回某些内容,该 Promise 就会被解决。如果它抛出异常,则该 Promise 被拒绝。
findInStorage(bigOak, "events on 2017-12-21") .then(console.log);
在一个 async
函数中,可以在一个表达式前面加上 await
来等待一个 Promise 解决,然后才继续执行该函数。
这样的函数不再像普通的 JavaScript 函数那样,从头到尾一次性运行。相反,它可以在任何包含 await
的地方被冻结,并在稍后恢复。
对于非平凡的异步代码,这种表示通常比直接使用 promise 更方便。即使您需要做一些不符合同步模型的事情,例如同时执行多个操作,也可以很容易地将 await
与直接使用 promise 结合起来。
生成器
函数暂停然后恢复的能力并非 async
函数所独有。JavaScript 还具有一项称为生成器函数的功能。它们很相似,但没有 promise。
当您使用 function*
(在 function
关键字后面放置一个星号)定义函数时,它就会变成一个生成器。当您调用一个生成器时,它会返回一个迭代器,我们已经在第 6 章中见过。
function* powers(n) { for (let current = n;; current *= n) { yield current; } } for (let power of powers(3)) { if (power > 50) break; console.log(power); } // → 3 // → 9 // → 27
最初,当您调用 powers
时,函数在开始处被冻结。每次您在迭代器上调用 next
时,函数都会运行,直到遇到 yield
表达式,该表达式会暂停函数,并使 yield 的值成为迭代器产生的下一个值。当函数返回时(示例中的函数永远不会返回),迭代器就完成了。
当您使用生成器函数时,编写迭代器通常要容易得多。Group
类(来自第 6 章中的练习)的迭代器可以使用此生成器编写
Group.prototype[Symbol.iterator] = function*() { for (let i = 0; i < this.members.length; i++) { yield this.members[i]; } };
不再需要创建对象来保存迭代状态——生成器在每次 yield 时会自动保存其局部状态。
这样的 yield
表达式只能直接出现在生成器函数本身中,而不能出现在您在其中定义的内部函数中。生成器在 yield 时保存的状态仅限于其局部环境和它 yield 的位置。
async
函数是一种特殊的生成器类型。它在被调用时会生成一个 promise,该 promise 在函数返回(完成)时被解决,在函数抛出异常时被拒绝。每当它 yield (等待)一个 promise 时,该 promise 的结果(值或抛出的异常)就是 await
表达式的结果。
事件循环
异步程序是分段执行的。每一段代码可能会启动一些操作,并安排在操作完成或失败时执行的代码。在这些代码段之间,程序处于空闲状态,等待下一个操作。
因此,回调不是由安排它们的代码直接调用。如果我在函数内部调用 setTimeout
,则该函数在回调函数被调用之前就会返回。当回调返回时,控制权不会返回到安排它的函数。
异步行为发生在它自己的空函数调用堆栈上。这是在没有 promise 的情况下,跨异步代码管理异常很困难的原因之一。由于每个回调都从一个几乎为空的堆栈开始,因此当您的 catch
处理程序抛出异常时,它们不会在堆栈中。
try { setTimeout(() => { throw new Error("Woosh"); }, 20); } catch (_) { // This will not run console.log("Caught!"); }
无论事件(如超时或传入请求)多么接近地发生,JavaScript 环境一次只运行一个程序。您可以将其视为在程序周围运行一个大型循环,称为事件循环。当没有事情需要做时,该循环就会停止。但是,当事件进来时,它们会被添加到队列中,它们的代码会一个接一个地执行。因为没有两个事物同时运行,所以运行缓慢的代码可能会延迟其他事件的处理。
此示例设置了超时,但随后一直拖延到超时预期的时间点之后,导致超时延迟。
let start = Date.now(); setTimeout(() => { console.log("Timeout ran at", Date.now() - start); }, 20); while (Date.now() < start + 50) {} console.log("Wasted time until", Date.now() - start); // → Wasted time until 50 // → Timeout ran at 55
promise 始终作为新事件解决或拒绝。即使 promise 已经解决,等待它也会导致您的回调在当前脚本完成之后运行,而不是立即运行。
Promise.resolve("Done").then(console.log); console.log("Me first!"); // → Me first! // → Done
在后面的章节中,我们将看到在事件循环上运行的各种其他类型的事件。
异步错误
当您的程序同步运行时,一次完成,除了程序本身进行的更改之外,没有状态更改。对于异步程序来说,情况有所不同——它们在执行过程中可能存在间隙,在此期间其他代码可以运行。
让我们看一个例子。我们乌鸦的爱好之一是统计每年的村庄中孵化的幼鸟数量。巢穴将此计数存储在其存储球茎中。以下代码尝试列出给定年份中所有巢穴的计数
function anyStorage(nest, source, name) { if (source == nest.name) return storage(nest, name); else return routeRequest(nest, source, "storage", name); } async function chicks(nest, year) { let list = ""; await Promise.all(network(nest).map(async name => { list += `${name}: ${ await anyStorage(nest, name, `chicks in ${year}`) }\n`; })); return list; }
async name =>
部分表明箭头函数也可以通过在前面加上 async
关键字来使其成为 async
。
这段代码乍看起来并不可疑......它将 async
箭头函数映射到巢穴集合上,创建一个 promise 数组,然后使用 Promise.all
等待所有这些 promise 完成,然后返回它们构建的列表。
chicks(bigOak, 2017).then(console.log);
问题在于 +=
运算符,它获取语句开始执行时的 list
的当前值,然后,当 await
完成时,将 list
绑定设置为该值加上添加的字符串。
但是在语句开始执行的时间和它完成的时间之间存在一个异步间隙。map
表达式在任何内容被添加到列表之前运行,因此每个 +=
运算符都从一个空字符串开始,并在其存储检索完成时结束,将 list
设置为一个单行列表——将它的行添加到空字符串的结果。
这本来可以很容易地避免,方法是将行从映射的 promise 中返回,并在 Promise.all
的结果上调用 join
,而不是通过更改绑定来构建列表。与往常一样,计算新值比更改现有值更不容易出错。
async function chicks(nest, year) { let lines = network(nest).map(async name => { return name + ": " + await anyStorage(nest, name, `chicks in ${year}`); }); return (await Promise.all(lines)).join("\n"); }
很容易犯这样的错误,尤其是在使用 await
时,您应该注意代码中的间隙出现在哪里。JavaScript 的显式异步性的一个优势(无论是通过回调、promise 还是 await
)是,识别这些间隙相对容易。
总结
异步编程使得能够表达等待长时间运行的操作,而不会在这些操作期间冻结程序。JavaScript 环境通常使用回调来实现这种编程风格,回调是在操作完成后调用的函数。事件循环安排在适当的时候调用这些回调,一个接一个地调用,这样它们的执行就不会重叠。
promise 和 async
函数使异步编程更容易,promise 是表示将来可能完成的操作的对象,async
函数允许您像编写同步程序一样编写异步程序。
练习
跟踪手术刀
村里的乌鸦拥有一把老式手术刀,它们偶尔会在特殊任务中使用——例如,切开纱门或包装。为了能够快速找到它,每次手术刀被移到另一个巢穴时,都会在拥有它和取走它的巢穴的存储中添加一个条目,名为 "scalpel"
,其新位置作为值。
这意味着找到手术刀只是跟踪存储条目的面包屑踪迹,直到找到一个巢穴,在那里它指向巢穴本身。
编写一个 async
函数 locateScalpel
来执行此操作,从运行它的巢穴开始。您可以使用前面定义的 anyStorage
函数来访问任意巢穴的存储。手术刀已经存在了足够长的时间,因此您可以假设每个巢穴在其数据存储中都有一个 "scalpel"
条目。
接下来,再次编写相同的函数,但不使用 async
和 await
。
请求失败是否会在两个版本中都正确地显示为返回 promise 的拒绝?如何?
async function locateScalpel(nest) { // Your code here. } function locateScalpel2(nest) { // Your code here. } locateScalpel(bigOak).then(console.log); // → Butcher Shop
这可以使用单个循环来完成,该循环搜索巢穴,当找到一个不匹配当前巢穴名称的值时向前移动,并在找到一个匹配的值时返回该名称。在 async
函数中,可以使用常规的 for
或 while
循环。
要在普通函数中执行相同的操作,您必须使用递归函数构建循环。最简单的方法是让该函数通过在检索存储值的 promise 上调用 then
来返回一个 promise。根据该值是否与当前巢穴的名称匹配,处理程序返回该值或通过再次调用循环函数创建的另一个 promise。
在 async
函数中,被拒绝的 promise 会被 await
转换为异常。当 async
函数抛出异常时,它的 promise 会被拒绝。所以这有效。
如果您按照前面所述实现了非 `async` 函数,那么 `then` 的工作方式也会自动导致失败最终出现在返回的 Promise 中。如果请求失败,则不会调用传递给 `then` 的处理程序,它返回的 Promise 将使用相同的理由被拒绝。
构建 Promise.all
给定一个 Promise 数组,`Promise.all` 返回一个 Promise,该 Promise 等待数组中所有 Promise 完成。然后它成功,生成一个结果值数组。如果数组中的某个 Promise 失败,则 `all` 返回的 Promise 也将失败,失败原因来自失败的 Promise。
自己实现类似的东西,作为一个名为 `Promise_all` 的常规函数。
请记住,在 Promise 成功或失败后,它不能再次成功或失败,对解析它的函数的进一步调用将被忽略。这可以简化处理 Promise 失败的方式。
function Promise_all(promises) { return new Promise((resolve, reject) => { // Your code here. }); } // Test code. Promise_all([]).then(array => { console.log("This should be []:", array); }); function soon(val) { return new Promise(resolve => { setTimeout(() => resolve(val), Math.random() * 500); }); } Promise_all([soon(1), soon(2), soon(3)]).then(array => { console.log("This should be [1, 2, 3]:", array); }); Promise_all([soon(1), Promise.reject("X"), soon(3)]) .then(array => { console.log("We should not get here"); }) .catch(error => { if (error != "X") { console.log("Unexpected failure:", error); } });
传递给 `Promise` 构造函数的函数必须在给定数组中的每个 Promise 上调用 `then`。当其中一个成功时,需要发生两件事。结果值需要存储在结果数组的正确位置,并且我们必须检查这是否为最后一个待处理的 Promise,如果是,则完成我们自己的 Promise。
后者可以通过一个计数器来完成,该计数器初始化为输入数组的长度,并且每次 Promise 成功时减去 1。当它达到 0 时,我们就完成了。确保您考虑到输入数组为空的情况(因此永远不会解析任何 Promise)。
处理失败需要一些思考,但事实证明非常简单。只需将包装 Promise 的 `reject` 函数作为 `catch` 处理程序或作为 `then` 的第二个参数传递给数组中的每个 Promise,这样其中一个的失败就会触发整个包装 Promise 的拒绝。