Implementing Async/Await
Giovanni Sarciotto
Posted on February 15, 2021
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:
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.
- The code feels weird. Why should
main
know aboutasyncWrapper
?main
should be able to just call the async operation and everything should be handled in the background. - Where would we do error handling?
- What if the asynchronous operations calls the callback multiple times?
- 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? - 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.
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:
- Initialize our code.
- Take the promise that is yielded to it, wait for its fulfillment and then give control back to our code with the resolved value.
And that's it! The problem 3 from our list is automatically solved whenever we use promises. The full code is the following:
Let's walk through the execution.
- First we call our runner with the
main
function generator. - The runner initializes our generator and then calls
it.next()
. This gives control tomain
. - Main executes until the
yield
. It yields the return value ofaddAsync
, which is a promise. This promise is unfulfilled at the moment. - 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 tomain
. - 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:
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:
Now our code doesn't crash with non-Promise values:
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:
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:
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:
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:
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 :)
Posted on February 15, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
December 25, 2022