How does the Call Stack behave when async/await functions are used ?
const asyncFuntion=async()=>{
//some asynchronous code
}
const first = async()=>{
await asyncFuntion();
console.log('first completed');
debugger;
}
const second = ()=>{
console.log('second completed');
debugger;
}
function main(){
first();
second();
}
main();
In the above code, when the first breakpoint is encountered in second(), I could see that the call stack contained main() and second(). And during the second breakpoint in first(), the call stack contained main() and first().
What happened to first() during the first breakpoint. Where is it pushed ? Assuming that the asyncFunction() takes some time to complete.
Someone please help.
First off, when you get to the breakpoint you hit in second
, first
has already executed and is no longer on the stack.
When we go into first
, we instantly hit an await asyncFunction()
. This tells JavaScript to call the asyncFunction, then feel free to go looking for something else to do while we're waiting for it to finish. What does Javascript do?
Well, first of all, we have to sort out asyncFunction
. We throw that function call into the event loop. That is to say, we put it in a queue of "things to do when we have time". We'll come back to it when it's finished.
Now we need to go find something else to do with our free time. JavaScript can't continue with the next line of first
(ie, the console.log('first completed')) because our await
means we can't carry on the next line until the asyncFunction
has finished.
So, we look up the stack. Javascript sees that first
was called from main, and first
itself wasn't await
ed. In other words, we told Javascript it doesn't matter if first is async, just carry on regardless. So, we skip right on to second
. Once we've executed second
, we look back to whatever called that, and carry on with execution in the way everyone would expect.
Then, at some point in the future, our asyncFunction
finishes. Maybe it was waiting on an API call to return. Maybe it was waiting on the database to reply back to it. Whatever it was waiting for, the signal got sent, and it's ready to be dealt with. It's not "called" from main
- internally the function is picked back up, somewhat like a callback, but crucially is called with a completely new stack, which will be destroyed once we're done calling the remainder of the function.
Given that we're in a new stack, and have long since left the 'main' stack frame, how do main
and first
end up on the stack again when we hit the breakpoint inside it?
For a long time, if you ran your code inside debuggers, the simple answer was that they wouldn't. You'd just get the function you were in, and the debugger would tell you it had been called from "asynchronous code", or something similar.
However, nowadays some debuggers can follow awaited code back to the promise that it is resolving (remember, await
and async
are mostly just syntactic sugar on top of promises). In other words, when your awaited code finishes, and the "promise" under the hood resolves, your debugger helpfully figures out what the stack "should" look like. What it shows doesn't actually bear much resemblence to how the engine ended up calling the function - after all, it was called out of the event loop. However, I think it's a helpful addition, enabling us all to keep the mental model of our code much simpler than what's actually going on!
Some further reading on how this works, which covers much more detail than I can here:
- Zero-cost async stack traces
- Asynchronous stack traces