Whenever I stumble upon "[something] is a syntactic sugar", I appreciate when it is accompanied by a good technical explanation of what exactly that particular "sugar" is translated to behind the scene. Which isn't always the case.
For instance, try googling "async await syntactic sugar". I don't think the statements like "async is a syntactic sugar for promises" are very helpful in grokking async
/await
. In my opinion, the concept of the finite-state machine would be very important in this context, yet I couldn't spot the phrase "state machine" in the top Google-quoted results.
So, here is one question I personally find interesting and relevant for the both sides of a JavaScript/TypeScript interview (as well as C#, F#, Python, Dart or any other programming language which has adopted the async
/await
syntax):
- How would you go about implementing the following
async
function<funcName>
as a simple state machine, without using the keywordsasync
,await
oryield
?
I think it's a one-shot-many-kills kind of question, potentially covering the knowledge of the basic topics like promises, closures, exception handling, recursion, in addition to async
/await
and the state machine concepts themselves.
For a practical JavaScript example, let's take the following simple asynchronous workflow function, loopWithDelay
. It runs a loop doing something useful (doWhat
), with a certain minimal interval between iterations, until the stopWhen
callback signals the end of the loop:
async function loopWithDelay({ doWhat, stopWhen, minInterval }) {
while (!stopWhen()) {
const interval = startInterval(minInterval);
await doWhat();
const ms = await interval();
console.log(`resumed after ${ms}ms...`);
}
console.log("finished.");
}
We might be calling loopWithDelay
like below (runkit). In JavaScript, anything can be awaited, so this works regardless of whether or not doWhat
returns a promise:
await loopWithDelay({
doWhat: doSomethingForMs(150),
stopWhen: stopAfterMs(2000),
minInterval: 500
});
// a higher-order helper to simulate an asynchronous task
// (for doWhat)
function doSomethingForMs(ms) {
let count = 0;
return async () => {
const elapsed = startTimeLapse();
await delay(ms); // simulate an asynchronous task
console.log(`done something for the ${
++count} time, it took ${elapsed()}ms`);
}
}
// a higher-order helper to tell when to stop
function stopAfterMs(ms) {
const elapsed = startTimeLapse();
return () => elapsed() >= ms;
}
// a simple delay helper (in every single codebase :)
function delay(ms) {
return new Promise(r => setTimeout(r, ms)); }
// a higher-order helper to calculate a timelapse
function startTimeLapse() {
const startTime = Date.now();
return () => Date.now() - startTime;
}
// a higher-order helper for a minimal interval delay
function startInterval(ms) {
const sinceStarted = startTimeLapse();
return () => {
const sinceDelayed = startTimeLapse();
return delay(Math.max(ms - sinceStarted(), 0))
.then(sinceDelayed);
};
}
Of course, there are many ways to rewrite this loopWithDelay
without using async
/await
. We don't have to stricly follow a typical state machine implementation as done by programming language compilers (which can be a bit intimidating, e.g., look at what TypeScript generates when it targets ES5. Interestingly, when targeting ES2015, TypeScript transpiles async
/await
using generators as an optimization).
To implement loopWithDelay
manually as a state machine, we need to break down the normal flow control statements (in our case, the while
loop) into individual states. These states will be transitioning to one another at the points of await
. Here's one take at that, loopWithDelayNonAsync
(runkit):
function loopWithDelayNonAsync({ doWhat, stopWhen, minInterval }) {
return new Promise((resolveWorkflow, rejectWorkflow) => {
let interval;
// a helper to transition to the next state,
// when a pending promise from
// the previous state is fulfilled
const transition = ({ pending, next }) => {
// we fail the whole workflow when
// the pending promise is rejected or
// when next() throws
pending.then(next).catch(rejectWorkflow);
}
// start with step1
step1();
// step1 will transition to step2 after completing a doWhat task
function step1() {
if (!stopWhen()) {
// start the interval timing here
interval = startInterval(minInterval);
// doWhat may or may not return a promise,
// thus we wrap its result with a promise
const pending = Promise.resolve(doWhat());
transition({ pending, next: step2 });
}
else {
// finish the whole workflow
console.log("finished.");
resolveWorkflow();
}
}
// step2 will transition to step3 after completing a delay
function step2() {
transition({ pending: interval(), next: step3 });
}
// step3 will transition to step1 after showing the time lapse
function step3(prevStepResults) {
// prevStepResults is what the pending promise
// from step2 has been resolved to
console.log(`resumed after ${prevStepResults}ms...`);
step1();
}
});
}
await loopWithDelayNonAsync({
doWhat: doSomethingForMs(150),
stopWhen: stopAfterMs(2000),
minInterval: 500
});
Equipped with async
/await
, we should never have to write code like loopWithDelayNonAsync
in real life. It still might be a useful exercise though, especially for folks who first got into JavaScript after it had received the native support for async
functions.
Rather than taking async
/await
syntactic sugar for granted, I think it helps to understand how it works behind the scene as a state machine. It also amplifies how versatile, concise and readable the async
/await
syntax is.
For a deep dive into async
/await
under the hood in JavaScript, the V8 blog has an awesome article: "Faster async functions and promises".
Top comments (2)
I suggest avoiding the term "non-async" when explaining the problem. Async is short for asynchronous and a non-asynchronous function is a synchronous function and that is not what you mean. e.g. Although
loopWithDelayNonAsync
isn't an instance ofAsyncFunction
it is an "async" (commonly used in code as shorthand for "asynchronous") function as it returns aPromise
(which represents the fulfillment of an asynchronous operation).One simple way to avoid this ambiguity would be: "How would you go about implementing a state-machine-style version of the following
async
function without using theasync
/await
keywords ..."I was actually contemplating how to avoid this ambiguity, but decided to stick with "non-async" for its literal meaning, no
async
modifier. Moreover, an asynchronous (in common sense) function doesn't even have to return aPromise
, it can be any thenable. Anyhow, it's a great point, thank you. I agree I need to change the wording, and I think I'd also add "... withoutyield
" :)