dwqs/blog

How to escape async/await hell

dwqs opened this issue · 8 comments

dwqs commented

为避免不必要的误解,本文标题由「避免陷入 async/await 地狱」改为 「How to escape async/await hell」 -- 2018/05/20 09:50

async/await 是 ES7 的新语法。在 async/await 标准出来之前,JavaScript 的异步编程经历了 callback --> promise --> generator 的演变过程。在 callback 的时代,最让人头疼的问题就是回调地狱(callback hell)。所以,在 async/await 一经推出,社区就有人认为「这是 JavaScript 异步编程的终极解决方案」。

但 async/await 也可能带来新的问题。

最近阅读了 Aditya Agarwal 的一篇文章:How to escape async/await hell。这篇文章主要讨论了过度使用 async/await 导致的新的「地狱」问题,其已经在 Medium 上获得了 19k+ 的 Applause。

好不容易逃离了一个「地狱」,又马上陷入另一个「地狱」了。

何为 async/await 地狱

在编写异步代码时,人们总是喜欢一次写多个语句,并且在一个函数调用之前使用 await 关键字。这可能会导致性能问题,因为很多时候一个语句并不依赖于前一个语句——但使用 await 关键字后,你就需要等待前一个语句完成。

示例

假设你要写一个订购 pizza 和 drink 的脚本,代码可能是如下这样的:

(async () => {
  const pizzaData = await getPizzaData()    // async call
  const drinkData = await getDrinkData()    // async call
  const chosenPizza = choosePizza()    // sync call
  const chosenDrink = chooseDrink()    // sync call
  await addPizzaToCart(chosenPizza)    // async call
  await addDrinkToCart(chosenDrink)    // async call
  orderItems()    // async call
})()

这段代码开起来没什么问题,也能正常的运行。但是,这并不是一个好的实现,因为这把本身可以并行执行的任务变成了串行执行。

选择一个 drink 添加到购物车和选择一个 pizza 添加到购物车可以看作是两个任务,而这两个任务之间并没有相互依赖的关系,也没有特定的顺序执行关系。所以这两个任务是可以并行执行的,这样能提高性能。而上述代码将二者变成了串行执行,显然是降低了程序性能的。

更糟糕的例子

假设要写一个程序,根据 followers 数用来显示 Github **区用户的排名情况。

如果只是获取排名,我们可以调用 Github 官方的 Search users 接口,伪代码如下:

async function getUserRank () {
	const data = await fetch(search_url);
	return data;
}

getUserRank();

调用 getUserRank 函数就能获取到想要的结果。但是,你可能还要想要获取每个用户的 follower 数、email、地区和仓库等数据,而 Search users 接口并没有返回这些数据,你可能需要再去调用 Single user 接口。

然后上述代码可能被改写为:

async function getUserRank () {
	const data = await fetch(search_url);
	const res = [];
	
	for(let i = 0; i < data.length; i++) {
		const item = data[i];
		const user = await fetch(user_url);
		res.push({ ...item, ...user });
    }
	
	return res;
}

getUserRank();

运行查看结果,自己想要的数据都拿到了。但是,你发现一个问题,程序运行时间有点长,该怎么优化下呢?

其实,铺垫了这么长,就是想说明一个问题:你陷入了 async/await 的地狱

上述代码的问题在于,获取每个用户资料的请求并不存在依赖性,就类似上文中的选择 pizza 和 drink 一样,这是可以并行执行的请求。而根据上述代码,请求都变成了串行执行,这当然会损耗程序的性能。

按照上述代码,可以看一下其异步请求的动态图:

images

可以看到,获取用户资料的每个请求都需要等到上一个请求完成之后才能执行,Waterfall 处于一个串行的状态。那要怎么改进这个问题呢?

既然获取每个用户资料的请求并不存在依赖性,那么我们可以先触发异步请求,然后延迟处理异步请求的结果,而不是一直等该请求完成。根据这个思路,那可能改进的代码如下:

async getUserDetails (username) {
	const user = await fetch(user_url);
	return user;
}

async function getUserRank () {
	const data = await fetch(search_url);
	const promises = []
	
	for(let i = 0; i < data.length; i++) {
		const item = data[i];
		const p = getUserDetails(item.username);
		promises.push(p);
    }
    // 更精简的代码
    // const promises = data.map((item) => getUserDetails(item.username))
	await Promise.all(promises).then(handleYourData);
}

getUserRank();

可以看一下异步请求的动态图:

images

可以看到,获取用户资料的异步请求处理不再是串行执行,而是并行执行了,这将大大提高程序的运行效率和性能。

总结

Aditya Agarwal 在其文章中也给出了怎么避免陷入 async/await 地狱的建议:

  1. 首先找出依赖于其他语句的执行的语句
  2. 然后将有依赖关系的一系列语句进行组合,合并成一个异步函数
  3. 最后用正确的方式执行这些函数

参考

原来并行的异步操作,写成串行的,好傻啊。:-) 这文章标题党啊

dwqs commented

@njleonzhang 你看了英文原文吗?

@dwqs 本来没看,你一说我去看了一下,和你这篇文章基本一个意思。
按我的理解,本质上这文章就是说用promise.all去实现多个异步请求并行,这东西早被说烂了。但是作者取了个高大上的名字叫async/await地狱,吸了一波眼球。
如果这种把并行异步写成串行的实现叫做地狱的话,用任何技术手段(callback, promise, rxjx)都能有这样的实现。
也就是说问题很普通,解法也很普通,名字高大上,所以有标题党之嫌。
我并没有怼你的意思,我只是吐糟一下这个国外大神。

你看下面的评论也有人这么说

1526780680436

image

最后感谢您的高产博客文章,很多让我受益匪浅。偶有吐槽,也不针对您,请不要介意。

dwqs commented

@njleonzhang 我不是说你怼我 文章中列出的这种现象是客观存在的 19k+ 的赞同表明很多人都认可原作者的观点 并且可能很多人之前就是把「原来并行的异步操作,写成串行的」 我相信这种现象在国内也存在不少

标题党的原因可能是我的问题 标题我是直译过来的

@dwqs 这个应该不是你的锅,这个作者应该就是这个意思。也许这个很多人并不知道(没注意)Promise.all或者并没有意识到这一点吧。毕竟业务里一般不会一次性去拿很多的detail信息。在并行请求不多的时候,一般感觉不出来差异。但是实际上,基本所有介绍async/await的文章对于这个用法都有明确的说明,我是不太明白这个作者一副发现新大陆的样子,冠以高大上的名字, 还有这么多点赞是什么意思, 233333333.

随便google下:
阮老师的async 函数的含义和用法
体验异步的终极解决方案-ES7的Async/Await

赞同 @njleonzhang 的观点。。这本质上并不是async、await的锅。。另外,forEach也可以解决文中的问题。

熟悉js异步编程思维的开发者不会这么干,async、await 用的多的都是从其他语言转过来的服务端开发者,因为习惯同步执行的代码编写风格,按着这个思路事事滥用 async、await,才有了这篇文章

@php-cpm 现在都流行这么写了啊。async、await 的代码看着还是挺舒服的。