Even with async / await, native promise is still very important for writing optimal parallel JS

Time:2021-3-1

Original texthttps://medium.com/@bluepnume…

along withes2017It’s coming,async/awaitThe time of the new era is not far away. In previous articlesI suggest that you fully master promiseBecause it’s builtasync/awaitIt’s the foundation of the project. Understanding promise will help you understand the basic concepts of async / await and help you write better async functions.

But even if you’ve followed the async trend (which I like personally) and fully understand promise, there are still some very compelling reasons to continue using them in asynchronous functions. Async / await will never get you out of every situation. Why? It’s simple:

  • You may still need to write some code to run on the browser.

  • It is sometimes impossible or not easy to write parallel code with async / await.

Code your browser? Can’t Babel solve it?

Obviously, unless you are writing purely for the node server, you will have to consider running your JavaScript in the browser. adoptBabelCompile, you can make the code written by es2015 + run in older browsers, or you can use an excellent compiler of FacebookRegeneratorBabel can even compile async / await into downward compatible code.

The problem is solved, and then what? Well, that’s not exactly true.

The key point is that the generated code is not necessarily what you want to run on the client. For example, the following simple async function uses an asynchronous mapping function to continuously map an array:

async function serialAsyncMap(collection, fn) {
  let result = [];
  
  for (let item of collection) {
    result.push(await fn(item));
  }
  
  return result;
}

This is 56 lines of code compiled by Babel / regenerator:

var serialAsyncMap = function () {
  var _ref = _asyncToGenerator(regeneratorRuntime.mark(function _callee(collection, fn) {
    var result, _iterator, _isArray, _i, _ref2, item;
return regeneratorRuntime.wrap(function _callee$(_context) {
      while (1) {
        switch (_context.prev = _context.next) {
          case 0:
            result = [];
            _iterator = collection, _isArray = Array.isArray(_iterator), _i = 0, _iterator = _isArray ? _iterator : _iterator[Symbol.iterator]();

See full code

This is just seven lines of code in the compiled result. By the way, even before the bundleregeneratorRuntimeor_asyncToGenerator. Although regenerator is a good technical function, it can not compile the simplest code to run in the browser. I guess it is not the best or best performance code. It’s also hard to read, understand and debug.

Suppose we use native promise to write the same function:

function serialAsyncMap(collection, fn) {
  
  let results = [];
  let promise = Promise.resolve();
  
  for (let item of collection) {
    promise = promise.then(() => {
      return fn(item).then(result => {
        results.push(result);
      });
    });
  }
  return promise.then(() => {
    return results;
  });
}

Or a simpler version:

function serialAsyncMap(collection, fn) {
  
  let results = [];
  let promise = Promise.resolve();
  
  for (let item of collection) {
    promise = promise.then(() => fn(item))
                     .then(result => results.push(result));
  }
  
  return promise.then(() => results);
}

It is true that native promise is more concise, readable and easy to debug than the async / await code compiled by regenerator. Debugging is closely related to the support of source map in the environment (it is usually an indispensable debugging environment, such as low version IE browser).

Are there any other options?

There are indeed several alternatives to regenerator compiling async / await, which extracts async code and tries to convert it to a more traditional one.thenand.catchMark. In my experience, for simple functions, these transformations work wellawaitafterreturn, plus at mosttry/catchBlock. But the more complex async function (with some conditions)awaitThe compiled code is like a piece of spaghetti.

At least for me, that’s not good enough; if I can’t just look at the compiled code and imagine what the compiled result looks like, then my chance of debugging the code easily becomes very small.

It takes a long time for browsers to fully support async / await, so please don’t hold your breath and write the familiar promise code on the client.

OK, OK, so I still need to write promise on the client side, but as long as I run on the node server side, I can use async / await, right?

Yes, but not necessarily.

Usually, you can use some async functions and await syntax on the JS server side, such as making some HTTP requests. It’s OK. You can even use itPromise.allTo parallelize asynchronous tasks (although I don’t think it’s appropriate, it’s better to run parallel with async / await).

But what happens when you want to write something more complex than “running some asynchronous tasks in series” or “running some asynchronous tasks in parallel”?

Take an example

We want to make a pizza, consider the following.

  • We make dough by ourselves.

  • We make our own sauce.

  • We want to taste the sauce before deciding which cheese to use with the pizza.

So let’s start with an ultra simple pure async / await solution:

async function makePizza(sauceType = 'red') {
  
  let dough  = await makeDough();
  let sauce  = await makeSauce(sauceType);
  let cheese = await grateCheese(sauce.determineCheese());
  
  dough.add(sauce);
  dough.add(cheese);
  
  return dough;
}

This has a big advantage: it’s very simple and easy to read and understand. First we make dough, then we make sauce, and then we grind cheese. Simple!

But it’s not exactly the best. We have to do things step by step. In fact, we should let the JS engine run these tasks at the same time. So instead of:

|——– dough ——–> |——– sauce ——–> |– cheese –>

What we want is more like:

|——– dough ——–>
|——– sauce ——–> |– cheese –>

In this way, the task is completed faster. Let’s try again

async function makePizza(sauceType = 'red') {
  
  let [ dough, sauce ] =
    await Promise.all([ makeDough(), makeSauce(sauceType) ]);
  let cheese = await grateCheese(sauce.determineCheese());
  
  dough.add(sauce);
  dough.add(cheese);
  
  return dough;
}

OK, use itPromise.allAfter that, our code looks cooler, at least now it’s the best, right? Well, it’s not. I even waited for the dough and sauce to be ready before I could start grinding the cheese. What if I make the sauce quickly? Now my execution looks like this:

|——– dough ——–>
|— sauce —> |– cheese –>

Notice that I have to wait for the dough and sauce to be ready before I want to grind the cheese? I just need to make the sauce before I grind the cheese, so I’m wasting my time here. Then let’s go back to the drawing board and try to use it Promise.all Instead of async / await:

function makePizza(sauceType = 'red') {
  
  let doughPromise  = makeDough();
  let saucePromise  = makeSauce(sauceType);
  let cheesePromise = saucePromise.then(sauce => {
    return grateCheese(sauce.determineCheese());
  });
  
  return Promise.all([ doughPromise, saucePromise, cheesePromise ])
    .then(([ dough, sauce, cheese ]) => {
      
      dough.add(sauce);
      dough.add(cheese);
      
      return dough;
    });
}

This is much better. Once all the dependencies are implemented, each task will now be completed as soon as possible. So the only thing that can stop me from grinding the cheese is waiting for the sauce.

|——— dough ———>
|—- sauce —-> |– cheese –>

But in order to do that, we had to quit writing async / await code and use promise. We try to go back to async / await.

async function makePizza(sauceType = 'red') {
  
  let doughPromise = makeDough();
  let saucePromise = makeSauce(sauceType);
  
  let sauce  = await saucePromise;
  let cheese = await grateCheese(sauce.determineCheese());
  let dough  = await doughPromise;
  
  dough.add(sauce);
  dough.add(cheese);
  
  return dough;
}

OK, so now we’re the best, and back to the async / await block… But it still feels like a setback. We have to pre set each promise, so be prepared. We also rely on these promises to run and set the specified task before any await. This is not obvious when reading the code, and may be accidentally decomposed or destroyed in the future. So this is probably my least favorite implementation.

Let’s try again. We can try again:

async function makePizza(sauceType = 'red') {
  
  let prepareDough  = memoize(async () => makeDough());
  let prepareSauce  = memoize(async () => makeSauce(sauceType));
  let prepareCheese = memoize(async () => {
    return grateCheese((await prepareSauce()).determineCheese());
  });
  
  let [ dough, sauce, cheese ] = 
    await Promise.all([
      prepareDough(), prepareSauce(), prepareCheese()
    ]);
    
  dough.add(sauce);
  dough.add(cheese);
  
  return dough;
}

This is my favorite solution. Instead of setting promise in advance, they run implicitly in parallel. We can set up three memoized tasks (make sure they run only once at a time) and run them in parallelPromise.allThey are called to run in parallel.

Here, exceptPromise.allIn addition, we have almost no other promise, although they operate at the bottom of async / await. I’m writing another article about this patternOn caching and parallelismIn more detail. But in my opinion, this leads to the perfect combination of optimal parallelism and readability / maintainability.

Of course, I’m always willing to be proved wrong, so if you have a favorite onemakePizzaRealization, please let me know!

So we quickly made a pizza. Where is the point?

The point is, if you’re planning to write fully parallel code, even with the latest node.js It is still a necessary skill to know how to mix promise and async / await. Whatever you like bestmakePizzaHow to implement it, you still need to consider how to combine promise links to minimize unnecessary delay when the function runs.

That’s all for async / await. If you don’t understand how promise works in your code, you’ll get stuck and find no obvious way to optimize your parallel tasks.

Here we are

Don’t be afraid to abstract auxiliary functions from your business logic, promise / parallel logic. Once you understand how promise works, you can get rid of spaghetti clutter in your code, and make your asynchronous programs / business logic functions more clearly reflect what you want to do, rather than always cramming into templates.

If the user logs in, it checks every ten seconds and resolves when the following conditions are detected in promise:

function onUserLoggedIn(id) {
  
  return ajax(`user/${id}`).then(user => {
    
    if (user.state === 'logged_in') {
      return;
    }
    
    return new Promise(resolve => {
      return setTimeout(resolve, 10 * 1000));
    }).then(() => {
      return onUserLoggedIn(id);
    })
  });
}

This is not the function I want to execute – business logic and promise / delay logic are very tightly coupled. I have to read and understand the whole passage before I want to make some adjustments to the function.
To improve this, I can split async / promise logic into some independent auxiliary functions, and make my business logic more concise

function delay(time) {
  return new Promise(resolve => {
    return setTimeout(resolve, time));
  });
}
function until(conditionFn, delayTime = 1000) {
  return Promise.resolve().then(() => {
    return conditionFn();
    
  }).then(result => {
    
    if (!result) {
      return delay(delayTime).then(() => {
        return until(conditionFn, delayTime);
      });
    }
  });
}

Or a super compact version of these auxiliary functions:

let delay = time =>
    new Promise(resolve =>
        setTimeout(resolve, time)
    );
let until = (cond, time) =>
    cond().then(result =>
        result || delay(time).then(() =>
            until(cond, delay)
        )
    );

thenonUserLoggedInIt is not so tightly coupled with process control logic.

function onUserLoggedIn(id) {
  return until(() => {
    return ajax(`user/${id}`).then(user => {
      return user.state === 'logged_in';
    });
  }, 10 * 1000);
}

Now I hope to be able to read and understand easily in the futureonUserLoggedIn. As long as I remember the interfaceuntilFunction, you don’t have to rearrange its logic every time. I can throw it in onepromise-utilsThe most important thing is to focus on your application logic.

Yes, we’re talking about async / await, right? Well, today is our lucky day. Since async / await and promise can be used together, we just accidentally created an auxiliary function that can continue to be used, even with async function:

async function onUserLoggedIn(id) {
  return await until(async () => {
    let user = await ajax(`user/${id}`);
    return user.state === 'logged_in';
  }, 10 * 1000);
}

So whether the code is based on promise or async / await, the rules are the same. If you find parallel logic trapped in your async business functions, be sure to consider whether you can extract a little bit. Of course, within a reasonable range.

Here’s a fairly large set of abstractions that might help.

So, if you want to get something out of this article, that’s it: if you’re writing async / await code, you should not only understand how promise works, but also use them to build your async / await code if necessary. Async / await alone won’t give you enough functionality to completely avoid promise thinking.

Thanks!

— Daniel