By Radosław Miernik · Published on · Comment on reddit
The vast majority of the code that most programmers work with is executed synchronously. In this setting, every instruction1 is computed separately, one at a time, in the order defined by the language or the runtime.
However, most programs operate with a database, communicate with external services, or do some sort of background computation. All of these operations have some kind of inherent delay. Classically, the program will pause and wait for the result. Yes, it’ll block the UI as well.
But there’s another way! We could perform these operations asynchronously, i.e., schedule them as well as some code that will be executed once the result is ready. There are many ideas for implementing these APIs, with varying levels of abstractions and performance considerations.
Ideally, we would abstract the waiting away, but we can’t. Or can we?
Making a part of the existing codebase async may sound trivial – especially for the stakeholders – but it has immediate and severe consequences. Why? Because every other piece of code using it has to become async as well. If the architecture wasn’t “ready” for it, it will take tens or even hundreds of lines just to propagate this one change.
I think that the virality2 of
At the same time, I’d say that introducing the
Some languages have some sort of an “async escape hatch”. For example, in Java, the
Future.get method will block the execution until the result is ready. For extra convenience, there’s often another option to wait with a timeout.
If you ever implemented such “waiting” in a low-level language, you know that it’s not that simple. Especially, if you don’t want to implement “busy waiting”, i.e., waiting by repeatedly checking the condition. In some situations, it’s fine, but usually, it’s not, as it tends to max up the CPU while doing so.
fibers package to wrap the
Promises, making it possible to “unwrap” it. Just keep in mind that this package has its downsides, and there’s an ongoing discussion on removing it.
As I already said, being able to write isomorphic (in terms of synchronicity) code is our holy grail. Surprisingly, it’s already possible in Nim! The idea is simple: implement an async function and generate the sync one.
As said in the docs, the
multisync pragma (macro) will do exactly that. When we look into the code, it literally strips down all
awaits. If you’d like to see it in practice, check out this Implementing Redis Client tutorial.
async is a macro in Nim as well. What it does, is replace all of the
awaits with the appropriate iterators;
await is basically a
read(). (I had a blast while working with Nim because I always could jump to the code and see how these complex ideas – like
parallel – are implemented.)
multisync does, e.g., with a Babel plugin. But to make it work, we have to be able to actually wait.
Let’s take a step back and think about what we actually want. We’d like to have an option to maybe wait for a
Promise to resolve and then continue processing. In other words, we’d like to have something like
then that accepts bare values.
While rethinking the form validation flow in uniforms (#711), I wanted to do precisely that. Previously, even if the entire validation flow was synchronous, the result was a
Promise – just because some parts could be async.
As a result, I created a
then function that receives two arguments. The first one is either a value (of type
T) or a
Promise of it (
Promise<T>). The second one is a function (
T -> U), that maps the first argument. The implementation is trivial. Try it yourself:
(x, f) => x instanceof Promise ? x.then(f) : f(x).
Once you have it, you can use it the same way as
.then() on the
Promise object. There are two significant differences: no error handling and the potential of unleashing the callback hell (once again). While the former could be solved by adding a
try..catch block and a second function for the error flow, the latter is solvable only with code transformations (again, like a Babel plugin).
Please note that adding the error handling to it would bring it much closer to the
Result type in Rust and other languages. Whether or not the
Promises should “carry” their error types is another topic3.
All of this fuss, and we finally ended up with a
then function. Was it even worth it? Why won’t we stick to the
await literally everywhere? Well, there’s a problem. A subtle but important one.
Because of the way how ECMAScript standard defined
await, it adds some slight
overhead delay. It’s barely noticeable but quickly accumulates – even in real-life scenarios. It got significantly better with this change, foreshadowed in this excellent writeup and released in V8 v7.2.
However, it’s still not ideal. For example,
for await..of is significantly slower than
for..of for synchronous iterators (nodejs/node#31979; especially this). That’s again a problem that may be solved by changing the standard, but such changes are relatively rare.
And last but not least, using
await always creates new
Promise objects. No, I won’t rant about the memory usage here, as the modern engines excel at handling that. The problem is much simpler: sometimes you’d like to branch off a
Promise just to handle the error.
The idea of multisynchronicity is fairly simple yet basically non-existent in mainstream programming languages. But maybe it’s going to be the next big thing – just like
async. I’m not so sure about it, but it’d be awesome.
As for now, let’s stick to
async. It really makes the code easier to comprehend and maintain. The performance overhead is there, sure, but so are the benefits. It’s up to you to decide, but for me, it’s a no-brainer. It’s just worth it.
Write idiomatic code. Performance will come next. It always does.
An instruction doesn’t necessarily have to be an assembler one. In this context, it can be a statement, expression, or any kind of computation in your language.
In short: they won’t. If you’d like them to, just use a handmade
Result type. Or use any of the hundreds of TypeScript packages that do it.