DEV Community

loading...

Implementing Async/Await

Giovanni Sarciotto
Computational Physicist working as a web developer.
・6 min read

On my last post we saw the theory behind generators in JS/TS. In this article I will apply those concepts and show how we can use generators to build something similar to async/await. In fact, async/await is implemented using generators and promises.

Delving into async with callbacks

First we will show how we can deal with asynchronicity using generators by writing an example with callbacks.

The idea is as follows. When using callbacks, we pass some function that will be called whenever the async action has finished. So what if we don't call a callback, but instead call next on some generator? Better yet, what if this generator is the code that called our async function? That way we would have a code that calls some asynchronous process, stays paused while the asynchronous process isn't finished and return its execution whenever it is ready. Check this out:

Implementation with callbacks

If you don't know what is ...args in the implementation above, take a look at spread syntax.
We wrap our asynchronous operations with asyncWrapper. This wrapper just pass a callback to give control back to the generator main whenever the async process is completed. Notice how our code in main looks totally synchronous. In fact, just looking at main, we can't assert if there is anything asynchronous at all, although the yield gives a hint. Also notice how our code is very similar to what it would have been with async/await, even though we don't use Promises. This is because we are abstracting away the asynchronous portions from our consuming code main.

Using callbacks like above is fine, but there are some problems.

  1. The code feels weird. Why should main know about asyncWrapper? main should be able to just call the async operation and everything should be handled in the background.
  2. Where would we do error handling?
  3. What if the asynchronous operations calls the callback multiple times?
  4. What if we wanted to run multiple async operations in parallel? Since a yield corresponds to a pause in execution, we would need to add some complicated code to decide if when we call next is it to execute another operation or is it because an asynchronous operation has finished?
  5. We have the problems that normal callbacks do (callback hell, etc).

Promises to the rescue

We can solve the problems above utilizing Promises. We will begin with a simple implementation with only one yield and no error handling and then expand it.

First we need to make our asynchronous operation addAsync return a promise, we will deal with the case that it doesn't later.

addAsync with Promises

To solve 1, we need to change our wrapper to receive the code that we want to execute, becoming a runner. This way our runner does the things it needs and gives control back to our code whenever it is ready, while hiding how anything works from our code. The runner needs to do essentially two things:

  1. Initialize our code.
  2. Take the promise that is yielded to it, wait for its fulfillment and then give control back to our code with the resolved value.

Basic runner implementation

And that's it! The problem 3 from our list is automatically solved whenever we use promises. The full code is the following:

Basic implementation with promises

Let's walk through the execution.

  1. First we call our runner with the main function generator.
  2. The runner initializes our generator and then calls it.next(). This gives control to main.
  3. Main executes until the yield. It yields the return value of addAsync, which is a promise. This promise is unfulfilled at the moment.
  4. Now the control is with the runner. It unwraps the value from the generator yield and gets the promise. It adds a .then that will pass the value of the fulfilled promise to main.
  5. Whenever the promised is resolved and the runner gives control to main, the yield expression evaluates to the resolved value of the promise (5) and continues the execution until the end.

Dealing with non-Promises values

At the moment, our runner expects to receive a Promise. However, by the spec, you can await any value, Promise or not. Fortunately, to solve this is very easy.

Consider the following synchronous add function:

Synchronous add function

This code crashes our generator, since our generator tries to call a .then to the yielded value. We can solve this by using Promise.resolve. Promise.resolve(arg) copies arg if it is a Promise, otherwise it wraps arg in a Promise. So our runner becomes:

Runner is now able to deal with non-Promise values

Now our code doesn't crash with non-Promise values:

Execution with synchronous function

If we run our code with addAsync, we will get the same behavior as before!

Dealing with errors

Since we are using Promises, we can easily get any error/rejection that happens in our asynchronous operations. Whenever a promise rejection occurs, our runner should simply unwrap the rejection reason and give it to the generator to allow for handling. We can do this with the .throw method:

Error handling example

Now not only we add a .then, but also a .catch to the yielded Promise and if a rejection occurs, we throw the reason to main. Notice that this also handles the case where we are performing a synchronous operation and there is a normal throw. Since our runner sits below main in the execution stack, this error will first bubble to the yield in main and be handled there in the try...catch. If there was no try...catch, then it would have bubbled up to the runner and since our runner doesn't have any try...catch it would bubble up again, the same as in async/await.

Dealing with multiple yields

We've come a long way. Right now our code is able to deal with one yield. Our code is already able to run multiple parallel asynchronous operations because we are using Promises, therefore Promise.all and other methods comes for free. Our runner, however isn't able to run multiple yield statements. Take the following generator:

Generator with multiple yields

Our runner will deal with the first yield just fine, however it won't correctly give control back to main at all in the second yield, the timeout will finish and nothing will happen. We need to add some iteration capability to the runner so that we can correctly process multiple yield statements. Look at the following code:

Multi-yield code

We use recursion with an IIFE to iterate through the generator. Instead of directly calling .next, we recursively call this IIFE with the unwrapped value of the promise. The first thing the function does is to give control back to the generator with the unwrapped value. The cycle then repeats if there is another yield. Notice that on the last yield (or if there isn't any), then the generator will end and give control back to the runner. The runner checks if the generator has ended and finishes execution if positive.

There is one problem however: if one of the promises rejects, then the cycle is broken and our runner doesn't run correctly. To fix this we need to add an error flag and call .next or .throw based on this flag:

Multi-yield runner with error handling

Conclusion

We've implemented something really close to async/await. If you look at the V8 blog you will notice that our program does essentially the same thing. I suggest reading the blog post above, there is a cool optimization that if you await promises, then the engine is so optimized that your code will run faster than just using promises with a .then.

With this post I finish writing about generators, at least for now. There is an interesting topic that I didn't touch that is coroutines. If you want to read about it, I recommended this post.

For my next post I think I will write about Symbol or the Myers diff algorithm (the default diff algorithm for git). If you have any doubts, sugestions or anything just comment below! Until next time :)

Discussion (0)