This doesn't go deep enough IMHO, I only really 'grokked' async/await (and how it differs from stack-switching coroutines) once I understood that it's just syntax sugar for a "switch-case state machine" (meaning you can emulate async/await-style in any language, even plain old C running on a heavily restricted VM like WASM).
For instance look at the JS output of async/await Typescript when a really old JS version is used that didn't support async/await yet.
>> So really when you use async/await, you’re just writing pseudo-sequential code that gets turned into callback code.
Small nitpick:
I wouldn't call it "pseudosequential", personally. By that logic, C is pseudosequential, because when you call a system call, your process state gets saved in the scheduler, control gets passed to the kernel, then when kernel finishes, your process state is resumed. Not even getting started on branch prediction and similar CPU optimizations.
What I mean is, as long as you have a kernel and scheduling, there's no such thing as a real (userspace) sequential program. We just call them sequential because their side effects correspond to a sequential model of execution. So if the side effects of an async/await program corresponds to that of a sequential model, it's a sequential program.
But I get the value of using that word for an explanation of the implementation.
The whole article properly the best explanation of generators I have come across. This quote stuck out:
> Generators are a special type of function that can return multiple pieces of data during its execution. Traditional functions can return multiple data by using structures like Arrays and Objects, but Generators return data whenever the caller asks for it, and they pause execution until they are asked to continue to generate and return more data.
Applications of generators? I have only used Redux-Saga[1]. Can't even think of other libraries that use them, but would be interested in learning.
I think seeing generators as special functions makes them feel more magic than what they really are: generators are nothing more than a syntactic sugar for objects with a “call” method that will change the values of the private fields in this object, that's why you can call it repeatedly with different results.
Some patterns become easier to write with the generator syntax, but it's not really adding expressive power. (Unlike futures for instance)
IMO this is like saying a high-level language is syntactic sugar for assembly.
Generators allow you to write a state machine as a procedural function whose local variables are automatically suspended when you yield and restored when the caller returns control to the generator. They compile this down to, as you said, an object with a call method. But that transformation is as nontrivial as any compilation/transpilation project.
Generators are not used much in the JS ecosystem for some reason, but they're used a lot in Python.
The most common application is iteration (lazy producers or transformers), but they're also used for their ability to suspend and resume function execution (interruptible functions) e.g.
- pytest fixtures use generators to setup, allow the test to run (yielding a value or not), and teardown
- contextlib.contextmanager uses generators for similar purposes of entering a context, allowing the wrapped code to run, and then exiting the context
They can also act as cheap state managers, especially with the ability to inject data back into the generator, so they can be used to implement a multi-step function where the user needs multiple points of interaction, without needing a bunch of callbacks, or complicated state management (especially without the benefit of sum types or static state machines)
I used generators in the way you described in your last paragraph. And I added the possibility to "replay" the generators with the previous inputs except the last one. It provides a cheap way to have a kind of multi-step input process with cancellation.
You can use them in any place where you want to return a bunch of values, but not necessarily all in one go. This goes for bytes in a Uint8Array, points you want to draw in canvas, DOM elements you are selecting or generating on the fy, data objects you've downloaded from somewhere, etc.
The advantage of generators over arrays is that you don't have to allocate a large array, so you can handle large numbers of elements while keeping a lower memory footprint.
They can also hide batching/chunking logic. You could have code that repeatedly downloads of separate JSON files with 200, or 500, or 5000 elements at a time, then return a generator that goes through each element at a time. The consuming code will be none the wiser.
Before async/await, generators were the only way to express async flow control in a synchronous way. Libraries like “co” were really popular at the time.
There are some really downside to async/await which is why redux-saga continued to use generators. Even in 2023, me and a couple others are experimenting with deliminited continuations using generators as the foundation.
You can also think of generators as a native implementation of Observables from rx (except you can't replay a generator), especially async generators.
You can implement basic operators like map, filter, take, etc. over generators to create pipelines of operations. Very neat abstraction to work with, but like rx, can quickly get hard to reason about.
Recently I wrote some tooling to read, do some operations, and write hundreds of thousands of files locally. Using generators solved having to think about not loading too much stuff into memory since it only yields files when consumed. Also allows you simply implement stuff like batching, like running X requests to a server at a time, and only starting the next batch once the first one is done.
Observables and Generators (iterators) are fundamentally different. Observables are push-based (like a promise) whereas iterators are pull-based (like a function).
Glossing over this fact leads to a flawed understanding, not a deeper one.
I use generators to stringify a huge amount (several gigabytes) of generated JSON for transmission over http/ws. Node can only handle about 1-2 gigs in a single string or Buffer -- it's a hard-coded limit depending on CPU architecture, AFAIK. So these huge JSON blobs need to be chunked.
A generator is nice here because it makes it easy for the code that converts the JSON for transmission to wait until the OS has transmitted the previous chunks -- which helps avoid catastrophic back pressure and runaway memory use.
Generators are a super clean way to do stream processing (in fact, in modern node, streams are async iterators, and can be used with “for await”). The really nice thing is that you can chain together a whole pipeline of generators to do transformations on a stream which are much more complex than map.
For example, do you have a stream of chunks, and want to turn it into a stream of lines? The implementation of this with generators is super clean and readable, because it’s like you’re just iterating over a list.
It's obsolete now, but before async/await was part of JS proper I worked on a compile-to-JS language that handled this by compiling to callbacks: https://github.com/rzimmerman/kal
That's a cool idea and it could be a lot more useful with modern approaches, like true type systems and pattern matching. I don't really have time to work on that but I support the effort of bringing some of CoffeeScript back to the world.
From my (admittedly shallow) understanding, each "await" in an async function creates continuation points, is that correct? And then when the internally used Promise resolves or rejects, the function resumes from the continuation point.
> Callbacks are used to hook functions into an event loop. The dispatching/scheduling is handled there.
This part never really made it into my understanding of the concept. For practical purposes I've been completely fine with not thinking about it at all. My function is called at some point, that's all what I find really matters. Thinking about the event loop opens up a can of worms I find more confusing than useful.
> Thinking about the event loop opens up a can of worms I find more confusing than useful.
Probably for daily usage this might be true, but there are definitely cases when you're using JavaScript and not understanding the event loop makes it hard to understand why things are happening. Example:
You'd normally expect this to print "hello" and then "world" since the "sleep time" for the setTimeout is set to 0, but thanks to the event loop, the result will be "world" and "hello". Very simple example, but in real life code bases and beginner JavaScript developers, in can be confusing at times.
Roughly understanding that there is a loop that is doing work on a queue and calling your code is useful insofar that you have a mental model for the IoC that is happening.
I've seen smart and capable programmers who are not used to UI programming and async in general who struggle with callbacks and IoC initially. You can draw a simple picture on a whiteboard to explain the overall concept to give them something to grasp on.
The only reason I know about JS generators, and also about using them to simulate async/await, is only because I was already using JS when they were added to the language. If that hasn't been the case, I probably wouldn't even know they existed.
I haven't used that much JS recently, but my guess is that "for await of" will make generators more widely known and used.
Good article, love the codesanbdox!
I often disregard yield because it's basically the same as having an `await for/of` or `while await` and it's not that clear for beginners.
Thank you to everyone who read my article! Absolutely love some of the discussions going on here. My goal with the article was to peel away one layer of this abstraction and encourage curiosity in engineers who usually don't think about lower level stuff.
The most surprising thing that's bitten me about async/await is that the promise is immediately executed rather than being executed at the point they get awaited.
Definitely something to know, but it's the best way for that to happen when you're waiting on asynchronous requests, as it lets you create the subsequent promises while the first promises is fetching its results
Eh, it's so easy to provide your own implementation that does this, but I guess it wouldn't hurt to include in the standard-library since we already have something like Promise.all()
Here is the implementation I typically use for doing just that:
async function executeQueue(promises, concurrentLimit) {
let index = 0;
let activePromises = [];
async function manageQueue() {
if (index >= promises.length && activePromises.length === 0) return;
while (activePromises.length < concurrentLimit && index < promises.length) {
const promise = promises[index]();
activePromises.push(promise);
index++;
promise.then(() => {
activePromises = activePromises.filter(p => p !== promise);
manageQueue();
});
}
}
await manageQueue();
await Promise.all(activePromises);
}
I mean, why would you want to "cancel" a Promise? You can already cancel HTTP requests, what are some other things you want to cancel really?
If you don't want to continue with something inside a Promise, just pass in something that could be changed from `true` to `false` and it cancels itself based on that.
I've had this same cancellation conversation many times with JS programmers. I think it's fair to say there's a need for standardization. Evidence for this need is network request management is really poor in pretty much all JS apps.
Specifically the case of request management, it's already implemented and available in all browsers practically. It's like four lines of code you have to add/change, to be able to cancel a request. https://developer.mozilla.org/en-US/docs/Web/API/AbortContro...
Regarding other cases, there are already ways of handling those. I don't think just because many ask about something, doesn't mean it's not currently working.
Did you actually look at the link I sent you? It's absolutely not low level. If you know how to use window.fetch, you know how to use AbortController already.
controller = new AbortController();
const signal = controller.signal;
fetch("http://example.com", { signal: signal })
// abort request
controller.abort();
It basically couldn't be simpler? What sort of API would you like to be able to cancel requests?
That's a toy example! In reality in a complex app you'd usually be aborting far from where the fetch is created, and you'd have large promise chains where the semantics of cancellation are non-obvious and even unintuitive.
For instance look at the JS output of async/await Typescript when a really old JS version is used that didn't support async/await yet.
It's switch-case all the way down:
https://www.typescriptlang.org/play?target=1#code/FAMwrgdgxg...