I'm building a new language with async/await and had to make a lot of these decisions, but I didn't have this organized of a framework to ground myself in. I'm happy to see it clearly that I choose mostly Trio with a bit of JavaScript.
My language (Zena's) async docs page: https://zena-lang.dev/guide/async/ I think I might do a pass and try to call out the decision points more explicitly.
fwiw, I found this post on cancellation by the author of Trio to be vey compelling: https://vorpus.org/blog/timeouts-and-cancellation-for-humans... and I based the cancellation design of Zena on it.
Edit to add: I do wish this included JavaScript's AbortSignal in the Cancellation section. Not because it's good, but because passing cancel tokens is a pattern that exists. There's also the dimension of who can cancel and, like AbortSignal, whether tasks have to opt-in to cancellation checks.
I think async is deceptive in that it seems like a self-contained and relatively straightforward aspect of a language. But there are many design choices to be made and they all have wide implications.
Plus I think the implications of many of these dimensions are not fully understood and that collectively we are still trying to understand how they are playing out in implementations. Add to this the subtle nature of some of the implications plus the combinations...
I think a good comparison is lexical vs dynamic scope in programming languages. This is a design dimension that was argued over for a decade or two in the early years of programming language design. It was only as time went by, and experience gained by working with concrete implementations that it became clear that lexical scoping should be the default choice and dynamic scoping should be restricted to various niches.
> We cannot attribute C++ to any particular design point in the taxonomy provided in Table 1 because each axis is configurable. Although elegant and neutral, the choice of full programmability makes each library an async dsl; knowledge transfer between projects within the same language becomes exceedingly difficult.
It is not if you think in terms of these axes and which solve your particular problem and not any particular specific design. Take for instance the simplest program one can imagine: a network video player. E.g. some server sends you RTP audio & video frames and you have to play them back correctly, with a nice GUI on top. If you want to do this in a way that is as efficient as possible you need to be aware of all possible ways of async interoperation:
- connecting & receiving packets from the network in a classic network state machine where coroutines shine
- handling vsync vs not-vsync for displaying the video frame
- conforming to whatever async paradigm the hardware video decoding system you want to use is going to provide you with, e.g. Intel QuickSync vs VideoToolbox vs NVDEC...
- handling the synchronous model of audio playback driven in pull mode
- handling the synchronisation between audio / video, and thus the async patterns that support multi-threading as your audio thread can't be your video or GUI thread
- handling the async model of your GUI library for your play / stop button's callbacks.
There's zero chance that a single async model fits all of these equally well without tradeoffs, so you have to have the knowledge anyways.
Decoding video/audio and talking to the right OS APIs and GPU is far from simple. It is reasonable to implement a http1 client from scratch by hand. For decoding, you need libraries/dependencies. And suddenly you have to find the intersection of dependencies that play nice in your async model of choice.
and i took that personally
It’s why I feel go (with go routines being the norm) is one of the few imperative languages that was designed vs. filling out a bunch of historical constraints (apologies this is not meant to trigger a language debate, just an idiosyncratic thought)
And of course go routines and channels can also be desugared into mere control flow. That’s how ClojureScript does async.
Do you mind elaborating on this? I don't understand what you're trying to get at.
Also a great teaching tool, if someone knows one async system, to be able to show them the differences on each axis from their prior one to a new one they’re learning.
Some of these design decisions seem indefensible to me. For example, what the authors call "Suspension":
-> Static: Await points guaranteed to suspend -- JavaScript
-> Dynamic: No guarantees on awaiting tasks -- C# · Swift · Tokio · Smol · Asyncio · Trio
What is "await" if not a synonym for "suspend"?!?
async/await is one product of a long line of thought that says "threads are too hard for programmers to get right". Threads (really, shared memory) have real usability issues for developers, but once you grok the semantics (which largely map to the physical execution model in a CPU) that knowledge is transferrable across virtually all languages and runtimes.
The `await` keyword in most languages is not a synonym for suspending thread execution so much as it is an effectual attempt to replicate the functionality of `coreturn`[0]. To wit, if an underlying `Future`/`Promise` has completed before the `await` instruction is evaluated, the thread executing same will not be suspended.
0 - https://www.euclideanspace.com/maths/discrete/category/highe...
Having used both threads and async/await and using them both in the same program, I don't see how async/await is supposed to make it easier to get right.
In my experience, async/await seems to be a solution to avoid running too many threads. In Javascript, because you could only have one thread in browsers; in other languages because thread per X is too many threads and queuing to a thread pool might not be desirable either.
Async/await always feels terrible to use though. Some other way to get thread like semantics without having to have OS threads for everything seems better (to me). Erlang processes, Java Loom Virtual Threads (which I haven't used), etc. If it avoids having all memory shared, even better.
The problem is, the community collectively decided the problem was "threading" in general rather than "trying to have tons of threads running around shared data structures controlled via piles of simultaneously-held semaphores" specifically.
If you don't structure your threads on that basis, but instead default to something that looks more like actors and message passing, even if it isn't strictly speaking actors and message passing, the complexity comes down. Add some later elaborations like structured concurrency and a few other pre-canned design patterns for threading like a parallel map or worker pools being issued work items and it becomes merely something difficult rather than insane. When you program with threads sanely, it takes very little for async/await to actually be the substantially more complicated and difficult-to-understand choice when you have a workflow more interesting than "always await everything immediately" to implement, to say nothing of how nice it is to have things actually running on multiple cores simultaneously without having to carefully arrange for it.
I do find that the ergonomics of this are highly dependent on a few features of a language runtime, without which it all falls apart. Or you need language specific syntax and typically a single standard implementation.
There are scenarios where something might need to await and might not. Why take the hit if you are able to do something synchronously? Edit: this is especially important given the “viral” nature of colored functions.
It does make it hard to reason about, but this kind of problem is all over the place - e.g. very similar-looking code can have very different semantics depending on your framework if you’re using jsx or a particular decorator means one thing in one project and something else in another. That’s just part of the game at this point.
I don't really understand gp's point. From inside the code, you can't tell if there was a pause or not. Clock time or thread id are heuristics, but you can't really be sure.
it's a question of whether the runtime is guaranteed to suspend at an await point or if it may choose not to
> async/await is one product of a long line of thought that says "threads are too hard for programmers to get right".
what? no! concurrency vs parallelism etc etc
Feel like I should assign myself a couple dozen Jon Skeet posts to read now, to make up for this embarrassment.
The 0.16 release earlier this year introduced a much-heralded userland API (https://ziglang.org/documentation/master/std/#std.Io) that can be used to implement various asynchrony and concurrency patterns, including green threads, but it can't do stackless coroutines because support for those has to be baked into the compiler.
There is currently an open proposal to bring back stackless coroutines without dedicated syntax (instead offering low-level bring-your-own-buffer APIs for interacting with suspended coroutines), which could be combined with the aforementioned userland API to produce something more like how async/await works in other languages (https://github.com/ziglang/zig/issues/23446).
I think that's definitely right. Knowing the semantics of the language you mainly use is important.