
Intro
I haven't touched JavaScript (JS) for over 10 years. You can't really escape it these days, as pretty much most of the web stuff deals with it. A while back I wrote a Google Chrome extension, that mostly required implementation in JS. After that I didn't really touch JS on a professional level.
I am a system developer, meaning that most of my programming and research is based around languages like C, C++, C# and the like, that require a good understanding of asynchronous programming and of its concepts. In other words, I'm very intimately familiar with multi-tasking and threads.
Recently, I was forced by Google to recode my extension, which required me to go back to JS. At first, it didn't sound too intimidating. It's just the same procedural programming, right. What could have they done to JS to make it difficult?
This blog post will loosely stick to the childish language that the modern JS documentation uses (eg: "promises", "pending promise", "fulfilled promise", "rejected promise", etc.) But instead of rehashing what has been documented a million times, I will try to address what is happening under the hood of the JS engine from the low-level perspective.
Some basic understanding of the modern JS language is required.
Finally, if you're just learning JS and what to know what all those concept are, this is probably not the post for you. I'm writing it mostly for myself, to understand the crazy code flow that the modern JS takes. I also do it for any C, C++, C# developers that want to understand it as well.
Procedural Code Flow (My Understanding)
When I write some code, I'm expecting it to run procedurally, meaning that execution goes from start to finish in a linear fashion. Sure, there can be if, return and other statements, maybe also callbacks, etc. that can redirect the code flow, but I would expect it to go in a predictable manner.
In that sense I'm not a big fan of exceptions and the way they can unexpectedly redirect the code flow. But at least we can guard against it with the try / catch blocks.
So JS introduced the concept of "asynchronous" code flow, and the async and await keywords. That was quite a familiar concept and thus if I saw a JS snippet like this:
I would assume that the await keyword will "wait" for the testFunction
to finish before executing the next line: when it will add 1 to the result
and assign it to v
.`
Or, so I thought.
A few hours after trying to understand the totally insane results of running my JS functions, I decided to run some tests to see what happens under the hood. This usually helps me to grasp the concept.
The rest of this blog post will share my discoveries.
JavaScript != Asynchronous
JavaScript is implemented as a single-threaded model. And thus it cannot run asynchronously. Period.
But that would be a short blog post, wouldn't it. So what gives? And why did they come up with those async and await keywords?
Well, like those early single-core CPUs, JavaScript attempts to "fool us" with its asynchronicity by doing internal gymnastics with its code flow.
The easiest way to understand it is to run a couple of tests.
Real Asynchronous Code
First, let's look at some code that really runs asynchronously. Say, this simple example in C++:
#include <thread>
#include <iostream>
int main()
{
//Start our thread
std::thread thread([]() -> void
{
//Another thread starts here
std::cout << "Inside another thread";
return;
});
//Wait for our thread to finish
thread.join();
return 0;
}
This is C++, and you probably hate it, but bare with me for a second. Let me explain what it does:
The std::thread
constructor initiates and starts another thread that begins running at some immediate moment in time right after that line of code executes. So in a sense, that line "forks" our main execution thread into two. After that point they will have an independent existence (if you want to anthropomorphize it) and will not depend much on one another.
Then if we want to wait in our main thread for our second thread to finish running, we can execute thread.join()
that will do just that.
So this was my understanding of true asynchronous programming before I started working with the modern JavaScript and its async and await keywords. And thus my total confusion over this code sample:
Sure enough, as the await keyword suggested, that code flow will wait for the testFunction
to finish running, which will return the result
that I can use later for my calculations.
But that is not what happens in JS.
I then realized, that the "awaiting" logic of the thread.join()
function in C++ cannot be applied to a single-thread environment of JS due a simple fact, that the process of "waiting" requires the waiting thread to relinquish control to other threads. And in case of a single-threaded environment like JavaScript, this is obviously not possible. If you block the only thread for "waiting", there will be nothing left to run. 😁
And this is when the craziness of the "asynchronous" JS comes into play.
await Keyword Is a Misnomer
Whoever named the await keyword in JS did not really make it easy on people that were coming to JavaScript from other lower-level languages. The function of that keyword does no "waiting" at all. It'd be more appropriate to call it "run_and_return". But that is a mouthful.
Let's review what happens with the following JS example.
To let you better visualize the code flow I'll use sequential numbers in the comments on the right to show which instructions follows what.Also remember that it all runs in a single-threaded mode.
I'll use a very simple example of a webpage that you can copy and paste into an .html
file that you can then test in your web browser.
Since there are technically only two web engines on the market today: Chromium and Gecko/Firefox, I will test all the samples with those.
<!DOCTYPE html>
<html>
<head></head>
<script>
function test(n)
{
return new Promise((resolve, reject) => // 5 // 14
{
console.log("Empty promise" + n); // 6 // 15
longWait(n); // 7 // 16 (takes 5 seconds)
resolve("Done" + n); // 8 // 17
});
}
async function testAsync()
{
console.log("Before promise1"); // 3
let result1 = await test(1); // 4
console.log("After promise1, result1=" + result1); // 11 (result1 = "Done1")
console.log("Before promise2"); // 12
let result2 = await test(2); // 13
console.log("After promise2, result2=" + result2); // 18 (result2 = "Done2")
return "This goes nowhere"; // 19
}
function runTests()
{
console.log("Starting our test"); // 1
let final_result = testAsync(); // 2
console.log("Got final result=" + final_result); // 9 final_result = Promise
longWait(100); // 10 (takes 5 seconds)
}
</script>
<body>
<script>
runTests(); // Tests start here
</script>
</body>
</html>
To ensure that I can introduce a special delay into my tests, without using timers, I needed to write my own longWait
function.
longWait
is a really bad function to use in production, since it will waste a lot of CPU cycles and may even have your script terminated by the web browser.So DO NOT use it other than for running tests!
I wrote it specifically to "spin" the CPU and not to return control back to the JS engine:
function longWait(n)
{
//WARNING: This function will block the UI for 5 seconds!!!
console.log("Before longwait" + n);
let startTime = performance.now();
for(;;)
{
let duration = performance.now() - startTime;
if(duration >= 5000)
{
break;
}
}
console.log("After longwait" + n);
}
Now that we have everything at play, let's run our Test Run 1
in a web browser and observe its output in the "Console" tab in the dev tools.
You can get to dev tools by clicking F12 on the keyboard.
In my case I got the following output after the script finished running:
16:46:45.017 Starting our test
16:46:45.018 Before promise1
16:46:45.018 Empty promise1
16:46:45.018 Before longwait1
16:46:50.018 After longwait1
16:46:50.018 Got final result=[object Promise]
16:46:50.018 Before longwait100
16:46:55.018 After longwait100
16:46:55.018 After promise1, result1=Done1
16:46:55.018 Before promise2
16:46:55.018 Empty promise2
16:46:55.018 Before longwait2
16:47:00.018 After longwait2
16:47:00.019 After promise2, result2=Done2
You need to enable time-stamps in the settings for the dev tools in your web browser to be able to see them in the console.
If you follow the sequence of calls using the numbers in the comments on the right, you will quickly notice something that immediately stood out for me:
The let result1 = await test(1)
call (sequence number 4) transferred control to a Promise
inside the test
function, that immediately started executing instructions in its body, including our longWait(n)
(sequence number 7). But then, as soon as it finished executing instructions in the Promise
scope (after sequence number 8), the code flow did not return back to the instruction following our let result1 = await test(1)
(sequence number 4), that mind you, called it, and instead jumped to console.log("Got final result=" + final_result)
(sequence number 9), that follows our "async" function call let final_result = testAsync()
(sequence number 2).
Additionally, note that the testAsync()
retuned the Promise
object, and not what I explicitly set it to return (sequence line 19.)
This whole sequence really threw me off.
Workings Of await/async Keywords
What happens with the "await" keyword is this. It first invokes the function that is used with it, and then when the "Promise" in that function finishes executing all of its instructions in its body, the code flow jumps to the instruction that follows the one that called the "async" function, regardless of the code flow that was happening inside the "async" function itself.
Without going into too many pedantics, the "await" keyword is kinda similar to ".then" syntax, which we will see in later tests.
After that, when the code flow returns back to the JS engine - in the example above after the longWait(100)
(sequence number 10) - any abandoned code paths, like after the let result1 = await test(1)
line (sequence number 4), resume execution.
Thus, you can view the instruction with the "await" keyword as a form of running the async function that it invokes and then by throwing an invisible exception when it finishes execution of the "Promise" body. This sequence returns control back the JS engine with a "promise" to resume execution of what was left in the async function at a later pass.
The idea behind all this madness is to ensure that the JS engine receives control back (at some regular intervals) to let it do its job, such as updating the UI, and being "responsive" in general.
Another quirk that you may have noticed is that instructions inside "async" functions may execute in several passes, like in case of our testAsync
function in the sample above.
This creates a total confusion with the return
statements in async functions. Like in case with the return "This goes nowhere"
statement (sequence number 19), that is pretty much swallowed by the JS engine due to the fact that the testAsync
function already returned a "Promise" object on an earlier pass (sequence number 9).
I guess, this nuttiness was needed to allow a semblance of multi-threading in a single-threaded environment.
Next, let's review what happens if you "reject" a promise.
Rejecting a Promise
Let's modify our example to "reject" a promise. In reality it just means an alternate path to return from the "Promise".
In that case we will need the catch
statement at the end of the "Promise" itself:
<!DOCTYPE html>
<html>
<head></head>
<script>
function test(n)
{
return new Promise((resolve, reject) => // 5 // 16
{
console.log("Empty promise" + n); // 6 // 17
longWait(n); // 7 // 18 (takes 5 seconds)
reject("Failed" + n); // 8 // 19
resolve("Done" + n); // 9 // 20 (This call will be ignored)
});
.catch(function(e)
{
console.log("Failed promise1_" + n + ": " + e); // 12 // 21
});
}
async function testAsync()
{
console.log("Before promise1"); // 3
let result1 = await test(1); // 4
console.log("After promise1, result1=" + result1); // 13 (result1 = undefined)
console.log("Before promise2"); // 14
let result2 = await test(2); // 15
console.log("After promise2, result2=" + result2); // 22 (result2 = undefined)
return "This goes nowhere"; // 23
}
function runTests()
{
console.log("Starting our test"); // 1
let final_result = testAsync(); // 2
console.log("Got final result=" + final_result); // 10 final_result = Promise
longWait(100); // 11 (takes 5 seconds)
}
</script>
<body>
<script>
runTests(); // Tests start here
</script>
</body>
</html>
Here's the output in the console from that run:
19:20:03.683 Starting our test
19:20:03.683 Before promise1
19:20:03.683 Empty promise1
19:20:03.683 Before longwait1
19:20:08.683 After longwait1
19:20:08.684 Got final result=[object Promise]
19:20:08.684 Before longwait100
19:20:13.684 After longwait100
19:20:13.684 Failed promise1_1: Failed1
19:20:13.684 After promise1, result1=undefined
19:20:13.684 Before promise2
19:20:13.684 Empty promise2
19:20:13.684 Before longwait2
19:20:18.684 After longwait2
19:20:18.684 Failed promise1_2: Failed2
19:20:18.684 After promise2, result2=undefined
Otherwise, the sequence of execution pretty much remains the same as above. I also indicated it with sequential numbers in comments on the right of each instruction.
"Rejecting" a "Promise" requires you to catch it outside of its body, like I did above. As you see from the code flow sequence above, the "promise rejection handler" (or whatever it's called) executes only after the execution returns back to the JS engine.
Also note that when a function that called a "rejected Promise" returns, the result is an undefined variable, as I noted after the let result1 = await test(1)
call (sequence number 13).
One more thing to note here is that once you call eitherresolve
orreject
in the body of the "Promise", you may continue executing other instructions. The execution of the body of the "Promise" ends only when you return from it, or throw an exception.Additionally, calling another
reject
orresolve
will be ignored.
Then let's see what happens if we nest a "Promise" inside another "Promise".
Nested Promises
Let's modify our JS example one more time:
<!DOCTYPE html>
<html>
<head></head>
<script>
function test(n)
{
return new Promise((resolve, reject) => // 5 // 22
{
console.log("Begin promise1_" + n); // 6 // 23
let resultP = new Promise((resolve, reject) => // 7 // 24
{
console.log("Begin promise2_" + n); // 8 // 25
longWait(10 + n); // 9 // 26 (takes 5 seconds)
console.log("End promise2_" + n); // 10 // 27
resolve("Done2_" + n); // 11 // 28
}).catch(function(e)
{
console.log("Failed promise2_" + // - (never executes)
n + ": " + e);
});
console.log("End promise1_" + n + // 12 // 29 (resultP = Promise)
", resultP=" + resultP);
resultP.then((val) => // 13 // 30
{
console.log("resultP-then: " + val); // 18 // 33 (val = "Done2_1" and next pass: val = "Done2_2")
});
console.log("Resolving promise1_" + n); // 14 // 31
resolve("Done1_" + n); // 15 // 32
}).catch(function(e)
{
console.log("Failed promise1_" + n + ": " + e); // - (never executes)
});
}
async function testAsync()
{
console.log("Before promise1"); // 3
let result1 = await test(1); // 4
console.log("After promise1, result1=" + result1); // 19 (result1 = "Done1_1")
console.log("Before promise2"); // 20
let result2 = await test(2); // 21
console.log("After promise2, result2=" + result2); // 34 (result2 = "Done1_2")
return "This goes nowhere"; // 35
}
function runTests()
{
console.log("Starting our test"); // 1
let final_result = testAsync(); // 2
console.log("Got final result=" + final_result); // 16 final_result = Promise
longWait(100); // 17 (takes 5 seconds)
}
</script>
<body>
<script>
runTests(); // Tests start here
</script>
</body>
</html>
And the output from the console for that code flow sequence:
15:38:18.685 Starting our test
15:38:18.685 Before promise1
15:38:18.685 Begin promise1_1
15:38:18.685 Begin promise2_1
15:38:18.685 Before longwait11
15:38:23.685 After longwait11
15:38:23.686 End promise2_1
15:38:23.686 End promise1_1, resultP=[object Promise]
15:38:23.686 Resolving promise1_1
15:38:23.686 Got final result=[object Promise]
15:38:23.686 Before longwait100
15:38:28.686 After longwait100
15:38:28.686 resultP-then: Done2_1
15:38:28.686 After promise1, result1=Done1_1
15:38:28.686 Before promise2
15:38:28.686 Begin promise1_2
15:38:28.686 Begin promise2_2
15:38:28.686 Before longwait12
15:38:33.686 After longwait12
15:38:33.686 End promise2_2
15:38:33.686 End promise1_2, resultP=[object Promise]
15:38:33.686 Resolving promise1_2
15:38:33.686 resultP-then: Done2_2
15:38:33.686 After promise2, result2=Done1_2
You can mostly see from the code flow sequence what is going on there.
The JS engine first executes the instructions in each "Promise" body, and only when it reaches the end of the last nested promise it returns to the body of the parent "Promise" and continues on until it reaches the end of its body. At that point the execution jumps to console.log("Got final result=" + final_result)
(sequence number 16), which follows the previous call to let final_result = testAsync()
(sequence number 2.)
My instinct tells me that it would jump toconsole.log("After promise1, result1=" + result1)
(sequence line 19). But nope, it jumps toconsole.log("Got final result=" + final_result)
(sequence line 16) instead.
Again, if you remember from the first simple sample, anything after the async function call executes right after the await
function inside of it finishes running its "Promise" body.
Also note the resultP.then((val)
line (sequence number 13.) The then
keyword is a cousin to await
, so the same rules apply to it: the code flow skipped its "body" until the execution returns back to the JS engine.
When the execution reaches back to the JS engine, any pending await'ed "Promise" begins executing. We can see that in sequence line 18, or with the console.log("resultP-then: " + val)
statement. After that the execution resumes from the await
statement (sequence line 19), or after the let result1 = await test(1)
statment.
Note that all pendingawait
statements seem to be executing on the first-in-last-out principle. In the example above, thelet result1 = await test(1)
statement (sequence line 4) was first to execute than theresultP.then((val)
statement (sequence line 13), but it was the last one to resume execution.Again, note that
await
and.then
statements do pretty much the same thing.
Then we begin executing the second pass with the let result2 = await test(2)
call (sequence line 21) that pretty much repeats it, with the only difference that we have already executed our let final_result = testAsync()
call (sequence line 2) that invoked it. So any await
and .then
statements will now return execution straight back to the JS engine.
It took me while to wrap my head around that sequence.
Finally note that neither of the catch statements in the code sample above executed during our test run because both "Promises" called resolve in their bodies. Those would run if a "Promise" called reject instead.
Conclusion
So here you go. I'm not sure if this was more educational or confusing?
I am not trying to give any reasons behind this crazy implementation of the "async" JavaScript, other than just thinking of some mental gymnastics to give it a semblance of multi-threading. And for that it serves its purpose.
What it doesn't serve (in my view) is the purpose of readability. You don't have to go far to prove my point than to just see the crazy sequence of execution in my last sample.
If this doesn't confuse you, I don't know what else will?