计算机的核心部分,即执行构成我们程序的各个步骤的部分,称为处理器。我们迄今为止看到的程序都会让处理器忙个不停,直到它们完成工作。像操作数字的循环这样的程序的执行速度几乎完全取决于计算机处理器和内存的速度。但是,许多程序会与处理器之外的事物交互。例如,它们可能会通过计算机网络进行通信,或从硬盘中请求数据--这比从内存中获取数据要慢得多。在这种情况下,让处理器闲置是很可惜的,因为在此期间,处理器可能还可以做一些其他工作。在某种程度上,操作系统可以解决这个问题,它会在多个运行程序之间切换处理器。但当我们希望单个程序在等待网络请求时能够取得进展时,这并没有什么帮助。
异步性
在同步编程模型中,事情是一件一件发生的。当你调用一个函数来执行一个长期运行的操作时,只有当该操作完成并可以返回结果时,函数才会返回。这样,你的程序就会停止执行该操作所需的时间。
异步模型允许多件事情同时发生。当你启动一个动作时,程序会继续运行。当操作结束时,程序会收到通知并访问结果(例如,从磁盘读取的数据)。
我们可以用一个小例子来比较同步和异步编程:一个程序通过网络发出两个请求,然后合并结果。在同步环境中,请求函数只有在完成工作后才会返回,执行这项任务的最简单方法就是一个接一个地发出请求。这样做的缺点是,只有在第一个请求完成后才会启动第二个请求。总耗时至少是两个响应时间之和。
在同步系统中,解决这一问题的方法是启动额外的控制线程。线程是另一个正在运行的程序,操作系统可以将其与其他程序交错执行--由于大多数现代计算机都包含多个处理器,因此多个线程甚至可以在不同的处理器上同时运行。第二个线程可以启动第二个请求,然后两个线程都等待它们的结果返回,之后它们重新同步,合并它们的结果。
在下图中,粗线代表程序正常运行的时间,细线代表等待网络的时间。在同步模型中,网络花费的时间是给定控制线程时间轴的一部分。在异步模型中,启动网络操作可以让程序继续运行,同时网络通信也在同时进行,并在结束时通知程序。
另一种描述区别的方法是,在同步模型中,等待操作完成是隐式的,而在异步模型中,等待操作完成是显式的--在我们的控制之下。
异步性是双向的。它使不符合直线控制模型的程序更容易表达,但也可能使符合直线控制模型的程序表达起来更困难。我们将在本章后半部分看到一些减少这种尴尬的方法。
两个著名的 JavaScript 编程平台--浏览器和 Node.js--都将可能需要一段时间的操作异步化,而不是依赖线程。由于使用线程编程是出了名的困难(当程序同时做多件事情时,理解它在做什么要困难得多),这通常被认为是件好事。
回调
异步编程的一种方法是让需要等待的函数接受一个额外的参数,即回调函数。异步函数启动一个进程,进行设置以便在进程结束时调用回调函数,然后返回。
例如,在 Node.js 和浏览器中都可用的 setTimeout 函数会等待给定的毫秒数,然后调用一个函数。
等待一般不是什么重要的工作,但当你需要安排某件事情在某个时间发生,或检查某些操作所需的时间是否比预期的要长时,等待就非常有用了。
另一个常见异步操作的例子是从设备存储中读取文件。想象一下,你有一个 readTextFile 函数,它以字符串的形式读取文件内容并将其传递给一个回调函数。
readTextFile 函数不是标准 JavaScript 的一部分。我们将在后面的章节中了解如何在浏览器和 Node.js 中读取文件。
使用回调连续执行多个异步操作意味着您必须不断传递新函数,以处理操作后的继续计算。比较两个文件并生成一个布尔值以指示其内容是否相同的异步函数可能如下所示:
这种编程方式是可行的,但每次异步操作的缩进程度都会增加,因为你最终会进入另一个函数。如果要做更复杂的事情,比如在循环中封装异步操作,就会变得很麻烦。
在某种程度上,异步性是会传染的。任何调用异步函数的函数本身都必须是异步的,使用回调或类似机制来传递结果。与简单返回值相比,调用回调涉及的问题更多,也更容易出错。
promise
构建异步程序的一个略有不同的方法是,让异步函数返回一个代表其(未来)结果的对象,而不是传递回调函数。这样,这些函数实际上返回了一些有意义的东西,程序的形状也更接近同步程序。这就是标准类 Promise 的作用。Promise 是一个收据,代表一个可能还未可用的值。它提供了一个 then 方法,允许你注册一个函数,当它所等待的操作完成时,该函数就会被调用。当promise被解析时,即其值可用时,此类函数(可以有多个)会被调用,并得到结果值。如果promise已经解析,则可以调用该promise的函数,但该函数仍会被调用。
创建promise的最简单方法是调用 Promise.resolve。该函数会确保你给它的值被封装在一个 promise 中。如果它已经是一个 promise,就会被简单地返回。否则,你将得到一个新的 promise,并立即以你的值作为结果进行解析。
要创建一个不会立即解析的promise,可以使用 Promise 作为构造函数。它有一个有点奇怪的接口:构造函数期待一个函数作为它的参数,它立即调用这个参数,并传递给它一个可以用来解析 promise 的函数。
例如,你可以这样为 readTextFile 函数创建一个基于promise的接口:
请注意,与回调式函数不同的是,这个异步函数返回的是一个有意义的值--一个在未来某个时刻提供文件内容的promise。then 方法的一个有用之处是,它本身返回另一个promise。这个promise解析为回调函数返回的值,如果返回的值是一个promise,则解析为该promise解析的值。因此,你可以将多个 then 调用 "链 "在一起,设置一系列异步操作。
该函数读取了一个包含大量文件名的文件,并返回该列表中随机文件的内容,展示了这种异步promise流水线:
函数会返回这一系列 then 调用的结果。初始promise以字符串形式获取文件列表。第一次 then 调用将字符串转换为行数组,产生一个新的 promise。第二个 then 调用从中随机抽取一行,产生第三个promise,得到一个文件名。最后一次 then 调用读取该文件,因此整个函数的结果是一个返回随机文件内容的 promise。
在这段代码中,前两个 then 调用中使用的函数会返回一个常规值,当函数返回时,该值会立即传入 then 返回的 promise 中。最后一个 then 调用会返回一个 promise(textFile(文件名)),使其成为一个实际的异步步骤。
我们也可以在单个 then 回调中执行所有这些步骤,因为只有最后一个步骤实际上是异步的。但是,这种只进行某些同步数据转换的 then 包装器通常很有用,例如,当你想返回一个产生某个异步结果的处理版本的 promise 时。
一般来说,我们可以将 promise 看作是一种工具,它可以让代码忽略值何时到达的问题。正常值必须实际存在,我们才能引用它。promise值是一个可能已经存在或可能在未来某个时间出现的值。根据promise定义的计算,通过将它们与调用连接在一起,在输入可用时异步执行。
失败
常规 JavaScript 计算可以通过抛出异常来失败。异步计算通常需要这样的功能。网络请求可能会失败,文件可能不存在,或者异步计算中的某些代码可能会抛出异常。异步编程的回调风格最紧迫的问题之一是,要确保向回调正确报告故障极为困难。
常见的惯例是使用回调的第一个参数来表示操作失败,第二个参数则传递操作成功时产生的值。
此类回调函数必须始终检查它们是否收到异常,并确保它们导致的任何问题(包括它们调用的函数抛出的异常)都被捕获并交给正确的函数。
promise使这一切变得更容易。它们既可以被解析(操作成功完成),也可以被拒绝(操作失败)。解析处理程序(通过 then 注册)只有在操作成功时才会被调用,而拒绝则会传播到 then 返回的新promise中。当处理程序抛出异常时,会自动导致由 then 调用产生的promise被拒绝。如果异步操作链中的任何元素失败,整个操作链的结果都会被标记为拒绝,并且不会调用失败点以外的成功处理程序。
就像解析一个promise会提供一个值一样,拒绝一个promise也会提供一个值,通常称为拒绝的原因。当处理程序函数中的异常导致拒绝时,异常值将被用作拒绝原因。同样,当处理程序返回一个被拒绝的promise时,该拒绝会流入下一个promise。有一个 Promise.reject 函数可以创建一个新的、立即被拒绝的promise。
为了显式地处理此类拒绝,promise有一个 catch 方法,当promise被拒绝时,该方法会注册一个要调用的处理程序,这与 then 处理程序处理正常解析的方式类似。它也非常像 then 方法,因为它会返回一个新的 promise,在正常解析时解析为原始 promise 的值,反之则解析为 catch 处理程序的结果。如果 catch 处理程序出错,新的 promise 也会被拒绝。
作为速记,then 也接受一个拒绝处理程序作为第二个参数,因此你可以在一个方法调用中安装两种类型的处理程序:
传递给 Promise 构造函数的函数会收到第二个参数,即 reject 函数,它可以用这个参数来拒绝新的 promise。当我们的 readTextFile 函数遇到问题时,它会将错误作为第二个参数传递给回调函数。我们的 textFile 封装程序应实际检查该参数,以便在失败时拒绝它返回的promise。
因此,通过调用 then 和 catch 创建的promise值链构成了一个流水线,异步值或故障就在流水线中移动。由于这些链是通过注册处理程序创建的,因此每个链接都与成功处理程序或拒绝处理程序(或两者)相关联。与结果类型(成功或失败)不匹配的处理程序会被忽略。与之匹配的处理程序会被调用,它们的结果决定了下一个值的类型--当它们返回非promise值时是成功,当它们抛出异常时是拒绝,当它们返回promise时是promise的结果。
第一个 then 处理程序函数没有被调用,因为在流水线的这一点上,promise 持有一个拒绝。catch 处理程序会处理该拒绝并返回一个值,然后将该值提供给第二个 then 处理程序函数。
就像环境会处理未捕获的异常一样,JavaScript 环境也能检测到未处理的promise拒绝,并将其报告为错误。
Carla
柏林的天气晴朗。退役的老机场跑道上挤满了骑自行车和直排轮滑的人。在垃圾箱附近的草地上,一群乌鸦吵吵嚷嚷地踱来踱去,试图说服一群游客放弃他们的三明治。
其中有一只乌鸦很引人注目--它是一只邋遢的大母鸦,右翼有几根白色羽毛。它以娴熟的技巧和自信的神情引诱人们,这说明它干这行已经很久了。当一位老人被另一只乌鸦的滑稽动作分心时,它轻而易举地扑了上去,从老人手里抢走了他吃了一半的馒头,然后扬长而去。这只大乌鸦看起来很有目的性,与其他看起来很高兴在这里闲逛的乌鸦相反。它带着战利品,径直飞向机库楼顶,消失在一个通风口里。
在楼内,你可以听到一种奇怪的敲击声--很轻,但持续不断。声音来自一个未完工楼梯间屋顶下的狭窄空间。乌鸦就坐在那里,周围是她偷来的零食、半打智能手机(其中几部已经开机)和乱七八糟的电缆。她用嘴快速敲击其中一部手机的屏幕。屏幕上出现了一些字。如果你不知道,还以为它在打字呢。这只乌鸦被同伴们称为 "cāāw-krö"。但由于这些声音不适合人类的声带,我们就称她为Carla。
Carla是一只有些奇特的乌鸦。年轻时,她对人类的语言非常着迷,偷听别人说话,直到完全掌握对方的意思为止。后来,她的兴趣转移到了人类科技上,开始偷手机研究。她目前的项目是学习编程。她在隐藏实验室里输入的文字其实是一段异步 JavaScript 代码。
闯入
Carla喜欢上网。遗憾的是,她正在使用的手机预付费数据即将耗尽。大楼里有无线网络,但需要密码才能访问。幸运的是,大楼里的无线路由器已经用了 20 年,安全性很差。经过研究,Carla发现网络验证机制有一个可以利用的漏洞。加入网络时,设备必须发送正确的六位密码。接入点会根据是否提供了正确的密码回复成功或失败信息。但是,如果发送的是部分密码(例如只有三位数字),则会根据这些数字是否是正确的密码开头而作出不同的回复。发送错误的数字会立即返回一条失败信息。发送正确的数字时,接入点会等待更多的数字。这样就可以大大加快猜测号码的速度。Carla 可以依次尝试每个数字,直到找到一个不会立即返回失败信息的数字为止,从而找到第一个数字。有了一个数字,她就可以用同样的方法找到第二个数字,依此类推,直到她知道整个密码。
假设 Carla 有一个 joinWifi 函数。给定网络名称和密码(字符串)后,该函数会尝试加入网络,如果成功则返回一个解析的 promise,如果验证失败则返回拒绝的 promise。她首先需要的是一种封装promise的方法,以便在花费过多时间后自动拒绝,从而在接入点没有响应时让程序快速继续。
这就利用了promise只能被解析或拒绝一次这一事实。如果作为其参数给出的 promise 首先解析或拒绝,那么结果将是 withTimeout 返回的 promise 的结果。反之,如果 setTimeout 首先触发,拒绝了promise,那么任何进一步的解析或拒绝调用都将被忽略。
为了找到整个密码,程序需要反复尝试每一位数字,寻找下一位数字。如果验证成功,我们就知道找到了要找的内容。如果立即失败,我们就知道这个数字是错误的,必须尝试下一个数字。如果请求超时,说明我们找到了另一个正确的数字,必须继续添加另一个数字。
因为不能在 for 循环中等待promise,所以 Carla 使用了一个递归函数来驱动这个过程。每次调用时,该函数都会获取我们目前已知的代码以及下一个要尝试的数字。根据情况,它可能会返回一个完成的代码,或者调用自身,开始破解代码中的下一个位置,或者再次尝试另一个数字。
接入点往往会在大约 20 毫秒内对错误的身份验证请求做出响应,因此为了安全起见,该函数会等待 50 毫秒后才超时处理请求。
Carla 歪着头叹了口气。如果密码再难猜一点,就更令人满意了。
异步方法
即使有了promise,编写这种异步代码也很麻烦。promise通常需要以冗长、任意的方式绑在一起。为了创建异步循环,Carla 不得不引入一个递归函数。破解函数实际做的事情完全是线性的--它总是等待前一个操作完成后才开始下一个操作。在同步编程模型中,这种表达方式会更加简单明了。好在 JavaScript 允许编写伪同步代码来描述异步计算。一个异步函数隐式返回一个promise,并可以在其主体中以一种看起来同步的方式等待其他promise。
我们可以这样重写 crackPasscode:
这个版本更清楚地显示了函数的双循环结构(内循环尝试 0 到 9 的数字,外循环将数字添加到密码中)。
异步函数的标志是函数关键字前的 async 字样。方法也可以通过在名称前写入 async 来实现异步。调用此类函数或方法时,它会返回一个 promise。一旦函数返回内容,该promise就会被解析。如果主体抛出异常,则会拒绝该promise。在异步函数中,可以在表达式前面加上 await,以等待promise解析,然后继续执行函数。如果promise被拒绝,就会在 await 处引发异常。
这样的函数不再像普通的 JavaScript 函数那样从开始到结束一次性运行。相反,它可以冻结在任何有 await 的点上,并在稍后时间继续执行。对于大多数异步代码来说,这种符号比直接使用promise更方便。您仍然需要了解promise,因为在许多情况下,您仍然会直接与它们交互。但是,当把它们连接在一起时,异步函数通常比然后调用链更容易编写。
发电机
函数可以暂停,然后再次恢复,这并不是异步函数独有的功能。JavaScript 也有一种称为生成器函数的功能。这些函数与之类似,但没有promise。当你用 function* 定义一个函数时(在函数后面加上星号),它就变成了一个生成器。调用生成器时,它将返回一个迭代器,这在第 6 章中已经介绍过了。
最初,当你调用 powers 时,函数会冻结在起点。每次在迭代器上调用 next 时,函数都会运行,直到遇到 yield 表达式时才会暂停,并使 yield 值成为迭代器产生的下一个值。当函数返回时(示例中的函数从未返回),迭代器就完成了。
使用生成器函数时,编写迭代器通常会容易得多。第 6 章练习中的 Group 类的迭代器就可以用这个生成器来编写:
现在不再需要创建一个对象来保存迭代状态,生成器每次yield时都会自动保存本地状态。这种yield表达式只能直接出现在生成器函数本身,而不能出现在你定义的内部函数中。生成器在yield时保存的状态只是其本地环境和yield的位置。
async函数是生成器的一种特殊类型。它在调用时产生一个promise,在返回(完成)时解决该promise,在抛出异常时拒绝该promise。每当它产生(等待)一个promise时,该promise的结果(值或抛出的异常)就是 await 表达式的结果。
秃鹰艺术项目
一天早上,Carla被机库外停机坪上传来的陌生声音吵醒。她跳上屋顶边缘,看到人类正在布置什么东西。那里有很多电线、一个舞台和一堵黑色的大墙。Carla是只好奇的乌鸦,她仔细看了看那堵墙。它似乎是由许多大型玻璃面装置组成的,这些装置与电缆相连。设备背面写着 "LedTec SIG-5030"。在互联网上快速搜索后,找到了这些设备的用户手册。它们似乎是交通标志,带有可编程的琥珀色 LED 灯矩阵。人类的目的可能是在活动期间在上面显示某种信息。有趣的是,这些屏幕可以通过无线网络进行编程。莫非它们与大楼的本地网络相连?网络上的每个设备都有一个 IP 地址,其他设备可以用它来发送信息。我们将在第 13 章详细介绍这一点。Carla注意到,她自己的手机都获得了 10.0.0.20 或 10.0.0.33 这样的地址。不妨试着向所有这些地址发送信息,看看其中是否有任何一个地址会响应标识手册中描述的接口。
第 18 章将介绍如何在真实网络上发出真实请求。在本章中,我们将使用一个名为 request 的简化虚函数来进行网络通信。该函数接收两个参数--一个网络地址和一条信息(可以是任何以 JSON 格式发送的信息)--并返回一个promise,该promise要么解析为来自给定地址机器的响应,要么在出现问题时拒绝响应。
根据手册,你可以向 SIG-5030 发送一条内容为 {"命令": "display", "data": [0、0、3、......]},其中数据为每个 LED 点的一个数字,亮度为-0 表示关闭,3 表示最大亮度。每个标志宽 50 灯,高 30 灯,因此更新命令应发送 1 500 个数字。这段代码会向本地网络的所有地址发送显示更新信息,看看会有什么变化。IP 地址中的每个数字可以从 0 到 255。在发送的数据中,它会激活与网络地址最后一个数字相对应的灯的数量。
由于这些地址大多不存在或不接受此类信息,因此 catch 调用可以确保网络错误不会导致程序崩溃。所有请求都会立即发出,不会等待其他请求完成,以免在某些机器没有回应时浪费时间。启动网络扫描后,Carla 回到外面查看结果。令她高兴的是,所有屏幕的左上角都出现了一条光带。它们都在本地网络上,而且都能接受命令。她迅速记下了每个屏幕上显示的数字。一共有九个屏幕,三高三宽。它们的网络地址如下:
这就为各种恶作剧提供了可能性。她可以在墙上用巨大的字体写上 "乌鸦统治,人类流口水"。但这感觉有点粗糙。相反,她打算在晚上播放一段乌鸦飞翔的视频,覆盖所有的屏幕。Carla找到了一个合适的视频片段,在这个片段中,一秒半的镜头可以重复播放,形成一个乌鸦振翅的循环视频。为了适应九块屏幕(每块屏幕可显示 50×30 像素),Carla对视频进行了剪切和大小调整,得到一系列 150×90 的图像,每秒 10 幅。然后将这些图像分别剪切成九个矩形,并进行处理,使视频中的暗点(乌鸦所在的位置)显示亮光,亮点(没有乌鸦的位置)保持暗色,这样就能营造出琥珀色乌鸦在黑色背景中飞翔的效果。
她在 clipImages 变量中设置了一个帧数组,每帧由九组像素组成的数组表示,每个屏幕一组像素,格式与标志所期望的一致。要显示一帧视频,Carla 需要同时向所有屏幕发送请求。但她还需要等待这些请求的结果,以便在当前帧正确发送之前不开始发送下一帧,并在请求失败时及时发现。
Promise 有一个静态方法 all,可以用来将一个promise数组转换为一个单一的promise,并解析为一个结果数组。这提供了一种方便的方法,可以让一些异步操作同时发生,等待它们全部完成,然后处理它们的结果(或至少等待它们以确保它们不会失败)。
这将映射帧中的图像(这是一个显示数据数组),以创建一个请求promise数组。然后,它会返回一个结合了所有这些请求的promise。为了能够停止播放视频,该过程被封装在一个类中。该类有一个异步播放方法,它返回一个只有在通过 stop 方法再次停止播放时才会解析的promise。
wait 函数将 setTimeout 封装在一个promise中,该promise会在给定的毫秒数后解析。这对于控制播放速度非常有用。
在幕墙矗立的整整一周里,每天傍晚天黑时,幕墙上都会神秘地出现一只巨大的、会发光的橙色小鸟。
事件循环
异步程序通过运行主脚本开始,而主脚本通常会设置回调,以便稍后调用。主脚本和回调脚本会不间断地完整运行。但在两者之间,程序可能会处于闲置状态,等待某些事情发生。因此,调度回调的代码不会直接调用回调。如果我在一个函数中调用 setTimeout,在回调函数被调用时,该函数已经返回。而当回调返回时,控制权并不会回到调度它的函数。
异步行为发生在自己的空函数调用栈上。这也是为什么在没有promise的情况下,管理异步代码中的异常如此困难的原因之一。因为每个回调都从一个基本为空的堆栈开始,所以当它们抛出异常时,你的捕获处理程序不会在堆栈上。
无论超时或传入请求等事件发生得多么紧密,JavaScript 环境一次只能运行一个程序。你可以把它想象成围绕程序运行的一个大循环,称为事件循环。当没有事情要做时,循环就会暂停。但当事件发生时,它们会被添加到一个队列中,其代码会被一个接一个地执行。由于没有两件事情是同时运行的,因此运行缓慢的代码会延迟其他事件的处理。
此示例设置了一个超时,但一直拖到超时的预定时间点之后,导致超时延迟。
promise总是作为新事件解析或拒绝。即使promise已经解决,等待它也会导致回调在当前脚本结束后运行,而不是立即运行。
在后面的章节中,我们将看到在事件循环中运行的其他各种类型的事件。
异步错误
当程序一次性同步运行时,除了程序本身的状态变化外,没有其他状态变化。而异步程序则不同--它们在执行过程中可能会有间隙,其他代码可以在间隙中运行。
我们来看一个例子。这是一个试图报告文件数组中每个文件大小的函数,确保同时而不是按顺序读取所有文件。
async fileName => 部分展示了如何通过在箭头函数前面加上 async 一词,使其成为异步函数。这段代码看起来并不可疑......它将 async 箭头函数映射到名称数组上,创建了一个promise数组,然后使用 Promise.all 等待所有这些promise,最后返回它们建立的列表。但这个程序完全崩溃了。它总是只返回一行输出,列出读取时间最长的文件。
你能找出原因吗?
问题出在 += 操作符上,它获取语句开始执行时 list 的当前值,然后在 await 结束时将 list 绑定设置为该值加上添加的字符串。但在语句开始执行和结束之间,存在一个异步间隙。map 表达式在有任何内容添加到 list 之前运行,因此每个 += 操作符都是从空字符串开始的,最后在其存储检索结束时,将 list 设置为将其行添加到空字符串的结果。要避免这种情况,只需从映射的promise中返回行,然后在 Promise.all 的结果上调用 join,而不是通过更改绑定来建立列表。通常,计算新值比更改现有值更不容易出错。
这样的错误很容易犯,尤其是在使用 await 时,您应该意识到代码中的漏洞在哪里。JavaScript 显式异步性(无论是通过回调、promise还是 await)的一个优点是,发现这些间隙相对容易。
总结
异步编程可以在不冻结整个程序的情况下表达对长期运行操作的等待。JavaScript 环境通常使用回调(在操作完成时调用的函数)来实现这种编程风格。事件循环会在适当的时候调用此类回调,一个接一个,这样它们的执行就不会重叠。
promise和异步函数可以让异步编程变得更简单,promise是代表未来可能完成的操作的对象,异步函数则可以让你像编写同步程序一样编写异步程序。