如何搞定 async/await 地狱

Jul 21, 2018 00:00 · 2920 words · 6 minute read JavaScript Node Async

译文

async/await 把我们从“回调地狱”中解救出来,但是当它开始被滥用——又导致了“async/await 地狱”的诞生。

什么是 async/await 地狱

当使用 JavaScript 编写异步程序时,人们经常会先定义一个又一个函数,在每个函数调用之前摆个 await。这就导致了性能问题,很多时候一个语句并不依赖前一个语句的执行结果——但是你不得不等待前一个函数执行完。

async/await 地狱的例子

想象你写个脚本来叫一份披萨和一杯饮料:

(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
})();

表面上看起来没问题,而且确实有效。但这不是一个好的实现手法,因为它扛不住并发。我们来理解下业务这样可以帮助我们确定问题所在。

解释

在一个异步的声明后立即执行的函数(IIFE)里包裹我们的代码,顺序如下:

  1. 获取披萨菜单。
  2. 获取饮料菜单。
  3. 从披萨菜单里挑一个。
  4. 从饮料菜单里挑一个。
  5. 将选中的披萨添加到购物车。
  6. 将选中的饮料添加到购物车。
  7. 下单。

那么哪里错了?

我之前强调,所有这些语句都是一句接一句执行的。这里没有任何并发。斟酌一下:为什么我们要等获取到了披萨菜单才去获取饮料菜单?我们明明就应该同时获取两份菜单。但是当选择披萨的时候,我们就需要手里有一份披萨的菜单。饮料同样如此。

我们可以断定点披萨和点饮料可以同时进行,但是点披萨的步骤需要顺序执行。

另一个不良手法的例子

这段代码用来获取购物车里的东西并发送下单请求:

async function orderItems() {
    const items = await getCartItems(); // async call
    const noOfItems = items.length;
    for(let i = 0; i < noOfItems; i++) {
        await sendRequest(items[i]); // async call
    }
}

这个例子中,for 循环必须等 sendRequest() 方法完成才会进入下一轮。但其实我们没必要等的。我们想要的是尽快发送所有请求然后等待它们完成。

现在你应该对 async/await 地狱有了进一步的理解,以及它是如何严重拖慢程序的性能。

如果忘了 await 关键字呢?

当调用 async 函数时如果忘记带上 await。这意味着在执行这个函数时不需要等待。这个 async 函数将返回一个 promise,之后可以使用。

(async () => {
    const value = doSomeAsyncTask();
    console.log(value); // an unresolved promise
})();

另一种情况是编译器不知道你想要等待函数执行完,从而编译器在异步任务还没完成时就强行退出程序了。所以确实需要 await 关键字。

promise 有个有趣的特点:你可以在某一行得到一个 promise 并等待它在在另一个 promise 中完成。这是逃离 async/await 地狱的关键。

(async () => {
    const promise = doSomeAsyncTask();
    const value = await prmomise;
    console.log(value); // the actual value
})();

正如你所见,doSomeAsyncTask() 返回一个 promise。这里 doSomeAsyncTask() 已经开始执行了。我们使用 await 关键字来告诉 JavaScript 不要立即执行下一行,从而得到 promise 的完成值,而不是等待 promise 完成后执行下一行。

如何摆脱 async/await 地狱?

照着下面的步骤做。

找到一个依赖其他语句执行结果的语句

在第一个例子中,挑选披萨和饮料。在选择披萨前,我们需要拿到披萨的菜单。在把披萨添加到购物车前,我们需要选择一款披萨。可以说这三步相互依赖,缺前者不可。

但是横向地看,我们发现挑选比萨并不依赖挑选饮料,所以我们可以同时选择它们。这方面机器比我们做的更好。

因此我们要发现依赖其他语句的执行结果的语句。

在 async 函数中组合有依赖关系的语句

正如我们所见,选择披萨这件事包括了像获取菜单,挑选,添加到购物车这些有依赖关系的语句。我们可以在一个 async 函数中组合它们。这样我们得到两个 async 方法,selectPizza()selectDrink()

并发执行 async 函数

然后发挥事件循环(Node.js 与生俱来的特点之一)的优势,来非阻塞地并发执行这些 async 函数。有两个常见的手法:提前返回 promise 结果和 Promise.all 方法。

实践一下

三步走:

async function selectPizza() {
    const pizzaData = await getPizzaData(); // async call
    const chosenPizza = choosePizza(); // sync call
    await addPizzaToCart(chosenPizza); // async call
}

async function selectDrink() {
    const drinkData = await getDrinkData(); // async call
    const chosenDrink = chooseDrink(); // sync call
    await addDrinkToCart(chosenDrink); // async cal
}

(async () => {
    const pizzaPromise = selectPizza();
    const drinkPromise = selectDrink();
    await pizzaPromise;
    await drinkPromise;
    orderItems(); // async call
})()

// Although I prefer it this way
(async () => {
    Promise.all([selectPizza(), selectDrink()]).then(orderItems); // async  call
})();

现在我们把这些步骤组合进了两个方法,每个语句依赖前一个语句的执行结果。然后我们就可以并发执行 selectPizza()selectDrink() 两个函数了。

第二个例子中,我们需要处理未知数量的 promise。这种情况很容易搞定的:我们只要把所有的 promise 都排进一个数组就行了。

async function orderItems() {
    const items = await getCartItems(); // async call
    const noOfItems = items.length;
    const promises = [];

    for (let i = 0; i < noOfItems; i++) {
        const orderPromise = sendRequest(items[i]); // async call
        promises.push(orderPromise) // async call
    }
    await Promise.all(promises); // async call
}

// Although I prefer it this way
async function orderItems() {
    const items = await getCartItems(); // async call
    const promises = items.map((item) => sendRequest(item));
    await Promise.all(promises); // async call
}

希望这篇文章可以帮助你看穿 async/await,提升应用程序性能。


原文

async/await freed us from callback hell, but people have started abusing it — leading to the birth of async/await hell.

In this article, I will try to explain what async/await hell is, and I’ll also share some tips to escape it.

What is async/await hell

While working with Asynchronous JavaScript, people often write multiple statements one after the other and slap an await before a function call. This causes performance issues, as many times one statement doesn’t depend on the previous one — but you still have to wait for the previous one to complete.

An example of async/await hell

Consider if you wrote a script to order a pizza and a drink. The script might look like this:

(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
})();

Explanation

We have wrapped our code in an async IIFE. The following occurs in this exact order:

  1. Get the list of pizzas.
  2. Get the list of drinks.
  3. Choose one pizza from the list.
  4. Choose one drink from the list.
  5. Add the chosen pizza to the cart.
  6. Add the chosen drink to the cart.
  7. Order the items in the cart.

So what’s wrong?

As I stressed earlier, all these statements execute one by one. There is no concurrency here. Think carefully: why are we waiting to get the list of pizzas before trying to get the list of drinks? We should just try to get both the lists together. However when we need to choose a pizza, we do need to have the list of pizzas beforehand. The same goes for the drinks.

So we can conclude that the pizza related work and drink related work can happen in parallel, but the individual steps involved in pizza related work need to happen sequentially (one by one).

Another example of bad implementation

This JavaScript snippet will get the items in the cart and place a request to order them.

async function orderItems() {
    const items = await getCartItems(); // async call
    const noOfItems = items.length;
    for(let i = 0; i < noOfItems; i++) {
        await sendRequest(items[i]); // async call
    }
}

In this case, the for loop has to wait for the sendRequest() function to complete before continuing the next iteration. However, we don’t actually need to wait. We want to send all the requests as quickly as possible and then we can wait for all of them to complete.

I hope that now you are getting closer to understanding what is async/await hell and how severely it affects the performance of your program. Now I want to ask you a question.

What if we forget the await keyword?

If you forget to use await while calling an async function, the function starts executing. This means that await is not required for executing the function. The async function will return a promise, which you can use later.

(async () => {
    const value = doSomeAsyncTask();
    console.log(value); // an unresolved promise
})();

Another consequence is that the compiler won’t know that you want to wait for the function to execute completely. Thus the compiler will exit the program without finishing the async task. So we do need the await keyword.

One interesting property of promises is that you can get a promise in one line and wait for it to resolve in another. This is the key to escaping async/await hell.

(async () => {
    const promise = doSomeAsyncTask();
    const value = await promise;
    console.log(value); // the actual value
})();

As you can see,doSomeAsyncTask() is returning a promise. At this point doSomeAsyncTask() has started its execution. To get the resolved value of the promise, we use the await keyword and that will tell JavaScript to not execute the next line immediately, but instead wait for the promise to resolve and then execute the next line.

How to get out of async/await hell?

You should follow these steps to escape async/await hell.

Find statements which depend on the execution of other statements

In our first example, we were selecting a pizza and a drink. We concluded that, before choosing a pizza, we need to have the list of pizzas. And before adding the pizza to the cart, we’d need to choose a pizza. So we can say that these three steps depend on each other. We cannot do one thing until we have finished the previous thing.

But if we look at it more broadly, we find that selecting a pizza doesn’t depend on selecting a drink, so we can select them in parallel. That is one thing that machines can do better than we can.

Thus we have discovered some statements which depend on the execution of other statements and some which do not.

Group-dependent statements in async functions

As we saw, selecting pizza involves dependent statements like getting the list of pizzas, choosing one, and then adding the chosen pizza to the cart. We should group these statements in an async function. This way we get two async functions, selectPizza() and selectDrink() .

Execute these async functions concurrently

We then take advantage of the event loop to run these async non blocking functions concurrently. Two common patterns of doing this is returning promises early and the Promise.all method.

Let’s fix the examples

Following the three steps, let’s apply them on our examples.

async function selectPizza() {
    const pizzaData = await getPizzaData(); // async call
    const chosenPizza = choosePizza(); // sync call
    await addPizzaToCart(chosenPizza); // async call
}

async function selectDrink() {
    const drinkData = await getDrinkData(); // async call
    const chosenDrink = chooseDrink(); // sync call
    await addDrinkToCart(chosenDrink); // async cal
}

(async () => {
    const pizzaPromise = selectPizza();
    const drinkPromise = selectDrink();
    await pizzaPromise;
    await drinkPromise;
    orderItems(); // async call
})();

// Although I prefer it this way
(async () => {
    Promise.all([selectPizza(), selectDrink()]).then(orderItems); // async  call
})();

Now we have grouped the statements into two functions. Inside the function, each statement depends on the execution of the previous one. Then we concurrently execute both the functions selectPizza() and selectDrink().

In the second example, we need to deal with an unknown number of promises. Dealing with this situation is super easy: we just create an array and push the promises in it. Then using Promise.all() we concurrently wait for all the promises to resolve.

async function orderItems() {
    const items = await getCartItems(); // async call
    const noOfItems = items.length;
    const promises = [];

    for (let i = 0; i < noOfItems; i++) {
        const orderPromise = sendRequest(items[i]); // async call
        promises.push(orderPromise) // async call
    }
    await Promise.all(promises); // async call
}

// Although I prefer it this way
async function orderItems() {
    const items = await getCartItems(); // async call
    const promises = items.map((item) => sendRequest(item));
    await Promise.all(promises); // async call
}

I hope this article helped you see beyond the basics of async/await, and also helped you improve the performance of your application.