14 KiB
十一、异步编程
孰能浊以澄?静之徐清;
孰能安以久?动之徐生。
老子,《道德经》
计算机的核心部分称为处理器,它执行构成我们程序的各个步骤。 到目前为止,我们看到的程序都是让处理器忙碌,直到他们完成工作。 处理数字的循环之类的东西,几乎完全取决于处理器的速度。
但是许多程序与处理器之外的东西交互。 例如,他们可能通过计算机网络进行通信或从硬盘请求数据 - 这比从内存获取数据要慢很多。
当发生这种事情时,让处理器处于闲置状态是可耻的 - 在此期间可以做一些其他工作。 某种程度上,它由您的操作系统处理,它将在多个正在运行的程序之间切换处理器。 但是,我们希望单个程序在等待网络请求时能做一些事情,这并没有什么帮助。
异步
在同步编程模型中,一次只发生一件事。 当您调用执行长时间操作的函数时,它只会在操作完成时返回,并且可以返回结果。 这会在您执行操作的时候停止您的程序。
异步模型允许同时发生多个事件。 当你开始一个动作时,你的程序会继续运行。 当动作结束时,程序会受到通知并访问结果(例如从磁盘读取的数据)。
我们可以使用一个小例子来比较同步和异步编程:一个从网络获取两个资源然后合并结果的程序。
在同步环境中,只有在请求函数完成工作后,它才返回,执行此任务的最简单方法是逐个创建请求。 这有一个缺点,仅当第一个请求完成时,第二个请求才会启动。 所花费的总时间至少是两个响应时间的总和。
在同步系统中解决这个问题的方法是启动额外的控制线程。 线程是另一个正在运行的程序,它的执行可能会交叉在操作系统与其他程序当中 - 因为大多数现代计算机都包含多个处理器,所以多个线程甚至可能同时运行在不同的处理器上。 第二个线程可以启动第二个请求,然后两个线程等待它们的结果返回,之后它们重新同步来组合它们的结果。
在下图中,粗线表示程序正常花费运行的时间,细线表示等待网络所花费的时间。 在同步模型中,网络所花费的时间是给定控制线程的时间线的一部分。 在异步模型中,从概念上讲,启动网络操作会导致时间轴中出现分裂。 启动该动作的程序将继续运行,并且该动作将与其同时发生,并在程序结束时通知该程序。
另一种描述差异的方式是,等待动作完成在同步模型中是隐式的,而在异步模型中,在我们的控制之下,它是显式的。
异步性是个双刃剑。 它可以生成不适合直线控制模型的程序,但它也可以使直线控制的程序更加笨拙。 本章后面我们会看到一些方法来解决这种笨拙。
两种重要的 JavaScript 编程平台(浏览器和 Node.js)都可能需要一段时间的异步操作,而不是依赖线程。 由于使用线程进行编程非常困难(理解程序在同时执行多个事情时所做的事情要困难得多),这通常被认为是一件好事。
乌鸦科技
大多数人都知道乌鸦非常聪明。 他们可以使用工具,提前计划,记住事情,甚至可以互相沟通这些事情。
大多数人不知道的是,他们能够做一些事情,并且对我们隐藏得很好。我听说一个有声望的(但也有点古怪的)专家 corvids 认为,乌鸦技术并不落后于人类的技术,并且正在迎头赶上。
例如,许多乌鸦文明能够构建计算设备。 这些并不是电子的,就像人类的计算设备一样,但是它们操作微小昆虫的行动,这种昆虫是与白蚁密切相关的物种,它与乌鸦形成了共生关系。 鸟类为它们提供食物,对之对应,昆虫建立并操作复杂的殖民地,在其内部的生物的帮助下进行计算。
这些殖民地通常位于大而久远的鸟巢中。 鸟类和昆虫一起工作,建立一个球形粘土结构的网络,隐藏在巢的树枝之间,昆虫在其中生活和工作。
为了与其他设备通信,这些机器使用光信号。 鸟类在特殊的通讯茎中嵌入反光材料片段,昆虫校准这些反光材料将光线反射到另一个鸟巢,将数据编码为一系列快速闪光。 这意味着只有具有完整视觉连接的巢才能沟通。
我们的朋友 corvid 专家已经绘制了 Rhône 河畔的 Hières-sur-Amby 村的乌鸦鸟巢网络。 这张地图显示了鸟巢及其连接。
在一个令人震惊的趋同进化的例子中,乌鸦计算机运行 JavaScript。 在本章中,我们将为他们编写一些基本的网络函数。
回调
异步编程的一种方法是使执行慢动作的函数接受额外的参数,即回调函数。动作开始,当它结束时,使用结果调用回调函数。
例如,在 Node.js 和浏览器中都可用的setTimeout
函数,等待给定的毫秒数(一秒为一千毫秒),然后调用一个函数。
setTimeout(() => console.log("Tick"), 500);
等待通常不是一种非常重要的工作,但在做一些事情时,例如更新动画或检查某件事是否花费比给定时间更长的时间,可能很有用。
使用回调在一行中执行多个异步操作,意味着您必须不断传递新函数来处理操作之后的计算延续。
大多数乌鸦鸟巢计算机都有一个长期的数据存储设备,其中的信息刻在小树枝上,以便以后可以检索。雕刻或查找一段数据需要一些时间,所以长期存储的接口是异步的,并使用回调函数。
存储设备按照名称存储 JSON 编码的数据片段。乌鸦可以存储它隐藏食物的地方的信息,其名称为"food caches"
,它可以包含指向其他数据片段的名称数组,描述实际的缓存。为了在 Big Oak 鸟巢的存储设备中查找食物缓存,乌鸦可以运行这样的代码:
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
属性,保存他们的名字。
给handler
的第四个参数done,是一个回调函数,它在完成请求时必须调用。如果我们使用了处理器的返回值作为响应值,那么这意味着请求处理器本身不能执行异步操作。执行异步工作的函数通常会在完成工作之前返回,安排回调函数在完成时调用。所以我们需要一些异步机制 - 在这种情况下是另一个回调函数 - 在响应可用时发出信号。
某种程度上,异步性是传染的。任何调用异步的函数的函数,本身都必须是异步的,使用回调或类似的机制来传递其结果。调用回调函数比简单地返回一个值更容易出错,所以以这种方式构建程序的较大部分并不是很好。
Promise
当这些概念可以用值表示时,处理抽象概念通常更容易。 在异步操作的情况下,你不需要安排将来某个时候调用的函数,而是返回一个代表这个未来事件的对象。
这是标准类Promise
的用途。 Promise
是一种异步行为,可以在某个时刻完成并产生一个值。 当值可用时,它能够通知任何感兴趣的人。
创建Promise
的最简单方法是调用Promise.resolve
。 这个函数确保你给它的值包含在一个Promise
中。 如果它已经是Promise
,那么仅仅返回它 - 否则,你会得到一个新的Promise
,并使用你的值立即结束。
let fifteen = Promise.resolve(15);
fifteen.then(value => console.log(`Got ${value}`));
// → Got 15
为了获得Promise
的结果,可以使用它的then
方法。 它注册了一个回调函数,当Promise
解析并产生一个值时被调用。 您可以将多个回调添加到单个Promise
中,即使在Promise
解析(完成)后添加它们,它们也会被调用。
但那不是then
方法所做的一切。 它返回另一个Promise
,它解析处理器函数返回的值,或者如果返回Promise
,则等待该Promise
,然后解析为结果。
将Promise
视为一种手段,将值转化为异步现实,是有用处的。 一个正常的值就在那里。promised 的值是未来可能存在或可能出现的值。 根据Promise
定义的计算对这些包装值起作用,并在值可用时异步执行。
为了创建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 计算可能会因抛出异常而失败。 异步计算经常需要类似的东西。 网络请求可能会失败,或者作为异步计算的一部分的某些代码,可能会引发异常。
异步编程的回调风格中最紧迫的问题之一是,确保将失败正确地报告给回调函数,是非常困难的。
一个广泛使用的约定是,回调函数的第一个参数用于指示操作失败,第二个参数包含操作成功时生成的值。 这种回调函数必须始终检查它们是否收到异常,并确保它们引起的任何问题,包括它们调用的函数所抛出的异常,都会被捕获并提供给正确的函数。
Promise
使这更容易。可以解决它们(操作成功完成)或拒绝(失败)。只有在操作成功时,才会调用解析处理器(使用then
注册),并且拒绝会自动传播给由then
返回的新Promise
。当一个处理器抛出一个异常时,这会自动使then
调用产生的Promise
被拒绝。因此,如果异步操作链中的任何元素失败,则整个链的结果被标记为拒绝,并且不会调用失败位置之后的任何常规处理器。
就像Promise
的解析提供了一个值,拒绝它也提供了一个值,通常称为拒绝的原因。当处理器中的异常导致拒绝时,异常值将用作原因。同样,当处理器返回被拒绝的Promise
时,拒绝流入下一个Promise
。Promise.reject
函数会创建一个新的,立即被拒绝的Promise
。
为了明确地处理这种拒绝,Promise
有一个catch
方法,用于注册一个处理器,当Promise
被拒绝时被调用,类似于处理器处理正常解析的方式。 这也非常类似于then
,因为它返回一个新的Promise
,如果它正常解析,它将解析原始Promise
的值,否则返回catch
处理器的结果。 如果catch
处理器抛出一个错误,新的Promise
也被拒绝。
作为简写,then
还接受拒绝处理器作为第二个参数,因此您可以在单个方法调用中,装配这两种的处理器。
传递给Promise
构造器的函数接收第二个参数,并与解析函数一起使用,它可以用来拒绝新的Promise
。
通过调用then
和catch
创建的Promise
值的链条,可以看作异步值或失败沿着它移动的流水线。 由于这种链条通过注册处理器来创建,因此每个链条都有一个成功处理器或与其关联的拒绝处理器(或两者都有)。 不匹配结果类型(成功或失败)的处理器将被忽略。 但是那些匹配的对象被调用,并且它们的结果决定了下一次会出现什么样的值 -- 返回非Promise
值时成功,当它抛出异常时拒绝,并且当它返回其中一个时是Promise
的结果。
就像环境处理未捕获的异常一样,JavaScript 环境可以检测未处理Promise
拒绝的时候,并将其报告为错误。