图解Async和Await

JavaScript ES7的async/await语法让异步promise操作起来更方便。如果你需要从多个数据库或者接口按顺序异步获取数据,你可能最终写出一坨纠缠不清的promise与回调。然而使用async/await可以让我们用更加可读、可维护的方式来表达这种逻辑。

这篇教程以图表与简单例子来阐述JS async/await的语法与运行机理。

在深入之前,我们先简单回顾一下promise,如果对这方面概念有自信,大可自行跳过。

Promise

在JS的世界里,一个promise抽象表达一个非阻塞(阻塞指一个任务开始后,要等待该任务执行结果产生之后才继续执行后续任务)的异步流程,类似于Java的Futrue或者C#的Task。

Promise最典型的使用场景是网络或其他I/O操作(如读取一个文件或者发送一个HTTP请求)。与其阻塞住当前的执行“线程”,我们可以产生一个异步的promise,然后用then方法来附加一个回调,用于执行该promise完成之后要做的事情。回调自身也可以返回一个promise,如此我就可以将多个promise串联。

为方便说明,假定后续所有的例子都已经引入了request-promise 库:

1
var rp = require('request-promise');

然后我们就可以如此发送一个简单的HTTP GET请求并获得一个promise返回值:

1
const promise = rp('http://example.com/')

现在来看个例子:

1
2
3
4
5
6
console.log('Starting Execution');

const promise = rp('http://example.com/');
promise.then(result => console.log(result));

console.log("Can't know if promise has finished yet...");

我们在第3行产生了一个promise,然后在第4行附上了一个回调函数。返回的promise是异步的,所以当执行的第6行的时候,我们无法确定这个promise有没有完成,多次执行可能有不同的结果(译者:浏览器里执行多少次,这里promise都会是未完成状态)。概括来说,promise之后的代码跟promise自身是并发的(译者:对这句话有异议者参见本文最后一节的并发说明)。

并不存在一种方法可以让当前的执行流程阻塞直到promise完成,这一点与Java的Futrue.get相异。JS里,我们无法直接原地等promise完成,唯一可以用于提前计划promise完成后的执行逻辑的方式就是通过then附加回调函数。

下面的图表描绘了上面代码例子的执行过程:
这里写图片描述

Promise的执行过程,调用“线程”无法直接等待promise结果。唯一规划promise之后逻辑的方法是使用then方法附加一个回调函数。

通过then 附加的回调函数只会在promise成功是被触发,如果失败了(比如网络异常),这个回调不会执行,处理错误需要通过catch 方法:

1
2
3
rp('http://example.com/').
then(() => console.log('Success')).
catch(e => console.log(`Failed: ${e}`))

最后,为了方便试验功能,我们可以直接创建一些“假想”的promise,使用Promise.resolve生成会直接成功或失败的promise 结果:

1
2
3
4
5
6
7
8
9
10
11
12
const success = Promise.resolve('Resolved');
// Will print "Successful result: Resolved"
success.
then(result => console.log(`Successful result: ${result}`)).
catch(e => console.log(`Failed with: ${e}`))


const fail = Promise.reject('Err');
// Will print "Failed with: Err"
fail.
then(result => console.log(`Successful result: ${result}`)).
catch(e => console.log(`Failed with: ${e}`))

问题——组合多个Promise

只使用一个单次的promise非常简单。然而如果我们需要编写一个非常复杂了异步逻辑,我们可能需要将若干个promise组合起来。写许多的then语句以及匿名函数很容易失控。

比如,我们需要实现以下逻辑:

  • 发起一个HTTP请求,等待结果并将其输出
  • 再发起两个并发的HTTP请求
  • 当两个请求都完成时,一起输出他们

下面的代码演示如何达到这个要求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Make the first call
const call1Promise = rp('http://example.com/');

call1Promise.then(result1 => {
// Executes after the first request has finished
console.log(result1);

const call2Promise = rp('http://example.com/');
const call3Promise = rp('http://example.com/');

return Promise.all([call2Promise, call3Promise]);
}).then(arr => {
// Executes after both promises have finished
console.log(arr[0]);
console.log(arr[1]);
})

我们先呼叫第一次HTTP请求,然后预备一个在它完成时执行的回调(第1-3行)。在回调里,我们为另外两次请求制造了promise(第8-9行)。这两个promise并发运行,我们需要计划一个在两个都完成时执行的回调,于是,我们通过Promise.all(第11行)来讲他们合并。这第一个回调的返回值是一个promise,我们再添加一个then来输出结果(第12-16行)。

以下图标描绘这个计算过程:
这里写图片描述

将promise组合的计算过程。使用“Promise.all”将两个并发的promise合并成一个。

为了一个简单的例子,我们最终写了两个then回调以及一个Promise.all来同步两个并发promise。如果我们还想再多做几个异步操作或者添加一些错误处理会怎样?这种实现方案最终很容变为纠缠成一坨的then、Promise.all以及回调匿名函数。

Async函数

一个async函数是定义会返回promise的函数的简便写法。

比如,以下两个定义是等效的:

1
2
3
4
5
6
7
8
function f() {
return Promise.resolve('TEST');
}

// asyncF is equivalent to f!
async function asyncF() {
return 'TEST';
}

相似地,会抛出错误的async函数等效于返回将失败的promise 的函数:

1
2
3
4
5
6
7
8
function f() {
return Promise.reject('Error');
}

// asyncF is equivalent to f!
async function asyncF() {
throw 'Error';
}

Await

以前,当我们产生一个promise,我们无法同步地等待它完成,我们只能通过then注册一个回调函数。不允许直接等待一个promise是为了鼓励开发者写非阻塞的代码,不然开发者会更乐意写阻塞的代码,因为这样比promise和回调简单。

然而,为了同步多个promise,我们需要它们互相等待,换句话说,如果一个操作本身就是异步的(比如,用promise包装的),它应该具备能力等待另一个异步操作先完成。但是JS解释器如何知道一个操作是不是在一个promise里的?

答案就是async关键字,所有的async函数一定会返回一个promise。所以,JS解释器也可以确信async函数里操作是用promise包装的异步过程。于是也就可以允许它等待其他promise。

键入await关键字,它只能在async函数内使用,让我们可以等待一个promise。如果在async函数外使用promise,我们依然需要使用then和回调函数:

1
2
3
4
5
6
7
8
9
async function f(){
// response will evaluate as the resolved value of the promise
const response = await rp('http://example.com/');
console.log(response);
}

// We can't use await outside of async function.
// We need to use then callbacks ....
f().then(() => console.log('Finished'));

现在我们来看看我们可以如何解决之前提到的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Encapsulate the solution in an async function
async function solution() {
// Wait for the first HTTP call and print the result
console.log(await rp('http://example.com/'));

// Spawn the HTTP calls without waiting for them - run them concurrently
const call2Promise = rp('http://example.com/'); // Does not wait!
const call3Promise = rp('http://example.com/'); // Does not wait!

// After they are both spawn - wait for both of them
const response2 = await call2Promise;
const response3 = await call3Promise;

console.log(response2);
console.log(response3);
}

// Call the async function
solution().then(() => console.log('Finished'));

上面的片段,我们将逻辑分装在一个async函数里。这样我们就可以直接对promise使用await了,也就规避了写then回调。最后我们调用这个async函数,然后按照普通的方式使用返回的promise。

要注意的是,在第一个例子里(没有async/await),后面两个promise是并发的。所以我们在第7-8行也是如此,然后直到11-12行才用await来等待两个promise都完成。这之后,我们可以确信两个promise都已经完成(与之前Promise.all(…).then(…)类似)。

计算流程跟之前的图表描绘的一样,但是代码变得更加已读与直白。

事实上,async/await其实会翻译成promise与then回调(译者:babel其实是翻译成generator语法,再通过类似co的函数运行,co内部运行机制离不开promise)。每次我们使用await,解释器会创建一个promise然后把async函数的后续代码放到then回调里。

我们来看看以下的例子:

1
2
3
4
5
async function f() {
console.log('Starting F');
const result = await rp('http://example.com/');
console.log(result);
}

f函数的内在运行过程如下图所描绘。因为f标记了async,它会与它的调用者“并发”:
这里写图片描述
函数f启动并产生一个promise。在这一刻,函数剩下的部分都会被封装到一个回调函数里,并被计划在promise完成之后执行。

错误处理

在之前的例子里,我们大多假定promise会成功,然后await一个promise的返回值。如果我们等待的promise失败了,会在async函数里产生一个异常,我们可以使用标准的try/catch来处理它

1
2
3
4
5
6
7
async function f() {
try {
const promiseResult = await Promise.reject('Error');
} catch (e){
console.log(e);
}
}

如果async函数不处理这个异常,不管是这异常是因为promise是被reject了还是其他的bug,这个函数都会返回一个被reject掉的promise:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
async function f() {
// Throws an exception
const promiseResult = await Promise.reject('Error');
}

// Will print "Error"
f().
then(() => console.log('Success')).
catch(err => console.log(err))

async function g() {
throw "Error";
}

// Will print "Error"
g().
then(() => console.log('Success')).
catch(err => console.log(err))

这就让我们可以使用熟悉的方式来处理错误。

扩展说明

async/await是一个对promise进行补充的语法部件,它能让我们写更少的重复代码来使用promise。然而,async/await并不能彻底取代普通的promise。比如,如果我们在一个普通的函数或者全局作用域里使用一个async函数,我们无法使用await,也就只能求助于原始的promise 用法:

1
2
3
4
5
6
7
async function fAsync() {
// actual return value is Promise.resolve(5)
return 5;
}

// can't call "await fAsync()". Need to use then/catch
fAsync().then(r => console.log(`result is ${r}`));

我通常会把大部分的异步逻辑封装在一个或少量几个async函数里,然后在非async的代码区域里使用,这样就可以尽量减少书写then或catch回调。

async / await是让promise用起来更简洁的语法糖。所有的async / await都可以用普通的promise来实现。所有总结来说,这只是个代码样式与简洁的问题。

学院派的人会指出,并发与并行是有区别的(译者:所以前文都是说并发,而非并行)。参见Rob Pike的讲话或者我之前的博文。并发是组合多个独立过程来一起工作,并行是多个过程同时执行。并发是体现在应用的结构设计,并行是实际执行的方式。

我们来看看一个多线程应用的例子。将应用分割成多个线程是该应用并发模型的定义,将这些线程放到可用的cpu核心上执行是确立它的并行。一个并发的系统也可以在一个单核处理器上正常运行,但这种情况并不是并行。
这里写图片描述
以这种方式理解,promise可以将一个程序分解成多个并发的模块,它们或许,也可能并不会并行执行。JS是否并行执行要看解释器自身的实现。比如,NodeJS是单线程的,如果一个promise里有大量的CPU操作(非I/O操作),你可能感受不到太多并行。然而如果你用像nashorn这样的工具把代码编译成java字节码,理论上你可以把繁重的CPU操作放到其他内核上来获得平行效果。于是在我的观点中,promise(不管是裸的还是有async/await)只是作用于定义JS应用的并发模型(而非确定逻辑是否会并行运行)。

关于本文

译者:@安秦

译文:https://zhuanlan.zihu.com/p/30500864

作者:@Nikolay

原文:http://nikgoozev.com/2017/10/01/async-await/

坚持原创技术分享,您的支持将鼓励我继续创作!