6 ms·
A Design Space Exploration of Async/Await
- homarp 22d agoThe paper explores how async/await behaves across today's languages: https://arxiv.org/abs/2608.20677 https://arxiv.org/abs/2608.20677
- perrygeo 20d agoAmazing work. It's one thing to say "async is complex". It's another to parse that statement so carefully as to have a cross-language theory of async execution. Looking forward to digging into this!
- layer8 20d agoIt would be a fun coding agent benchmark to have them translate such a program between the different languages and see whether they preserve the respective semantics.
- moralestapia 20d agoGreat work. Must read for anyone working with this type of concurrency.
- agumonkey 20d agoBeautiful
- alilleybrinker 20d agoWith these dimensions of design variance defined, you could also make a closeness measure in 9-dimensional space and identify the most or least similar combos. 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.
- bradleybuda 20d agoI answered the quiz and it said "you must be a Javascript developer", which is true enough - that's probably my second-most-proficient language. In fact, I'm a Ruby developer partially because I hate the idea of async/await and I'm feeling very smug about my choice after reading this. 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.
- biorach 20d ago> What is "await" if not a synonym for "suspend"?!? 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
- danilocesar 20d agoI'm teaching async calls in makearcade to my 10yo son, to bypass a platform bug. He said he doesn't get it. My answer was: Don't worry, adults don't get it either.
- nxc18 20d ago> What is "await" if not a synonym for "suspend"?!? 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.
- bmm6o 20d agoC# has Task.FromResult(), which is useful if you are implementing an interface that allows async work but your implementation doesn't require it. I believe the runtime will check for this case and continue execution. It's better for the cache to keep executing the current task on the current thread. 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.
- toast0 20d ago> async/await is one product of a long line of thought that says "threads are too hard for programmers to get right". 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.
- biorach 20d agoAt last someone took the time to pore over all the tedious crap that I have been trying and failing to keep straight in my head since forever.
- jdw64 20d ago>You must be a C#, Swift, Asyncio, or Tokio developer — hard to narrow down, you all agree on this one. I think that's definitely right. Knowing the semantics of the language you mainly use is important.
- cbm-vic-20 20d agoor, Java Virtual Threads and chill.
- biorach 20d agoNo. Because concurrency vs parallelism
- MichaelNolan 20d agoHow does parallelism come into play for this conversation? Async/await is a concurrency construct. And Java’s virtual threads are also a concurrency construct. Neither of them have anything to do with parallelism. Or am I misunderstanding something?
- PhilipRoman 20d agoI'd say Java's virtual threads are also a parallelism construct (at least in the performance sense, not logical guarantees), since they're scheduled on a pool.
- mitxela 20d agoAlso formerly known as goroutines Both efforts, instead of trying to avoid threads because they are expensive, simply asked why they have to be expensive and then made them not expensive.
- glaslong 20d agoC# is my primary, but the quiz tells me I'm a JS dev. Feel like I should assign myself a couple dozen Jon Skeet posts to read now, to make up for this embarrassment.
- jameshart 20d agoTo be fair to yourself, the fact that C# terminates pending tasks when the main method exits is something most C# devs don’t have to deal with because they’re mostly working in the context of long running servers and apps.
- biorach 20d agoIt's long been clear that there were fundamental implementation choices that mattered between async runtimes, but _nine_ design dimensions? Damn. 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.
- mitxela 19d agoI felt like they were reaching deep to find dimensions. Whether a task runs autonomously or needs its parent to poll it (Rust) is a dimension but whether an autonomous running task can continue after being cancelled shouldn't be - if you go into that much detail you could probably find thousands of dimensions. If it's autonomous it can do whatever it wants to.
- msteffen 19d ago> whether an autonomous running task can continue after being cancelled shouldn't be Idk, I think this might be quite important in servers, or any application where the running task might spawn subprocesses (can those continue autonomously?) or block on/use os resources
- antonvs 19d agoFull lifecycle management of async tasks is a pretty important aspect - one might even say dimension - of the problem.
- mitxela 19d ago
- vitaminCPP 20d agoLove it. I wish it included zig.
- ameliaquining 20d agoZig doesn't currently have async/await in the sense that this post is about (i.e., async/await based on stackless coroutines). It previously had this, but it was removed last year (https://ziglang.org/download/0.15.1/release-notes.html#async-and-await-keywords-removed https://ziglang.org/download/0.15.1/release-notes.html#async...). The 0.16 release earlier this year introduced a much-heralded userland API (https://ziglang.org/documentation/master/std/#std.Io 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 https://github.com/ziglang/zig/issues/23446).
- slopinthebag 20d agoMaybe it’s cuz I started with async/await instead of threads but I cannot relate to people saying it’s harder than threading. To me it’s substantially easier to understand than threads, goroutines, or structured concurrency in Kotlin.
- spankalee 20d agoWow, this is really helpful and timely! 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/ 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/ 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.
- bufordsharkley 20d agoI definitely find trio (formerly curio) to be so thoughtfully designed at every turn; it's dispiriting that it never seemed to gain much of a user share over asyncio (whose main advantage appears to simply be inertia and stdlib privilege)
- jeremyjh 20d agoSince this exercise was pseudo-code, I did not think particularly hard about the semantics of specific implementations, I just reasoned about what I would naively expect from any implementation. The answer I gave was the Trio answer - this was the first time I heard about Trio. I realized I have very little experience with async/await; I've only used it extensively in Javascript and only in the browser there - so if my understanding of the exercise hinged on semantics of child_process.spawn then I had no reference point at all for that. The languages that I have used extensively for back-end work either have native green-threads (Elixir, Haskell), or further back in my career I simply used synchronous I/O in Java and C# which only offered async or futures long after I'd moved on from them. Frankly, this is a big part of why I chose Elixir and Haskell (and lately, some Go). edit: Also thanks for your work on Zena, and mentioning it here! I've looked for exactly this before. Now I just have to invent a project for it :)
- jcelerier 20d agoI was wondering "hopefully C++ allows you to pick across these axes so that you can build yourself the async primitives that work best for the problem at hand" and then: yes! > 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.
- bombela 20d agoAgree with your post except the adjective "simple" for a network video player. 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.
- holt62 20d ago[flagged]
- rao-v 20d agoI remember being so mad years ago, coming from a pure CS background, when it dawned on me that async await was “mere” control flow and not actual parallelism. 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)
- aw1621107 20d ago> vs. filling out a bunch of historical constraints Do you mind elaborating on this? I don't understand what you're trying to get at.
- rao-v 20d agoThreads were historically expensive enough that “just spawn a thread” wasn’t a reasonable thing to do in many situations. Thread pools were sort of a last resort, and we ended up with control flow like objects (futures, await etc.) to multiplex concurrency without parallelism. Go sort of asks why tho and just standardizes on go routines as a good abstraction over both concurrency and parallelism. This is sorta true elsewhere too. Go rejects a lot of the machinery that OO languages seem to feel obliged to carry around - inheritance hierarchies, explicit interface implementation etc. For what it's worth, I don't write much go, and I don't think it's magical. I just like how clearly it revisited some basics. Elsewhere on HA you’ll find my extended rant about how strange it is that we don't have a language that elegantly abstracts computation over threads, SIMD, GPUs etc. Compilers can do this sort of thing now, just not optimally.
- jcranmer 20d ago> Elsewhere on HA you’ll find my extended rant about how strange it is that we don't have a language that elegantly abstracts computation over threads, SIMD, GPUs etc. Compilers can do this sort of thing now, just not optimally. Autoparallelization has been a hot topic for literally decades, quite possibly longer than you've been alive. The problem is that the techniques you need to do to write good SIMD code versus good GPU code versus good multithreaded code versus distributed computation are all different. Taking just memory concerns: a SIMD code needs you to carefully arrange memory so that every thread is accessing an adjacent memory location. GPU code likes locality, but you have large group sizes that can share all the local memory pretty cheaply, and loading from global memory to local memory is relatively expensive, so now you have to do a lot of tuned blocking. With multithreaded code, you now want to avoid sharing between different threads (which generally requires distributing loop iterations among threads very differently). And with a distributed platform, now you're primarily worrying about the overhead of communication of data between different nodes, and you're trying to minimize that.
- hankbond 20d ago> You must be a JavaScript developer. and i took that personally
- jquery 20d agoI was upset and felt like I did something wrong.
- brabel 20d agoGot that too... but on JS's defence, I think the JS behavior is the most "natural" unless you've been trained on the other approaches (where explicitly awaiting is required for anything to actually happen). Dart and Kotlin, for example, also do that (and are not mentioned in the article - would have felt nicer to be told I must be a Kotlin/Dart developer).
- theamk 20d agoGreat post, but the quiz is unfair - it assumes there is only one "true way", but a lot of frameworks give you options For example, Trio has no global "spawn" method, by design. Judging by the results, authors assumed "with trio.open_nursery() as n: n.start_soon(write_to_log())", and so they got eager execution, dynamic extent, destructive propagation. But opening a nursery just to write a single log line is absolutely crazy! The real program would use an appropriately scoped shared nursery: either per-request or global. Later option allows indefinite extent and "never" propagation. Also, that "()" after write_to_log matters! If one follow trio's own examples, you'd write "n.start_soon(write_to_log)" - note no (). This will switch to lazy execution. I am not familiar with non-python frameworks listed, but I would not be surprised if they allow for similarly wide range of behaviors.
- wzdd 20d agoAgreed. I was confused by the Trio example until I reverse engineered what they meant from the outputs. Trio behaves differently (in well-defined, easy to understand ways) depending on where you put nurseries.
- crabbone 19d agoYes. Like I wrote in another comment: authors' expectations and explanations aren't... very convincing. Asking about the order of execution in a concurrent program which certainly can have multiple valid orders by design is unfair. Also, a lot of discrepancy between results is explained by how long the program waits for spawned but unawaited tasks before exiting. In a realistic program, this situation would be considered a bug (spawning a task w/o awaiting it, and then missing the results because the program exits too soon). I can't imagine a situation where the program's author would intentionally create a situation where non-deterministically, a part of the program might not run...
- teh_klev 19d ago>but the quiz is unfair - it assumes there is only one "true way", but a lot of frameworks give you options It says right underneath that quiz: "There isn’t really a right answer, because you were probably right for some language"
- dmix 20d agoI learned from publishing a javascript library that people were supposed to put on their own websites then customize, that people don't really understand async/await even if they pretend to know JS and that you should avoid it in baseline documentation. That's changed a bit since this demographic started using LLMs but I'm still a bit wary. I almost don't blame them after using it for nearly a decade.
- pansa2 20d agoThis looks like a really thorough examination of async-await i.e. stackless coroutines, but it doesn’t seem to cover Lua-like stackful coroutines. Apologies if it does and I’ve glanced over it - but if it doesn’t, is there another resource that compares stackful coroutines to stackless in a similar way?
- mitxela 20d agoStackful coroutines are just traditional threads.
- jandrewrogers 19d agoStackful coroutines are cooperatively scheduled, unlike traditional threads, and context switch much more inexpensively. It is the reason people write stackful coroutine libraries instead of just using threads.
- mitxela 19d agoThreads were traditionally cooperatively scheduled, too. Expensive context switch is an artifact of implementation.
- worik 20d agoWierd. After all these years doing cooperative multitasking again
- _ink_ 20d agoCan someone explain what happens in Rust and Python? I don't see how C / ABC can happen (or what's even the point of async when that's the result).
- rawling 20d agoI think it's down to them noticing that nothing is waiting for the result of the task and handling it differently? C: if nothing is waiting for it, don't run it at all. ABC: if nothing is waiting for it, wait for it when it's run.
- _ink_ 19d agoBut print is a side effect? Why would that be optimised away?
- Arch485 19d agoIt's not that it's optimized away, it's that the authors have done a bad job translating their pseudo code into Rust etc. In Rust, if you do not `await` a future, it does not run - so in their example where only C is printed, they must have translated this into some Rust code that does not await the call to print AB. This is simply not something you would ever do in real life, and in fact the Rust compiler emits a warning if you create a future but do not await it. A more "correct" approach would be to call `tokio::spawn` with the future to print AB. This would result in AB always being printed, usually in the order ACB, but with no hard guarantees on that order because of the semantics of the `sleep` call. (specifically, it will wait at least the amount of time specified, but possibly more)
- tcfhgj 19d agonothing is optimized away, it's just not executed - e.g. the program including semantics of the language is just designed such that it is not executed. e.g. for Rust/tokio printing B is skipped, because the process exits and cancels the future before the task can print B
- 19d ago
- galaxyLogic 20d agoThe problem I encounter with async/await (in JS) is that while an async method can call a non-async-function and do somewthing with the result of that, the reverse is not true, a sync function can call async-function but can not us the result of that in any way, except pass it on or upwards. What makes it worse is that you can not simply modify a sync-function to become an async-function, if it has existing callers because those would likely break them. This affects the whole tree of possible function calls in my program. If at some level I have a sync function but I see it needs to get to some data that only async fuction can provide, I may need to change a whole call-chain of my call-tree, not just modify a single function in that tree. Then as I develop my program I need to make the decision for every function; should it be sync or async? In many cases it could be either one so which should I choose? Making it async would seem to make it easier to evolve the program later. But then would it make sense to make every function async?
- e1g 20d agoTactically, this problem is commonly known as “colored functions”[1], and the only option in JS is to have some other runtime coordinate your function execution; in JS, that solution is Effect[2] [1] https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/ https://journal.stuffwithstuff.com/2015/02/01/what-color-is-... [2] https://effect.website/ https://effect.website/
- josephg 20d agoThere are a lot of libraries which can help you deal with this, but ultimately the parent is right. I usually arrange my programs to have a call tree of all my async code, and separate call trees of sync processing work. You want to know ahead of time which is which. If you have a sync function which needs data that’s only available via a network request, take that data in as a function parameter or something. And make the caller responsible for making that data available before the function is called. It’s a simple model. It’s fast and quite easy to understand once you’re used to it. But you do need to plan ahead, and structure your programs with a plan.
- 20d ago
- cpa 20d agoThis kind of semantics-first comparative analysis of programming languages is so important. I had a course at uni where we dissected how different languages approached concurrency, parallelism, modules/OOP, metaprogramming, eager vs lazy evaluation, types, exceptions... Understanding the trade-offs each language made (and their historical lineage) taught me much more about programming than any Python/Java/C course and made it much easier to pick up new languages.
- deleted 20d ago[deleted]
- weinzierl 20d agoSounds interesting. Is the course material available somewhere?
- faresahmed 20d agoI had a similar course, "Concepts of Programming Languages". The accompanying textbook was by Robert Sebest under the same name.
- cpa 19d agohttps://www.irif.fr/~gc/teachAdvancedProgramming.en.html https://www.irif.fr/~gc/teachAdvancedProgramming.en.html There you go, but half of the slides are in French.
- ksh09 20d agoPerfect timing, just yesterday I was exploring async implementation and their intricacies in non-GC langs.
- strideashort 20d agoAsync await is a glorious fucking event loop which obscures the primitive, crude simplicity to something unrecognizable which most developers think does something it absolutely doesn't. it could be sth along the lines of: on(x=foo()){ //land here when x is computed } catch{ //sth got wrong with foo } Visual basic was superior to async/await crap. Not even joking.
- gugagore 20d ago> For "basic” abstraction, lambda calculus is the canonical approach. There is no equivalent for concurrency. There are multiple different approaches. Which one is canonical? Simply-typed lambda calculus guarantees that the computation terminates. Sometimes you need non-termination. Fixed point operators is one thing that brings in all the stuff that you threw out. Linear logic is good for the bits and pieces of concurrency where you don't need concurrency. Linear logic guarantees that there are no race conditions. Sometimes you need race conditions — how do you fit that in there? Sometimes, having race conditions is really important: I am selling tickets and there is going to be a race for who gets the last ticket. Is there a single thing you can add to linear logic that would give me race conditions? Not known. - Philip Wadler on Type Theory Forall #54 - The Goal of Science is to Communicate Ideas!
- flossly 20d agoIn my last project (Kotlin; web app) I've decided against async (coroutines). I just want to keep it simple. Async "infects" you code: for it to bring benefits your whole codebase needs to be doing it (ingesting requests, db calls, web API calls). Due to this we see "split" stacks in programming languages: one lib stack for synchronous, and one for async. I did not think the benefits of better performance under load is worth the mental overhead of doing async everywhere. So i went with blocking calls and virtual threads. No regrets.
- tcfhgj 20d ago> for it to bring benefits your whole codebase needs to be doing it (ingesting requests, db calls, web API calls). not really - the core of apps (usually no outside dependencies), and additions which solely rely onthe core, usually can be implemented without async entirely; async only comes into play once you add dependencies to file system, network and ui, but you don't need to make the core async for that. You might not call some functions in async code at all, because the function is used only for heavy computation which is best handled by dedicated threads to avoid stalling your io handling.
- vips7L 19d agoWhether you use async or blocking calls you’ll still want to keep IO at your edges otherwise you end up with tightly coupled spaghetti.
- crabbone 19d agoI think the article is... not being completely honest. To my simple mind, spawning a task but not awaiting it is undefined behavior. So, no wonder it does different things in different languages / libraries. What creates the implementation difference is the side effect. Some runtimes may, legitimately, conclude that since the task hasn't been awaited, then it shouldn't run at all, and no side effects should happen. Other runtimes either lack this kind of sophistication, or believe that the side effect is the goal of spawning the task, and so they proceed to run it anyways. Other discrepancies between runtimes are explained by the non-deterministic nature of concurrency... They happen to be more predictable in a very simple program that happens to terminate before the unawaited task has a chance to complete, which is what creates such diverse answers. I imagine that if the program waited longer, then we'd see most if not all implementations print all of the A, B, and C, where C can be first, second or third, but B must follow A. Which is what you'd expect, if you are familiar with any async framework.
- xboxnolifes 19d agoI apparently do not understand async/await.
- talhaanwar 19d ago[flagged]
- ckrapu 19d agoThe outputs for the first example aren’t showing up for me.
- deepsun 19d agoI would really want to see there Go with their goroutines, Kotlin with their coroutines, and good old Java with their CompletableFuture vs. virtual threads (and compare that with non-continuation ThreadPoolExecutor). IMO the discussion without Go is really lacking, as it's the whole point of Go.
- tcfhgj 19d agoon the other hand it is trivial to project goroutines onto Rust-tokio async/await: function func() -> async fn func() go func -> tokio::task::spawn(func) func() -> func().await func_blocking() -> tokio::task::spawn_blocking(||func_blocking()).await (not sure about cancellation behavior in go though)
- hn_submit 18d agoAsync/await is a poor abstraction since I find it difficult to reason how an application using it will behave. It's certainly not intuitive or easier to understand than threading. If, in addition, there are many possible design choices (as described in the article) that lead to inconsistent behavior between languages it has failed for its reason to exist in the first place. I believe we should make threading easier and safer to use by, for example, two-way message queues.