异步操作和Async函数
异步编程对JavaScript语言太重要。Javascript语言的执行环境是“单线程”的,如果没有异步编程,根本没法用,非卡死不可。
ES6诞生以前,异步编程的方法,大概有下面四种。
- 回调函数
- 事件监听
- 发布/订阅
- Promise 对象
ES6将JavaScript异步编程带入了一个全新的阶段,ES7的Async函数更是提出了异步编程的终极解决方案。
基本概念
异步
所谓”异步”,简单说就是一个任务分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。
比如,有一个任务是读取文件进行处理,任务的第一段是向操作系统发出请求,要求读取文件。然后,程序执行其他任务,等到操作系统返回文件,再接着执行任务的第二段(处理文件)。这种不连续的执行,就叫做异步。
相应地,连续的执行就叫做同步。由于是连续执行,不能插入其他任务,所以操作系统从硬盘读取文件的这段时间,程序只能干等着。
回调函数
JavaScript语言对异步编程的实现,就是回调函数。所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。它的英语名字callback,直译过来就是”重新调用”。
读取文件进行处理,是这样写的。
|
|
上面代码中,readFile
函数的第二个参数,就是回调函数,也就是任务的第二段。等到操作系统返回了/etc/passwd
这个文件以后,回调函数才会执行。
Promise
回调函数本身并没有问题,它的问题出现在多个回调函数嵌套。假定读取A
文件之后,再读取B
文件,代码如下。
|
|
不难想象,如果依次读取多个文件,就会出现多重嵌套。代码不是纵向发展,而是横向发展,很快就会乱成一团,无法管理。这种情况就称为”回调函数噩梦”(callback hell)。
Promise
就是为了解决这个问题而提出的。它不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套,改成链式调用。采用Promise
,连续读取多个文件,写法如下。
|
|
Promise 的最大问题是代码冗余,原来的任务被Promise 包装了一下,不管什么操作,一眼看去都是一堆 then,原来的语义变得很不清楚。
Generator函数
协程
之前介绍过,这里就简单描述下。
传统的编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做”协程”(coroutine
),意思是多个线程互相协作,完成异步任务。
协程有点像函数,又有点像线程。它的运行流程大致如下。
- 第一步,协程A开始执行。
- 第二步,协程A执行到一半,进入暂停,执行权转移到协程B。
- 第三步,(一段时间后)协程B交还执行权。
- 第四步,协程A恢复执行。
Thunk函数
参数的求值策略
Thunk函数早在上个世纪60年代就诞生了。
那时,编程语言刚刚起步,计算机学家还在研究,编译器怎么写比较好。一个争论的焦点是”求值策略”,即函数的参数到底应该何时求值。
|
|
上面代码先定义函数f,然后向它传入表达式x + 5。请问,这个表达式应该何时求值?
一种意见是”传值调用”(call by value),即在进入函数体之前,就计算x + 5的值(等于6),再将这个值传入函数f 。C语言就采用这种策略。
|
|
另一种意见是”传名调用”(call by name),即直接将表达式x + 5传入函数体,只在用到它的时候求值。Haskell语言采用这种策略。
|
|
传值调用和传名调用,哪一种比较好?回答是各有利弊。传值调用比较简单,但是对参数求值的时候,实际上还没用到这个参数,有可能造成性能损失。
|
|
上面代码中,函数f的第一个参数是一个复杂的表达式,但是函数体内根本没用到。对这个参数求值,实际上是不必要的。因此,有一些计算机学家倾向于”传名调用”,即只在执行时求值。
Thunk函数的含义
编译器的”传名调用”实现,往往是将参数放到一个临时函数之中,再将这个临时函数传入函数体。这个临时函数就叫做Thunk函数。
|
|
上面代码中,函数f的参数x + 5被一个函数替换了。凡是用到原参数的地方,对Thunk函数求值即可。
这就是Thunk函数的定义,它是”传名调用”的一种实现策略,用来替换某个表达式。
JavaScript语言的Thunk函数
JavaScript语言是传值调用,它的Thunk函数含义有所不同。在JavaScript语言中,Thunk函数替换的不是表达式,而是多参数函数,将其替换成单参数的版本,且只接受回调函数作为参数。
|
|
上面代码中,fs模块的readFile方法是一个多参数函数,两个参数分别为文件名和回调函数。经过转换器处理,它变成了一个单参数函数,只接受回调函数作为参数。这个单参数版本,就叫做Thunk函数。
任何函数,只要参数有回调函数,就能写成Thunk函数的形式。下面是一个简单的Thunk函数转换器。
|
|
Thunk函数的自动流程管理
Thunk函数真正的威力,在于可以自动执行Generator函数。下面就是一个基于Thunk函数的Generator执行器。
|
|
上面代码的run
函数,就是一个Generator函数的自动执行器。内部的next
函数就是Thunk的回调函数。next
函数先将指针移到Generator函数的下一步(gen.next
方法),然后判断Generator函数是否结束(result.done
属性),如果没结束,就将next
函数再传入Thunk函数(result.value
属性),否则就直接退出。
有了这个执行器,执行Generator函数方便多了。不管内部有多少个异步操作,直接把Generator函数传入run
函数即可。当然,前提是每一个异步操作,都要是Thunk函数,也就是说,跟在yield
命令后面的必须是Thunk函数。
|
|
上面代码中,函数g
封装了n
个异步的读取文件操作,只要执行run
函数,这些操作就会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。
co模块
基本用法
co模块是著名程序员TJ Holowaychuk于2013年6月发布的一个小工具,用于Generator函数的自动执行。
比如,有一个Generator函数,用于依次读取两个文件。
|
|
co模块可以让你不用编写Generator函数的执行器。
|
|
co函数返回一个Promise对象,因此可以用then方法添加回调函数。
|
|
co模块的源码
co它的源码只有几十行,非常简单。首先,co函数接受Generator函数作为参数,返回一个 Promise 对象。
|
|
在返回的Promise对象里面,co先检查参数gen是否为Generator函数。如果是,就执行该函数,得到一个内部指针对象;如果不是就返回,并将Promise对象的状态改为resolved。
|
|
接着,co将Generator函数的内部指针对象的next方法,包装成onFulfilled函数。这主要是为了能够捕捉抛出的错误。
|
|
最后,就是关键的next函数,它会反复调用自身。
|
|
处理并发的异步操作
co支持并发的异步操作,即允许某些操作同时进行,等到它们全部完成,才进行下一步。这时,要把并发的操作都放在数组或对象里面,跟在yield语句后面。
|
|
下面是另一个例子。
|
|
上面的代码允许并发三个somethingAsync
异步操作,等到它们全部完成,才会进行下一步。
async函数
async
函数是非常新的语法功能,新到都不属于 ES6,而是属于 ES7
。目前,它仍处于提案阶段,但是转码器Babel
和regenerator
都已经支持,转码后就能使用。
含义
ES7提供了async
函数,使得异步操作变得更加方便。async
函数是什么?一句话,async
函数就是Generator函数的语法糖。
前文有一个Generator函数,依次读取两个文件
|
|
写成async
函数,就是下面这样。
|
|
一比较就会发现,async
函数就是将Generator函数的星号(*
)替换成async
,将yield
替换成await
,仅此而已。
async
函数对 Generator 函数的改进,体现在以下四点。
(1)内置执行器。Generator函数的执行必须靠执行器,所以才有了co
模块,而async
函数自带执行器。也就是说,async
函数的执行,与普通函数一模一样,只要一行。
|
|
上面的代码调用了asyncReadFile
函数,然后它就会自动执行,输出最后结果。这完全不像Generator函数,需要调用next
方法,或者用co
模块,才能得到真正执行,得到最后结果。
(2)更好的语义。async
和await
,比起星号
和yield
,语义更清楚了。async
表示函数里有异步操作,await
表示紧跟在后面的表达式需要等待结果。
(3)更广的适用性。 co
模块约定,yield
命令后面只能是Thunk
函数或Promise
对象,而async
函数的await
命令后面,可以是Promise
对象和原始类型的值(数值、字符串和布尔值,但这时等同于同步操作)。
(4)返回值是Promise
。async
函数的返回值是Promise
对象,这比Generator
函数的返回值是Iterator
对象方便多了。你可以用then
方法指定下一步的操作。
进一步说,async
函数完全可以看作多个异步操作,包装成的一个Promise
对象,而await
命令就是内部then
命令的语法糖。
语法
async
函数的语法规则总体上比较简单,难点是错误处理机制。
(1)async函数返回一个Promise对象。
async
函数内部return
语句返回的值,会成为then
方法回调函数的参数。
|
|
async函数内部抛出错误,会导致返回的Promise对象变为reject状态。抛出的错误对象会被catch方法回调函数接收到。
|
|
(2)async
函数返回的Promise对象,必须等到内部所有await
命令的Promise对象执行完,才会发生状态改变。也就是说,只有async
函数内部的异步操作执行完,才会执行then
方法指定的回调函数。
|
|
(3)正常情况下,await命令后面是一个Promise对象。如果不是,会被转成一个立即resolve的Promise对象。
|
|
上面代码中,await
命令的参数是数值123
,它被转成Promise
对象,并立即resolve
。
await
命令后面的Promise
对象如果变为reject
状态,则reject
的参数会被catch
方法的回调函数接收到。
|
|
注意,上面代码中,await
语句前面没有return
,但是reject
方法的参数依然传入了catch
方法的回调函数。这里如果在await
前面加上return
,效果是一样的。
只要一个await
语句后面的Promise
变为reject
,那么整个async
函数都会中断执行。
|
|
为了避免这个问题,可以将第一个await
放在try...catch
结构里面,这样第二个await
就会执行。
|
|
另一种方法是await
后面的Promise
对象再跟一个catch
方法,处理前面可能出现的错误。
|
|
如果有多个await
命令,可以统一放在try...catch
结构中。
|
|
(4)如果await
后面的异步操作出错,那么等同于async
函数返回的Promise
对象被reject
。
|
|
上面代码中,async
函数f
执行后,await
后面的Promise
对象会抛出一个错误对象,导致catch
方法的回调函数被调用,它的参数就是抛出的错误对象。具体的执行机制,可以参考后文的async函数的实现
。
防止出错的方法,也是将其放在try...catch
代码块之中。
|
|
注意点
- (1)
await
命令后面的Promise
对象,运行结果可能是rejected
,所以最好把await
命令放在try...catch
代码块中。
|
|
- (2)多个await命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。
|
|
与Promise、Generator的比较
我们通过一个例子,来看Async函数与Promise、Generator函数的区别。假定某个DOM元素上面,部署了一系列的动画,前一个动画结束,才能开始后一个。如果当中有一个动画出错,就不再往下执行,返回上一个成功执行的动画的返回值。
首先是Promise的写法。
|
|
虽然Promise的写法比回调函数的写法大大改进,但是一眼看上去,代码完全都是Promise的API(then、catch等等),操作本身的语义反而不容易看出来。
接着是Generator函数的写法。
|
|
上面代码使用Generator函数遍历了每个动画,语义比Promise写法更清晰,用户定义的操作全部都出现在spawn函数的内部。这个写法的问题在于,必须有一个任务运行器,自动执行Generator函数,上面代码的spawn函数就是自动执行器,它返回一个Promise对象,而且必须保证yield语句后面的表达式,必须返回一个Promise。
最后是Async函数的写法。
|
|
可以看到Async函数的实现最简洁,最符合语义,几乎没有语义不相关的代码。它将Generator写法中的自动执行器,改在语言层面提供,不暴露给用户,因此代码量最少。如果使用Generator写法,自动执行器需要用户自己提供。
文章参考ECMAScript 6 入门