I recently came to Javascript from too many years in a C/C++/Python world. Javascript is very familiar in many ways, and different in quite a few as well (see: all the ways to import/export in Javascript — not that Python’s module/import system is so great either.) As a systems-level programmer, I like to know how things work all the way down to the metal. It’s satisfying, but it also really helps with debugging.
One thing I didn’t really understand at a deep level is how Promises and async/await work in Javascript. The examples I read all had to do with waiting for some kind of I/O or a timeout. I wondered “what’s the magic that makes the I/O or timeout wake the main thread back up?” I didn’t know whether that was part of the architecture or just a common-enough use case that nobody bothered talking about other event types.
It turns out there’s no magic, but there’s significant code in the Javascript runtime to make all this work. So now that I figured out how all this stuff works, here’s a working example that will hopefully illuminate some dark corners for those of you, like me, who like to know how things really work rather than just “write it like this and it’ll be fine.”
A few things to know before we start into the example:
- Javascript is single-threaded. Promises and async/await are not a replacement for multithreading; you still are only running on one core. There’s an event loop always running in Javascript. In the browser, it’s run by the browser itself to process user input and network events. In node.js, it’s what runs the functions you specify, and when there’s nothing left to do, it exits. (In recent Pythons there are various event-loop implementations, but they’re layered on — and in C++ it’s a free-for-all as usual.)
- Async functions start running immediately when you call them, but when they get to an await on a Promise, that creates a closure of the current stack state, with all local variables and the whole execution context, and that promise + closure gets put on a list of pending functions (more detail below). The event loop runs any “resolved” Promises whenever it gets control back from user code. In this way, it’s like a python generator calling yield.
Our example is of a promise that can get woken up from anywhere by calling a function. Here it is. You’ll want to check it out in the JSFiddle.
What this prints out as you run it is this:
waiting…
(wakeable: creating Promise, setting wakeup to resolve function)
main: about to call wakeup
wakeup: Woke up!
wakeup: after resolve
Reached end of source file
handle_event: await returned OK!
waiting…
(wakeable: creating Promise, setting wakeup to resolve function)
wakeup: Woke up!
wakeup: after resolve
handle_event: await returned OK!
waiting…
(wakeable: creating Promise, setting wakeup to resolve function)
So one step at a time:
- The functions
wakeable
andhandle_event
get defined - we call
handle_event()
, which starts to run. - At the line
await wakeable()
, Javascript first callswakeable()
(which will return a Promise), and then passes that Promise to await. At that point that Promise, and the current execution context, gets pushed onto a queue for the event loop to check later. But first, how does the Promise get created inwakeable()
? - The Promise constructor takes one arg, a function which itself takes two args (
resolve
andreject
). The promise constructor calls (right now, synchronously) that anonymous function, passing it its own internally-created resolve and reject methods as args. (When we finally get around to calling that resolve, it’ll mark the Promise as resolved.) In our case, the function creates another anonymous function which calls the original resolve (which, remember, was passed into us — it’s internal to Promise), and assigns that function to the global varwakeup
. So later when we callwakeup()
it’ll call the Promise’s resolve method. Whew! Got all that? (It would be harder to make these non-anonymous functions, because they need to be closures to get the original resolve.) - OK, back to top-level. After the call to
handle_event
returns (it’s async, and the continuation has been put on the queue, but in the main thread, it returns normally), the next thing is we callwakeup()
. -
wakeup
is now a function — we created it in step 4. It just calls the Promise’s resolve method. All that actually does is set a flag on the Promise (which is saved on an internal queue in the JS runtime), saying it’s now ready, and it’s resolved successfully. It also saves any value we pass into resolve as the return value you get from awaiting the Promise, or in a.then
call. (You can reject as well.) - Next, we set a timeout that will call
wakeup
again after a while. (I’m not going to cover timeouts here; basically they go on a similar execution queue in the runtime.) - Now we’ve reached the end of the source file. Time to exit, right? Not so fast. There’s still a pending Promise on the queue, so the runtime sees if it’s ready.
- It is ready, so the Promise runtime then calls all its
.then
functions andawait
continuations — in our case just completing the first iteration of the while loop inhandle_event
, which loops back around and stops on the next await, creating a new continuation and returning to the runtime’s event loop. - At this point the only thing left on the execution queue is that timeout. The JS runtime waits out the clock, and then calls
wakeup
again. - As before,
wakeup
resolves its Promise, which just sets a flag and returns. - Now the runtime gets control again, sees that it has a resolved Promise, so calls its continuations, which takes us around the while loop one more time, and back to its await.
- At this point there’s nothing left to do; there aren’t any Promises or timeouts or anything. If you run this code in node.js or jsfiddle, it’ll exit. In the browser, the page will just sit there waiting for user input. And that’s all, folks!
More about async and await:
All async functions always return a Promise. If you write async function foo() { return 1 }
it will actually return a resolved Promise with a value of 1. If your async function has an await, the returned Promise will only resolve when the await has finished, and the rest of the code in the function has run. But notice that the async function returns to the caller immediately. There’s no waiting going on, ever. Now if the caller awaits the async callee like this:
async function callee() {
return 100;
}
async function caller() {
let val=await callee()
await new Promise(resolve => setTimeout(resolve, 100)); # 100 ms
return val+1
}
async function parent() {
let val=await caller()
return val+1
then the same thing happens: the caller returns (immediately) a Promise to its parent that only resolves when its await returns, which only returns when that callee’s await returns, and so on all the way down. There’s essentially a call graph that gets built up so that whenever the runtime loop gets control back, it calls the next thing that’s runnable in that call graph.
Note that in this example, parent calls caller, caller calls callee, which returns its value — all this happens without waiting, synchronously. The first await is the one in caller; that puts the rest of caller's code into a continuation and returns. Similarly parent puts the rest of its code after the await into a continuation and returns.
If you call an async function without awaiting it, it’ll return its Promise which you can wait on later, or not. If you don’t, the rest of the function after the first await will still get run eventually, whenever the event loop gets around to it. Here’s a nice example of that:
async function f1() { await something long... }
async function f2() { await another long thing... }
async function callParallel() {
let p1 = f1() # a Promise
let p2 = f2() # another Promise
await Promise.all([p1, p2])
}
In this case f1
and f2
both get their continuations set up, they return promises to callParallel
, which returns, and then when (eventually) both f1
and f2
resolve, the Promise.all
resolves and the last await returns and callParallel
's implicit Promise resolves (but nobody’s checking that).
Some notes:
- A promise is resolved when its resolve function is called; in this case calling
wakeup()
calls the promise’s internalresolve()
method, which triggers any.then
methods on the next tick of the Javascript event loop. Here we use await, but.then(…)
would work the same way. - There’s no magic; I/O and timeout promises work the same way. They keep a private registry of functions to call when the I/O event or timeout happens, and those functions call the promise’s
resolve()
which triggers the.then()
or satisfies the await.
By the way, unlike async in python, leaving a pending promise “open” when the process exits is perfectly fine in Javascript, and in fact this demo does that. It exits when there’s no more code to run; the fact that the while loop is still “awaiting” doesn’t keep the process running, because it’s really just some closures stored in a queue. The event loop is empty, so the process exits (assuming it’s in node.js — in a browser it just goes back to waiting for events). In python, this would print an error as the process exits — you’re supposed to clean up all your awaits there. Javascript is more forgiving.
Further reading:
Promise docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
Async function spec: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
Async implementation in the Javascript V8 engine — great low-level description of how it works: https://v8.dev/blog/fast-async
Top comments (1)
This was super insightful, great article!
Consider doing a "Async C++ for JavaScript Programmers" !
Would love to hear your same insights coming from the other side. As you say, it's a bit of a free for all in C++ land on this regard; makes it difficult for newcomers to know what to use in common async scenarios.