A Design Space Exploration of Async/Await
32 points by drmorr
32 points by drmorr
Swift wasn’t listed in the eager/lazy dimension; IIRC it’s eager. I think I prefer eager, since laziness adds a lot of overhead to every async call, which is bad if a function frequently doesn’t need to await anything (e.g. it memoizes some async computation.) In JS this is quite noticeable, with calls to async functions being many times slower. There may be less overhead in Rust since futures are typically stack-allocated.
Speaking of Swift, it supports both definite and indefinite extent, also known as structured vs unstructured concurrency. And Rust supports a limited form of definite extent in the form of ‘async{}’ blocks.
It occurs to me that C++ should have been included; maybe it wasn’t because coroutines are still sort of bleeding-edge. Were it included, results would vary with different coroutine implementations, just as with Rust.
And Rust supports a limited form of definite extent in the form of ‘async{}’ blocks.
I'm not sure I would call that definite extent. After all, an async block just creates a struct of some type, which then needs to be passed to a runtime. At that point it's gonna be exactly as described in the linked to article.
But IIRC the future from an async block has to be awaited before the block leaves scope, or rather, it can’t escape the enclosing scope. That to me makes it more “structured.”
That is incorrect. An async block just creates an anonymous future, that is a first class value you can manipulate however you want.
In fact before async closures were finally stabilised the normal (and very frustrating from an ownership perspective) pattern for “async closures” was:
|| async { … }
Whose entire point is for the closure to return the result of the async block.
The async book chapter 30 (explaining the implementation details) also has the following example:
fn bar() -> impl Future<Output = u8> {
async {
let x: u8 = foo().await;
x + 5
}
}
If you put that in the playground and add some logging, you will see that the async block’s content is not evaluated until the future is awaited, even though the function itself is executed when it is called. An async block is an expression which creates a future, not entirely unlike an anonymous function / closure.
But isn't JS async eager?
No, it creates the Promise and it’s coroutine state immediately when the function is called, then immediately schedules the coroutine.
The article’s classification of “lazy” is that nothing happens to the coroutine until it’s awaited (instantiating the coroutine just creates the state machine), which is very much not how javascript works. The article classifies javascript as eager.
Swift wasn’t listed in the eager/lazy dimension; IIRC it’s eager.
The paper has an expanded table (Table 1, p. 7) that has three columns for that dimension – Lazy, Eager, Semi-Eager – instead of the two in the blog post. It lists Swift in two of them, Eager for "Swift (immediate)" and Semi-Eager for "Swift (async let)".
laziness adds a lot of overhead to every async call […] In JS this is quite noticeable, with calls to async functions being many times slower.
The article classified JS as eager, which it is per their classification: lazy is when the language creates an inactive coroutine and it has to be scheduled explicitly (by await-ing it or converging it to a task).
That is very much not what javascript does, calling an async function immediately spawns a task and schedules it, there is nothing lazy about it. From your issue and the article’s description I guess your issue is JS is too eager? It immediately spawns a task on call of the async function.
Lazy tends to be significantly more efficient since there are way less tasks (and less runtime pressure), however it does have worse failure modes if you never await the result, because nothing happens, silently.
From experience lazy is a fine default for Rust because the compiler will complain pretty loudly if you never await a future so it’s pretty hard to miss one, but it’s an absolutely awful default for Python where no such thing happens and all the runtime can tell you is that a coroutine was GC’d without ever being awaited and you’ve no idea which.
Kotlin's async/await has an interesting design choice that I haven't seen in other languages, though maybe it's not unique: awaiting is effectively opt-out, not opt-in. The default behavior in async Kotlin code is to await, and you have to explicitly do async {} to launch something concurrently.
Not having to spew await all over the place makes async code less cluttered and makes it much easier to convert a blocking function to an async one without otherwise changing its behavior. Often you just need to add a suspend keyword to the function declaration and you're done.
Better still, if you make a mistake and forget to wrap a function call in async {}, the behavior is much more forgiving than when you forget to add await in an opt-in-awaiting language. I've spent days chasing down intermittent bugs in JS code that turned out to be someone inadvertently introducing a race condition by leaving out an await somewhere.
Wow this is awesome! I like the motivating example
In a different domain, I called this "Yahtzee" -- the same program text with N different behaviors.
For my case that was N=4, and different shell's interpretation of this POSIX shell script
command -v echo ZZZ for
echo status=$?
https://github.com/bashix-spec/bashix/blob/main/yahtzee/4-command.sh
Although now I realize there could be a better name than "yahtzee", since rolling distinct values doesn't strictly mean anything in the game. But I found the dice roll metaphor evocative
Anyway, back to async -- I will definitely read this, because I have been puzzling over the difference between Python and JS
In a different domain, I called this "Yahtzee" -- the same program text with N different behaviors.
I like the name! It's intuitive as well.
rolling distinct values doesn't strictly mean anything in the game
Well, it has a straight, like in poker.