异步函数

更新时间: 2021-06-28 15:23:05

异步函数,也称为“async/await”(语法关键字),是ES6期约模式在ECMAScript函数中的应用。async/await是ES8规范新增的。这个特性从行为和语法上都增强了JavaScript,让以同步方式写的代码能够异步执行。下面来看一个最简单的例子,这个期约在超时之后会解决为一个值:

let p = new Promise((resolve,reject) => setTimeout(resolve,1000,3))
1

这个期约在1000毫秒之后解决为数值3.如果程序中的其他代码要在这个值可用时访问它,则需要写一个解决处理程序:

let p = new Promise((resolve,reject) => setTimeout(resolve, 1000, 3))
p.then((x) => console.log(x)) //3
1
2

这其实是很不方便的,因为其他代码都必须塞到期约处理程序中。不过可以把处理程序定义为一个函数:

function handler(x){
    console.log(x)
}

let p = new Promise((resolve,reject) => setTimeout(resolve, 1000, 3))
p.then(handler) //3
1
2
3
4
5
6

这个改进其实也不大。这是因为任何需要访问这个期约所产生值的代码,都需要以处理程序的形式来接收这个值。也就是说,代码照样还是要放到处理程序里。ES8为此提供了async/await关键字。

# 异步函数

# 1.async

async关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上:

async function foo(){
    let bar = async function()

    let baz = async () = {}

    class Qux {
        async qux() {}
    }
}
1
2
3
4
5
6
7
8
9

使用async关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。而在参数或闭包方面,异步函数仍然具有普通JavaScript函数的正常行为。如下面例子所示,foo()函数仍然会在后面的指令之前被求值:

async function foo() {
    console.log(1)
}

foo()

console.log(2)

//1
//2
1
2
3
4
5
6
7
8
9
10

不过,异步函数如果使用return关键字返回了值(如果没有return则会返回undefined),这个值会被Promise.resolve()包装成一个期约对象。异步函数始终返回期约对象。在函数外部调用这个函数可以得到它返回的期约:

async function foo() {
    console.log(1)
    return 3
}

//给返回的期约添加一个解决处理程序
foo().then(console.log)

console.log(2)
//1
//2
//3
1
2
3
4
5
6
7
8
9
10
11
12

当然,直接返回一个期约对象也是一样的:

async function foo() {
    console.log(1)
    return Promise.resolve(3)
}

//给返回的期约添加一个解决处理程序
foo().then(console.log)
console.log(2)

//1
//2
//3
1
2
3
4
5
6
7
8
9
10
11
12

异步函数的返回值期待(但实际上并不要求)一个实现thenable接口的对象,但是常规的值也可以。如果返回的是实现thenable接口的对象,则这个对象可以由提供给then()的处理程序“解包”。如果不是,则返回值就被当做已经解决的期约。下面的代码演示了这些情况:

//返回一个原始值
async function foo(){
    return 'foo'
}
foo().then(console.log) //foo

//返回一个没有实现thenable接口的对象
async function bar(){
    return ['bar']
}
bar().then(console.log) //['bar']

//返回一个实现了thenable接口的非期约对象
async function baz() {
    const thenable = {
        then(callback) {callback('baz')}
    }
    return thenable
}
baz().then(console.log) //baz

//返回一个期约
async function qux(){
    return Promise.resolve('qux')
}
qux().then(console.log) // qux
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

与在期约处理程序中医院,在异步函数中抛出错误会返回拒绝的期约:

async function foo() {
    console.log(1)
    throw 3
}

//给返回的期约添加一个拒绝处理程序
foo().catch(console.log)
console.log(2)

//1
//2
//3
1
2
3
4
5
6
7
8
9
10
11
12

不过拒绝期约的错误不会被异步函数捕获:

async function foo() {
    console.log(1)
    Promise.reject(3)
}

//Attach a reject handler to the returned promise
foo().catch(console.log)
console.log(2)

//1
//2
//Uncaught (in promise):3
1
2
3
4
5
6
7
8
9
10
11
12

# 2.await

因为异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复的执行能力。使用await关键字可以暂停异步代码的执行,等待期约解决。来看下之前出现过的例子:

let p =  new Promise((resolve,reject) => setTimeout(resolve, 1000, 3))

p.then((x) => console.log(x)) //3
1
2
3

使用async/await可以写成这样:

async function foo() {
    let p =  new Promise((resolve,reject) => setTimeout(resolve,1000,3))
    console.log(await p)
}

foo()
//3
1
2
3
4
5
6
7

注意,await关键字会暂停执行异步函数后面的代码,让出Javascript运行时执行线程。这个行为与生成器函数中的yield关键字是一样的。await关键字同样是尝试“解包”对象的值,然后将这个值传给表达式,再异步恢复函数的执行。

await关键字的用法与JavaScript的一元操作一样。它可以单独使用,也可以在表达式中使用,如下面的例子所示:

//异步打印“foo”
async function foo(){
    console.log(await Promise.resolve('foo'))
}
foo()
//foo

//异步打印“bar”
async function bar() {
    return await Promise.resolve('bar')
}
bar().then(console.log)
//bar

//1000毫秒后异步打印“baz”
async function baz(){
    await new Promise((resolve,reject) => setTimeout(resolve,1000))
    console.log('baz')
}
baz()
//baz(1000毫秒后)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

await关键字期待(但实际上并不要求)一个实现thenable接口的对象,但常规的值也可以。如果是实现thenable接口的对象,则这个对象可以由await来“解包”。如果不是,则这个值就被当做一个已经解决的期约。下面代码演示了这些情况:

//等待一个原始值
async function foo(){
    console.log(await 'foo')
}
foo() //foo

//等待一个没有实现thenable接口的对象
async function bar(){
    console.log(await ['bar'])
}
bar() //['bar']

//等待一个实现了thenable接口的非期约对象
async function baz(){
    const thenable = {
        then(callback){callback('baz')}
    }
    console.log(await thenable)
}
baz() //baz

//等待一个期约
async function qux() {
    console.log(await Promise.resolve('qux'))
}
qux() //qux
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

等待会抛出错误的同步操作,会返回拒绝的期约:

async function foo(){
    console.log(1)
    await (() => {throw 3})()
}

//给返回的期约添加一个拒绝处理程序
foo().catch(console.log)
console.log(2)

//1
//2
//3
1
2
3
4
5
6
7
8
9
10
11
12

如前面的例子所示,单独的Promise.reject()不会被异步函数捕获,而会抛出未捕获错误。不过,对拒绝的期约使用await则会释放(unwrap)错误值(将拒绝期约返回):

async function foo(){
    console.log(1)
    await Promise.reject(3)
    console.log(4) //这行代码不会执行
}

//给返回的期约添加一个拒绝处理程序
foo().catch(console.log)
console.log(2)

//1
//2
//3 
1
2
3
4
5
6
7
8
9
10
11
12
13

# 3.await的限制

await关键字必须在异步函数中使用,不能在顶级上下文如<script>标签或模块中使用。不过,定义并立即调用异步函数是没问题的。下面两段代码实际是相同的:

async function foo(){
    console.log(await Promise.resolve(3))
}
foo() //3

//立即调用的异步函数表达式
(async function() {
    console.log(await Promise.resolve(3))
})() //3
1
2
3
4
5
6
7
8
9

此外,异步函数的特质不会扩展到嵌套函数。因此,await关键字也只能直接出现在异步函数的定义中。在同步函数内部使用await会抛出SyntaxError

下面展示了一些会出错的例子:

//不允许:await出现在了箭头函数中,只允许出现在异步函数定义中,而且只能在代码块顶层
function foo() {
    const syncFn = () => {
        return await Promise.resolve('foo')
    }
    console.log(syncFn())
}
//Uncaught SyntaxError: await is only valid in async functions and the top level bodies of modules

//不允许:await出现在了同步函数声明中
function bar(){
    function syncFn() {
        return await Promise.resolve('bar')
    }
    console.log(syncFn())
}
//Uncaught SyntaxError: await is only valid in async functions and the top level bodies of modules

//不允许:await 出现在了同步函数表达式中
function baz() {
    const syncFn = function() {
        return await Promise.resolve('baz')
    }
    console.log(syncFn())
}
//ncaught SyntaxError: await is only valid in async functions and the top level bodies of modules

//不允许:IIFE使用同步函数表达式或箭头函数
function qux() {
    (
        function(){
            console.log(await Promise.resolve('qux'))
        }
    )()
    (
        () => console.log(await Promise.resolve('qux'))
    )()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

# 停止和恢复执行

使用await关键字之后的区别其实比看上去的还要微妙一些。比如,下面的例子中按顺序调用了3个函数,但它们的输出结果顺序是相反的:

async function foo() {
    console.log(await Promise.resolve('foo'))
}

async function bar() {
    console.log(await 'bar')
}

async function baz() {
    console.log('baz')
}

foo()
bar()
baz()

//baz
//bar
//foo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

async/await中真正起作用的是awaitasync关键字,无论从哪方面来看,都不过是一个标识符。毕竟,异步函数如果不包含await关键字,其执行基本上跟普通函数没什么区别:

async function foo() {
    console.log(2)
}
console.log(1)
foo()
console.log(3)

//1
//2
//3
1
2
3
4
5
6
7
8
9
10

要完全理解await关键字,必须知道它并非只是等待一个值可用那么简单。JavaScript运行时在碰到await关键字时,会记录在哪里暂停执行。等到await右边的值可以用了,JavaScript运行时会向消息队列中推送一个任务,这个任务会恢复异步函数的执行。

因此,即使await后面跟着一个立即可用的值,函数的其余部分也会被异步求职。下面的例子演示了这一点:

async function foo() {
    console.log(2)
    await null
    console.log(4)
}
console.log(1)
foo()
console.log(3)

//1
//2
//3
//4
1
2
3
4
5
6
7
8
9
10
11
12
13

控制台中输出结果的顺序很好地解释了运行时的工作过程:

  1. 打印1
  2. 调用异步函数foo()
  3. (在foo()中)打印2
  4. (在foo()中)await关键字暂停执行,为立即可用的值null向消息队列中添加一个任务
  5. foo()退出
  6. 打印3
  7. 同步线程的代码执行完毕
  8. JavaScript运行时从消息队列中取出任务,恢复异步函数执行
  9. (在foo()中)恢复执行,await取得null值(这里并没有使用)
  10. (在foo()中) 打印4
  11. foo()返回

如果await后面是一个期约,则问题会稍微复杂些。此时,为了执行异步函数,实际上会有两个任务被添加到消息队列并被异步求值。下面的例子虽然看起来很反直觉,但它演示了真正的执行顺序:

async function foo() {
    console.log(2)
    console.log(await Promise.resolve(8))
    console.log(9)
}

async function bar(){
    console.log(4)
    console.log(await 6)
    console.log(7)
}

console.log(1)
foo()
console.log(3)
bar()
console.log(5)

//1
//2
//3
//4
//5
//6
//7
//8
//9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

运行时会像这样执行上面的例子:

  1. 打印1
  2. 调用异步函数foo()
  3. (在foo()中)打印2
  4. (在foo()中) await关键字暂停执行,向消息队列中添加一个期约在落定之后执行的任务
  5. 期约立即落定,把给await提供值的任务添加到消息队列
  6. foo()退出
  7. 打印3
  8. 调用异步函数bar()
  9. (在bar()中)打印4
  10. (在bar()中)await关键字暂停执行,为立即可用的值6向消息队列中添加一个任务
  11. bar()退出
  12. 打印5
  13. 顶级线程执行完毕
  14. JavaScript运行时从消息队列中取出解决await期约的处理程序,并将解决的值8提供给它
  15. JavaScript运行时向消息队列中添加一个恢复执行foo()函数的任务
  16. JavaScript运行时从消息队列中取出恢复执行bar()的任务及值 6
  17. (在bar()中)恢复执行,await 取得值6
  18. (在bar()中)打印6
  19. (在bar()中)打印7
  20. bar()返回
  21. 异步任务完成,JavaScript从消息队列中取出恢复执行foo()的任务及值8
  22. (在foo()中)打印8
  23. (在foo()中)打印9
  24. foo()返回

注意

TC39对await后面是期约的情况如果处理做过一次修改。修改后,本例中的Promise.resolve(8)只会生成一个异步任务。因此在新版浏览器中,这个示例的输出结果为123458967

async function foo() {
    console.log(2)
    console.log(
        await new Promise((resolve) => resolve(Promise.resolve(8)))
    )
    console.log(9)
}

async function bar(){
    console.log(4)
    console.log(await 6)
    console.log(7)
}

console.log(1)
foo()
console.log(3)
bar()
console.log(5)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

这样才是真的123456789

# 异步函数策略

因为简单实用,所以异步函数很快成为JavaScript项目最广泛的特性之一。不过,在使用异步函数时,还是有些问题需要注意。

# 1. 实现sleep()

很多人在刚开始学习JavaScript时,想找到一个类似JavaThread.sleep()之类的函数,好在程序中加入非阻塞性的暂停。以前,这个需求基本上都通过setTimeout()利用JavaScript运行时的行为来实现的。

有了异步函数之后,就不一样了。一个简单的箭头函数就可以实现sleep()







 





async function sleep(delay) {
    return new Promise((resolve) => setTimeout(resolve,delay))
}

async function foo() {
    const t0 = Date.now()
    await sleep(1500) 
    console.log(Date.now() - t0)
}
foo()
//1500
1
2
3
4
5
6
7
8
9
10
11

# 2.利用平行执行

如果使用await时不留心,则很可能错过平行加速的机会。来看下面的例子,其中顺序等待了5个随机的超时:

async function randomDelay(id){
    //延迟0-1000毫秒
    const delay = Math.random() * 1000
    return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`)
        resolve()
    },delay))
}

async function foo() {
    const t0 = Date.now()
    await randomDelay(0)
    await randomDelay(1)
    await randomDelay(2)
    await randomDelay(3)
    await randomDelay(4)
    console.log(`${Date.now() - t0}ms elapsed`)
}
foo()

//0 finished
//1 finished
//2 finished
//3 finished
//4 finished
//2372ms elapsed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

用一个for循环重写,就是:











 
 
 











async function randomDelay(id){
    //延迟0-1000毫秒
    const delay = Math.random() * 1000
    return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`)
        resolve()
    },delay))
}

async function foo() {
    const t0 = Date.now()
    for(let i = 0;i<5;++i){
        await randomDelay(i)
    }
    console.log(`${Date.now() - t0}ms elapsed`)
}
foo()
//0 finished
//1 finished
//2 finished
//3 finished
//4 finished
//2372ms elapsed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

就算这些期约之间没有依赖,异步函数也会一次暂停,等待每个超时完成。这样可以保证执行顺序,但总执行时间会边长。

如果顺序不是必须保证的,那么可以先一次性初始化所有期约,然后再分别等待他们的结果。比如:

async function randomDelay(id){
    //延迟0-1000毫秒
    const delay = Math.random() * 1000
    return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`)
        resolve()
    },delay))
}

async function foo() {
    const t0 =  Date.now()

    const p0 = randomDelay(0)
    const p1 = randomDelay(1)
    const p2 = randomDelay(2)
    const p3 = randomDelay(3)
    const p4 = randomDelay(4)

    await p0
    await p1
    await p2
    await p3
    await p4

    setTimeout(console.log,0,`${Date.now() - t0}ms elapsed`)
}
foo()

//2 finished
// 3 finished
// 4 finished
// 1 finished
// 0 finished
// 369ms elapsed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

用数组和for循环再包装一下:













 
 
 
 
 












async function randomDelay(id){
    //延迟0-1000毫秒
    const delay = Math.random() * 1000
    return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`)
        resolve()
    },delay))
}

async function foo() {
    const t0 = Date.now()

    const promises = Array(5).fill(null).map((_,i) => randomDelay(i))

    for(const p of promises){
        await p
    }

    console.log(`${Date.now() - t0}ms elapred`)
}
foo()

// 2 finished
// 3 finished
// 4 finished
// 1 finished
// 0 finished
// 369ms elapsed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

注意,虽然期约没有按照顺序执行,但await按顺序接收到了每个期约的值:

async function randomDelay(id){
    //延迟0-1000毫秒
    const delay = Math.random() * 1000
    return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`)
        resolve(id)
    },delay))
}

async function foo(){
    const t0 = Date.now()

    const promises = Array(5).fill(null).map((_,i) => randomDelay(i))

    for(const p of promises) {
        console.log(`awaited ${await p}`)
    }

    console.log(`${Date.now() - t0}ms elapsed`)
}
foo()

//1 finishid
//2 finishid
//3 finishid
//4 finishid
//0 finishid
//awaited 0
//awaited 1
//awaited 2
//awaited 3
//awaited 4
//654ms elapsed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

# 3.串行执行期约

使用async/await,期约连锁会变得很简单:

function addTwo(x) {return  x + 2}
function addThree(x) {return x + 3}
function addFive(x) {return x + 5}

async function addTen(x) {
    for(const fn of [addTwo,addThree,addFive]){
        x = await fn(x)
    }
    return x
}
addTen(9).then(console.log)
1
2
3
4
5
6
7
8
9
10
11

这里,await直接传递了每个函数的返回值,结果通过迭代产生。当然,这个例子没有使用期约,如果要使用期约,则可以把所有函数都改成异步函数。这样他们就都返回期约了:

async function addTwo(x) {return  x + 2}
async function addThree(x) {return x + 3}
async function addFive(x) {return x + 5}

async function addTen(x) {
    for(const fn of [addTwo,addThree,addFive]){
        x = await fn(x)
    }
    return x
}
addTen(9).then(console.log)
1
2
3
4
5
6
7
8
9
10
11

# 4.栈追踪与内存管理

期约与异步函数的功能有相当程度的重叠,但他们在内存中的表示则差别很大。看看下面的例子,它展示了拒绝期约的栈追踪信息:

function fooPromiseExecutor(resolve,reject) {
    setTimeout(reject,1000,'bar')
}

function foo() {
    new Promise(fooPromiseExecutor)
}

foo()

//Uncaught (in promise) bar
//setTimeout
//setTimeout (async)
//fooPromiseExecutor
//foo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

栈追踪信息应该相当直接地表现JavaScript引擎当前栈内存中函数调用之间的嵌套关系。在超时处理程序执行和拒绝期约时,我们看到的错误信息包含嵌套函数的标识符,那是被调用以创建最初期约实例的函数,可是,我们知道这些函数已经返回了,因此栈追踪信息中不应该看到他们。

答案很简单,这是因为JavaScript引擎会在创建期约时尽可能保留完整的调用栈。在抛出错误时,调用栈可以由运行时的错误处理逻辑获取,因而就会出现在栈追踪信息中。当然,这意味着栈追踪信息会占用内存,从而带来一些计算和存储成本。

如果在前面的例子中使用的是异步函数,那又会怎样呢?比如:

function fooPromiseExecutor(resolve,reject) {
    setTimeout(reject,1000,'bar')
}

async function foo() {
    await new Promise(fooPromiseExecutor)
}

foo()

//Uncaught (in promise) bar
//foo
//async function(async)
//foo
1
2
3
4
5
6
7
8
9
10
11
12
13
14

这样一改,栈追踪信息就准确地反应了当前的调用栈。fooPromiseExecutor()已经返回,所以它不在错误信息中。但foo()此时被挂起了,并没有退出。JavaScript运行时可以简单地在嵌套函数中存储指向包含函数的指针,就跟对待同步函数调用栈一样。这个指针实际上存储在内存中,可用于在出错时生成栈追踪信息。这样就不会像之前的例子那样带来额外的消耗,因此在重视性能的应用中是可以优先考虑的。