Rendered at 19:05:45 GMT+0000 (Coordinated Universal Time) with Cloudflare Workers.
biorach 20 hours ago [-]
It'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 3 hours ago [-]
I 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.
cpa 11 hours ago [-]
This 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.
weinzierl 7 hours ago [-]
Sounds interesting. Is the course material available somewhere?
There you go, but half of the slides are in French.
faresahmed 6 hours ago [-]
I had a similar course, "Concepts of Programming Languages". The accompanying textbook was by Robert Sebest under the same name.
9 hours ago [-]
galaxyLogic 11 hours ago [-]
The 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?
brabel 9 hours ago [-]
This is often brought up as if it were a problem, but I see async functions as something similar to IO in Haskell. Almost all asynchronous functions I write are asynchronous because they will do IO of some sort. Async functions end up being markers of where IO may occur, which is very useful. It is very rare that I need to change a function from being sync to async (and the inverse pretty much never happens), and when that happens it's usually not a big deal (the caller is highly likely to be an async function within a short stack distance, so only one or two functions in the middle normally need to change).
In summary, async is something that looks problematic in theory, but in practice it just works really well!
valcron1000 6 hours ago [-]
If you want to have annotations on IO code you could get it using attributes - and you could even go further by using more precise annotations like `network`, `disk`, etc. The problem with using the type system is that now you need to account for the distinction everywhere (ex. interfaces/traits must support IO-based implementations) only to carry this metadata which should not affect the behavior. In Haskell, IO exists not to track a behavior but to enforce it in a lazy language. In Rust is done due to the lack of a runtime.
This is only true in Javascript though - even though you have the same function coloring aspect in most other languages with async/await, the other ones do not come with this benefit since synchronous I/O is not only possible but the classical default.
hombre_fatal 2 hours ago [-]
Javascript's async-everything is really unique in a domain where async is almost always bolted on to synchronous-everything in some sort of incompatible subecosystem.
brabel 5 hours ago [-]
I mostly do this in Dart (though even Dart also has sync IO, it’s just not supposed to be used often), but yeah other languages may not have this benefit.
mrsmrtss 9 hours ago [-]
Agreed on async. You better know if a function does IO, hiding that can lead to nasty surprises.
e1g 11 hours ago [-]
Tactically, 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]
There 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.
e1g 10 hours ago [-]
A hallmark of good architecture is adaptability to unexpected changes in requirements. Planning ahead helps with 'known unknowns', but it's impractical when building across N years in a dynamic environment - "knowing ahead of time" is just not possible for anything non-trivial. You need strong architectural primitives that don't scale based on developers' omniscience.
For example, say you have a workflow where, when someone signs up, you generate a user label, e.g., `$firstName $lastName`. You decide to move that to a function that might consider their personal title, preferred name, etc. Currently, it's a pure sync operation. Then, you discover people don't fill out that form at all, but some log in with Google, and you can use the name there as a fallback. Under flexible, this change is local: you can put that into your `createUserLabel(userInfo)`, and it can decide to fire off an API call to fill in any missing data, etc. In Promises land, this would taint the entire tree of everything everywhere that called that method, and all of those things must evolve or be refactored. In an Effectful system, this (previously unplanned) change remains isolated to that one function. Multiply that by every decision over multiple years for software looking for PMF, and requiring developers to "know ahead" severely slows down your ability to evolve.
simonask 7 hours ago [-]
Languages with effect systems typically let callers inherit the effects of their callees (e.g., calling an async function means the caller also is async), or force them to handle the effect (e.g., spawn the async call as a task and waiting synchronously for it to finish).
Effects are just a generalization, where async/await is one particular effect.
But: The fact that an operation now does some kind of I/O, or waits for user input, or whatever else you might express using async, has an _enormous_ impact on the architecture of your program. The “virality” of async is completely a feature, because it forces you to actually deal with that change, resulting in much more robust software.
It’s “inconvenient” because the architecture of your program changed. That’s what the job is, though. Languages that don’t help you here (by hiding that you made a change with huge ramifications) make it actively harder to deliver working software, in my opinion. You get there faster, but it won’t keep working.
mitxela 3 hours ago [-]
The problem with effects systems is that effects aren't generalisable. Every effect is unique. Sometimes they can be applied automatically and sometimes not.
You can have the compiler automatically recompile map with async to make map<async>, likewise map<pure> and map<nofail> and map<noblock> but they will not be optimal; map<async> could be parallel but isn't. And you probably want to control the amount of parallelism at each call site, which just makes it a completely different function. It's likely that you wrote map in a way that uses a loop counter and it's possible the compiler can't prove it's pure. map<abortable> is likely correct, but the compiler has absolutely no way to prove that, and other functions won't be correct if you naively make them possible to abort from outside.
mitxela 3 hours ago [-]
It's a fundamental architecture change only if we assume async == slow (or potentially slow) which isn't always true. Otherwise you might want to run something inline that the language designer made async - such as writing a file in /tmp.
mitxela 3 hours ago [-]
To go with this, don't be afraid of changing your code. If you get an unexpected change that means a whole hierarchy has to become async, bite the bullet and change the hierarchy. Such things happen.
This works for all applications, but not libraries where you don't control your callers. In that case it may make sense to make something async pre-emptively if you think requirements might change in a way that requires it but you can never predict every change successfully and you might need to make a V2 library.
mrkeen 4 hours ago [-]
The solution to this is assume an async spine to your program, and branch off to as much sync code as possible. It's the same lesson you learn wrangling the IO monad in Haskell, or dependencies (like databases) in OO-land.
josephg 7 hours ago [-]
That’s a good example. I would refactor that code to have a different signature and change all callers to fetch the relevant data.
I’d probably insist on doing that even in a blocking language where it’s not necessary. Interspersing database or network requests all through a codebase is horrible. Before you know it, someone is calling that function in a loop and you’re doing N serialised database queries. And you can’t even tell that that’s happening from the function signature. Your program just gets slow as your database grows. To say nothing of the correctness problems from issuing these queries outside of a transaction.
I worked on a project that was written like this in Python. The code was packed full of “convenient” sql queries. Some http requests took seconds to render. Turns out those request handlers were issuing thousands of individual sql queries, loading hundreds of megabytes from our database. A lot of the queries were redundant. The backend was just overfetching the same data over and over in tiny helper functions. Because of how the code was written, fixing performance required huge refactors all over the codebase.
File, network and database queries should not be spread all over “for convenience”. Fetching user data and processing it are different tasks. They generally shouldn’t be combined into a single function.
fpoling 5 hours ago [-]
LLMs are good at refactoring. So function color mismatch is no longer a problem while explicit io helps to read and understand the code.
RossBencina 10 hours ago [-]
I'm curious about your mental model. Would it be accurate to say that the async tree is the "IO program" and the sync functions operate on pure data, or is it more complicated than that?
josephg 7 hours ago [-]
Yeah, more or less. I think Haskell programmers are right on this.
whilenot-dev 8 hours ago [-]
> But then would it make sense to make every function async?
No, that doesn't make sense at all! You're being too reductionist...
I/O has its place in every real world program, and the true limitation (or what you call a "problem") are the single-threaded runtimes of JavaScript. It's not a question of whether you should mark your functions async or not, as if it's an issue of consistency in the call-tree of your program. The true question should rather be whether your functions are I/O-bound (and would actually block the single-threaded event loop) or are solely compute-bound.
You're forgetting the fact that async/await in JavaScript was a historical design choice to prevent the callback-hell that came with single-threaded concurrency. So if you'd want to get back to that callback-hell (and convert async functions back to "some-form-of" sync), you still can[0]:
// some dummy async function that doesn't really do any I/O
async function add(
a: number,
b: number,
): Promise<number> {
return a + b;
}
// convert async function back to sync to enjoy callback-hell again
function addUnpromisified(
a: number,
b: number,
cb: ((result: number | null, reason: any) => any),
): void {
add(a, b)
.then((result) => { cb(result, null); })
.catch((reason) => { cb(null, reason); });
}
You can think of async/await as an evolution of generators[1], as every await yields control back to the event loop. I'd actually encourage you to write some of your programs' behavior with generators if you've never done that before. Generator functions will yield control back to the caller, which is an interesting way to design programs when the caller is you instead of the event loop.
It's in Python, but David Beazley still has one of the best explanations on that topic, and shows the how and why you'd want to design an event loop live on stage[2].
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.
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 18 hours ago [-]
I 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 6 hours ago [-]
Since 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 :)
theamk 15 hours ago [-]
Great 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.
teh_klev 14 minutes 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"
wzdd 14 hours ago [-]
Agreed. 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 3 hours ago [-]
Yes. 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...
jcelerier 18 hours ago [-]
I 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 17 hours ago [-]
Agree 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.
flossly 7 hours ago [-]
In 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 6 hours 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 4 hours ago [-]
Whether you use async or blocking calls you’ll still want to keep IO at your edges otherwise you end up with tightly coupled spaghetti.
biorach 21 hours ago [-]
At 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.
gugagore 7 hours 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!
hankbond 16 hours ago [-]
> You must be a JavaScript developer.
and i took that personally
brabel 9 hours ago [-]
Got 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).
jquery 15 hours ago [-]
I was upset and felt like I did something wrong.
bradleybuda 21 hours ago [-]
I 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.
toast0 20 hours 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.
jerf 20 hours ago [-]
In the 1990s, threads were programmed with extensive use of semaphores and threads arbitrarily running around shared data structures. This is a disastrous approach to threading, and I agree with pretty much every scathing condemnation written about it.
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.
theamk 15 hours ago [-]
Linux has 8MB thread stacks by default, Windows apparently has 1MB ones, and that space is not going anywhere. As long as people are worried about memory, they will need something lighter than threads. (Yes, golang managed to create dynamic stacks, but this required major support from compiler and so unlikely to appear in existing languages).
Also, I think that single-threaded programs, even with co-routines, are just so much nicer than multi-threaded ones. You write "index = last_index++;" and it Just Works (tm), no need to worry about locks or atomics or other thread access.
mitxela 12 hours ago [-]
Both windows and Linux let you configure the thread stack size
jerf 5 hours ago [-]
More people think they need a web framework that serves a million requests per second than actually do.
More people think they need the latest and hottest in manual memory management than actually do.
More people think they need a hundred thousand threads than actually do.
If you do have one of those cases, by all means prepare for it and deal with it. But be sure you have one first. The program that exceeds so much as a 100 threads is not only exceptional, but very exceptional. The exceptions are cognitively available and leap to mind, but are nevertheless the exceptions. And, again, if you have one, deal with it, but be sure you have one.
If you're sitting there in TypeScript land writing "async" code you've already surrendered on Ultimate Efficiency anyhow. Deciding what is more efficient between a threaded program in a runtime that doesn't box everything and JIT-optimized JS code is difficult but it isn't that hard for the threaded program that isn't boxing to win out on all runtime measurements, including consumed RAM.
"You write "index = last_index++;" and it Just Works (tm), no need to worry about locks or atomics or other thread access."
That goes back to my comment about using better threading techniques. I write a lot of "index = last_index + 1" (mutably incrementing like that in a single expression is just bad style anywhere you see it) in my threaded code all the time without thinking much about it, because I use the model where by default a value belongs to the one actor process that has access to it at all. The problem isn't that mutation is dangerous in threaded code, the problem was people writing threaded code based on a ton of threads running around shared data structures with locking. Not only is that not the only way to write threaded code, it is literally the worst. There are many other options, all of them better in some way, many of them much better.
Contrasting the difficulty of writing threaded code to something else based on the assumption that "lots of shared state locked by semaphores" is the only way to write code is like a Haskell advocate talking about the amazing benefits of functional programming while writing as if literally every imperative program is just one big pile of unmitigated, pure spaghetti code where everything is linked together with gotos and every variable in the program is a global variable. That's not the relevant comparison any more. It hasn't been for a long time. If anyone's program is scrambled because they did write a big pile of gotos and global variables or they did write a big pile of shared state with semaphores everywhere, that's on them. A vast array of better techniques of all shapes and sizes was available to them.
toast0 2 hours ago [-]
> More people think they need a hundred thousand threads than actually do.
This is fair, but is a hundred thousand OS threads really a reasonable load on reasonable OSes?
I've (helped to) run systems with millions of Erlang processes on a node, so I'm not most people and my attitudes might well be skewed. I would expect that the reasonable ceiling for OS threads is closer to 10k than 100k, although I don't think I've seen many articles about findinf the limits... maybe everything just quietly works?
If the limit is 10k, I think there's a lot of real situations where thread per connection or thread per task runs out of headroom. If the limit is 100k, a lot fewer people are going to hit that. Common wisdom is "thread per connection doesn't scale", but it has a desirable programming model, so the question becomes how to get the programming model and scale, at least to modest size.
If it's fine to mostly just use threads (hopefully without so much of the shared everything model that makes everything hard), it would be great if people knew that instead of spending so much time adding async/await to everything. :P
PhilipRoman 11 hours ago [-]
Just to nitpick, it's a 8MB-sized mapping, not 8MB of RAM.
e4m2 10 hours ago [-]
Likewise, on Windows, it's 1 MB of reserved memory but only 4 KB of initially committed memory.
> Yes, golang managed to create dynamic stacks, but this required major support from compiler and so unlikely to appear in existing languages
That's true but I'm puzzled by the decision rationale. It's undeniably a major undertaking to add first class, fine-grained processes to a language and its runtime. But time invested there gets the multiplicative upside that all language users benefit from the investment. Instead, Async/Await transfers the complexity to users of the language, as TFA describes.
As an Erlang and now gleam developer, I'm continuously grateful for the BEAM's support for fine-grained processes (note these are VM processes, not OS level). If I want to do things in parallel, I spawn a new process to do it. Do I want that concurrency because of io latency or parallel computation? Doesn't matter. Processes handle both. If I want an actor - a long(ish) lived "object" that responds to messages sent to it - I spawn it as a process. If I want to communicate between processes, I send a message. That's the only choice. No shared memory so no semaphores, locks and whatnot.
I never have to think "hmm, should this function be sync or async?" and reason about the transitive implications through the entire call stack. I write functions to calculate values. If I want function A to be called after function B in program 1, I write them sequentially. If I want to run them concurrently in program 2, I spawn them in separate processes. Concurrency is a decision at the calling site, not when writing the function being called.
One concurrency primitive that meets all the needs. The reduction in cognitive load is palpable compared to Python (the other language I use regularly).
The usual reaction is "yeah but performance". I've never found this to be an issue in real life. Sure there are benchmarks that show C/Rust/C#/whatever is faster, often meaningfully so. In practice, for my needs: never been a problem.
I'm ever more grateful for the elegance and consistency of the BEAM concurrency model. From an ergonomic perspective, Async/Await feels like a poor abstraction by comparison.
That's not to say the BEAM (or its languages) is the final word in concurrency. The strong encapsulation boundaries from Structured Concurrency[0] would be a useful addition. Though even there, Erlang's supervisor hierarchies provide a a similar mechanism. Dataflow is another interesting area (many task-concurrent design questions are essentially dataflow problems).
Even without improvement though I'd still take Erlang's approach over Async/Await every day.
The principal difference between dispatching in a thread-based framework and async/await is that async/await allows you to program sequences of asynchronous operations much more easily. No more separation of code that initiates an async operation and the code that handles the result!
switchbak 16 hours ago [-]
These higher level primitives that they mention provide the primitives for exactly that. This can be found in other paradigms besides async/await.
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.
rerdavies 13 hours ago [-]
Of course. I don't think a language can support async/await without a library implementation, or the language features that support it.
And I can't honestly think of another paradigm that doesn't require callback functions or lambdas that, ergonomically, end up producing function implementations that end up drifting off the right side of the screen for anything more than a couple of sequential asynchronous operations.
switchbak 2 hours ago [-]
I didn’t say async/await need language features (though they usually do), I meant that high level async orchestration can be represented with suitable language features - often in ways that are more interesting than async/await.
“I can't honestly think of another paradigm that doesn't require callback functions” … Haskell, Scala, Rust all use various approaches to asynchrony that leverage these language features to provide you very usable abstractions without the “callbacks” you mention. Some of those lean on lambdas, but the scrolling off the right issue hasn’t been an issue there for at least a decade now.
Scala’s direct mode is interesting, as an example of library driven, blocking/imperative style interactions that provide most of the benefits of the monadic effect style, but in a way that’s far easier for a human to write and review.
This might not be ready for mass adoption yet, but I think it’s a sneak peek of where we’ll see some languages move to.
marcosdumay 15 hours ago [-]
The principal difference between threads and async/await has to be specified in at least 9 dimensions...
theamk 15 hours ago [-]
Very similar dimensions also apply to threads, the async/await is not really that different there.
marcosdumay 6 hours ago [-]
No, they don't. With threads almost all of those properties are explicit choices that the developer has to implement.
At most you get hidden behavior on exception propagation and end-of-life extent.
jerf 5 hours ago [-]
You get a few dimensions. Some of the dimensions listed in the article for async/await become user library decisions rather than being baked into the threading. But you could still get things like, is each thread memory-isolated (Erlang, Pony?) or not, can you cancel them (imperative languages no, but Erlang and Haskell yes), is the concurrency structured or not, details around how and when the threads clean up (though perhaps arguably more related to memory management then thread management), can a thread be pre-emptively descheduled (though I'm not sure if there is any current system where the answer is "no", Go was "no" for a while).
If I sat down and made a careful study of all the threading implementations we might get up to a similar number of quirks.
I would suggest though that the dimensions are generally more likely to be corner cases. Some of the dimensions mentioned in that article are fairly in-your-face for an async/await implementation and can cause serious difficulties migrating between systems fairly quickly if you make the wrong assumptions, and writing correct async/await code that isn't just straightline "await everything immediately" code has to start taking some of those things into account very quickly. The equivalent for threading is more likely to only come up rarely and in more cases the correct answer is really "don't depend on that anyhow", e.g., rather than depending on exact details of how a thread is terminated to accomplish something, just cleanly send a message with your results to whoever it is waiting for it directly and let the runtime do the cleanup without your code witnessing any effects of it. Depending on these quirks in threading code is much more likely to be bad engineering practice, rather than necessary engineering practice in the async/await case.
yxhuvud 11 hours ago [-]
Very much so and it can be argued that the difference between threads and ssync is just another dimension to compare on. For example, essentially everything that is involved in Structured Concurrency is as relevant to parallel scenarios as well.
AdieuToLogic 16 hours ago [-]
> What is "await" if not a synonym for "suspend"?!?
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.
> 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 21 hours ago [-]
C# 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.
nottorp 9 hours ago [-]
> threads are too hard for programmers to get right
Also message loops and state machines :)
biorach 21 hours 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 21 hours ago [-]
I'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.
This 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 12 hours ago [-]
Stackful coroutines are just traditional threads.
_ink_ 13 hours ago [-]
Can 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 12 hours ago [-]
I 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_ 3 hours ago [-]
But print is a side effect? Why would that be optimised away?
Arch485 2 hours ago [-]
It'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 2 hours ago [-]
nothing 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
weinzierl 7 hours ago [-]
[dead]
xboxnolifes 3 hours ago [-]
I apparently do not understand async/await.
dmix 15 hours ago [-]
I 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.
rao-v 17 hours ago [-]
I 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)
kccqzy 16 hours ago [-]
Being mere control flow is a good thing: some async/await implementations are just desugared into a state machine anyways, and it totally works on a single thread. Early async/await in Python was just a small generalization of its existing generator mechanism, and nobody would think generators in Python enables parallelism: it was always a control flow construct. This cleanly demonstrates the separation between the concepts of concurrency versus parallelism.
And of course go routines and channels can also be desugared into mere control flow. That’s how ClojureScript does async.
aw1621107 17 hours 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 14 hours ago [-]
Threads 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 14 hours 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.
mitxela 12 hours ago [-]
Another axis: a GPU wants you to load a large batch of work and then start it - you can't be bouncing between CPU and GPU work all the time, but you can mix SIMD and non-SIMD instructions freely.
rao-v 12 hours ago [-]
There is better than even odds I'm older than you, so I'd recommend you rethink using phrases like "possibly longer than you've been alive", it's not ... polite regardless of people's age.
The point (and I'd encourage you to find that thread to not retread ground) is that we absolutely can compile most computation heavy code for these different targets reasonably well - what we cannot garentee is that the resulting code is optimal given context. But gosh we can do so much - I’d encourage you to look into in profile guided, target aware, and autotuning optimization etc. (and then of course, there are LLM guided optimizations, but that's a whole other kettle of fish)
tcfhgj 11 hours ago [-]
> Go sort of asks why tho and just standardizes on go routines as a good abstraction over both concurrency and parallelism.
if it is really that good, why didn't Rust adopt the same thing?
valcron1000 1 hours ago [-]
Was part of the initial design, but then the designers dropped the idea of a built-in runtime.
bobnamob 10 hours ago [-]
Different design goals, go ships with a runtime baked in, rust wanted an async design independent of runtime implementation that’d be usable in embedded contexts
ksh09 9 hours ago [-]
Perfect timing, just yesterday I was exploring async implementation and their intricacies in non-GC langs.
alilleybrinker 21 hours ago [-]
With 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.
perrygeo 22 hours ago [-]
Amazing 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!
crabbone 3 hours ago [-]
I 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.
glaslong 20 hours ago [-]
C# 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 18 hours ago [-]
To 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.
layer8 21 hours ago [-]
It 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.
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).
strideashort 8 hours ago [-]
Async 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.
moralestapia 21 hours ago [-]
Great work. Must read for anyone working with this type of concurrency.
worik 14 hours ago [-]
Wierd.
After all these years doing cooperative multitasking again
cbm-vic-20 20 hours ago [-]
or, Java Virtual Threads and chill.
yxhuvud 11 hours ago [-]
Most of the dimensions exist in the threaded world as well, only the dimensions wasn't as explored at that point so the choices are usually not what would have been chosen today.
mitxela 12 hours ago [-]
Also 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.
tcfhgj 11 hours ago [-]
they are still more expensive than compiled async await, also mixing compute and io-bound work can cause issues
mitxela 9 hours ago [-]
How sure are you? Got a benchmark?
tcfhgj 8 hours ago [-]
100% sure - Java just has to store and process more than a simple state machine, also if all threads are busy with compute bound work, async work is stalled if you don't put the compute bound work on adedicated thread (pool)
biorach 20 hours ago [-]
No. Because concurrency vs parallelism
MichaelNolan 19 hours ago [-]
How 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 11 hours ago [-]
I'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.
slopinthebag 19 hours ago [-]
Maybe 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.
jdw64 21 hours 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.
agumonkey 21 hours ago [-]
Beautiful
talhaanwar 2 hours ago [-]
[flagged]
holt62 18 hours ago [-]
[flagged]
switchbak 16 hours ago [-]
Why do people bother with this llm stuff? Is it to mine the karma system? It’s so obvious and clearly dumb, I don’t understand the point otherwise.
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.
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.
There you go, but half of the slides are in French.
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?
In summary, async is something that looks problematic in theory, but in practice it just works really well!
I recommend reading https://degoes.net/articles/no-effect-tracking . In summary, most languages could do with the Go/Java virtual thread async model dropping async/await entirely.
[1] https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
[2] https://effect.website/
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.
For example, say you have a workflow where, when someone signs up, you generate a user label, e.g., `$firstName $lastName`. You decide to move that to a function that might consider their personal title, preferred name, etc. Currently, it's a pure sync operation. Then, you discover people don't fill out that form at all, but some log in with Google, and you can use the name there as a fallback. Under flexible, this change is local: you can put that into your `createUserLabel(userInfo)`, and it can decide to fire off an API call to fill in any missing data, etc. In Promises land, this would taint the entire tree of everything everywhere that called that method, and all of those things must evolve or be refactored. In an Effectful system, this (previously unplanned) change remains isolated to that one function. Multiply that by every decision over multiple years for software looking for PMF, and requiring developers to "know ahead" severely slows down your ability to evolve.
Effects are just a generalization, where async/await is one particular effect.
But: The fact that an operation now does some kind of I/O, or waits for user input, or whatever else you might express using async, has an _enormous_ impact on the architecture of your program. The “virality” of async is completely a feature, because it forces you to actually deal with that change, resulting in much more robust software.
It’s “inconvenient” because the architecture of your program changed. That’s what the job is, though. Languages that don’t help you here (by hiding that you made a change with huge ramifications) make it actively harder to deliver working software, in my opinion. You get there faster, but it won’t keep working.
You can have the compiler automatically recompile map with async to make map<async>, likewise map<pure> and map<nofail> and map<noblock> but they will not be optimal; map<async> could be parallel but isn't. And you probably want to control the amount of parallelism at each call site, which just makes it a completely different function. It's likely that you wrote map in a way that uses a loop counter and it's possible the compiler can't prove it's pure. map<abortable> is likely correct, but the compiler has absolutely no way to prove that, and other functions won't be correct if you naively make them possible to abort from outside.
This works for all applications, but not libraries where you don't control your callers. In that case it may make sense to make something async pre-emptively if you think requirements might change in a way that requires it but you can never predict every change successfully and you might need to make a V2 library.
I’d probably insist on doing that even in a blocking language where it’s not necessary. Interspersing database or network requests all through a codebase is horrible. Before you know it, someone is calling that function in a loop and you’re doing N serialised database queries. And you can’t even tell that that’s happening from the function signature. Your program just gets slow as your database grows. To say nothing of the correctness problems from issuing these queries outside of a transaction.
I worked on a project that was written like this in Python. The code was packed full of “convenient” sql queries. Some http requests took seconds to render. Turns out those request handlers were issuing thousands of individual sql queries, loading hundreds of megabytes from our database. A lot of the queries were redundant. The backend was just overfetching the same data over and over in tiny helper functions. Because of how the code was written, fixing performance required huge refactors all over the codebase.
File, network and database queries should not be spread all over “for convenience”. Fetching user data and processing it are different tasks. They generally shouldn’t be combined into a single function.
No, that doesn't make sense at all! You're being too reductionist...
I/O has its place in every real world program, and the true limitation (or what you call a "problem") are the single-threaded runtimes of JavaScript. It's not a question of whether you should mark your functions async or not, as if it's an issue of consistency in the call-tree of your program. The true question should rather be whether your functions are I/O-bound (and would actually block the single-threaded event loop) or are solely compute-bound.
You're forgetting the fact that async/await in JavaScript was a historical design choice to prevent the callback-hell that came with single-threaded concurrency. So if you'd want to get back to that callback-hell (and convert async functions back to "some-form-of" sync), you still can[0]:
You can think of async/await as an evolution of generators[1], as every await yields control back to the event loop. I'd actually encourage you to write some of your programs' behavior with generators if you've never done that before. Generator functions will yield control back to the caller, which is an interesting way to design programs when the caller is you instead of the event loop.It's in Python, but David Beazley still has one of the best explanations on that topic, and shows the how and why you'd want to design an event loop live on stage[2].
[0]: https://www.typescriptlang.org/play/?#code/PTAEGcHsFsFNQCYFd...
[1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guid...
[2]: https://www.youtube.com/watch?v=MCs5OvhV9S4
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 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 :)
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.
It says right underneath that quiz:
"There isn’t really a right answer, because you were probably right for some language"
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...
> 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.
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.
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.
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!
and i took that personally
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.
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.
Also, I think that single-threaded programs, even with co-routines, are just so much nicer than multi-threaded ones. You write "index = last_index++;" and it Just Works (tm), no need to worry about locks or atomics or other thread access.
More people think they need the latest and hottest in manual memory management than actually do.
More people think they need a hundred thousand threads than actually do.
If you do have one of those cases, by all means prepare for it and deal with it. But be sure you have one first. The program that exceeds so much as a 100 threads is not only exceptional, but very exceptional. The exceptions are cognitively available and leap to mind, but are nevertheless the exceptions. And, again, if you have one, deal with it, but be sure you have one.
If you're sitting there in TypeScript land writing "async" code you've already surrendered on Ultimate Efficiency anyhow. Deciding what is more efficient between a threaded program in a runtime that doesn't box everything and JIT-optimized JS code is difficult but it isn't that hard for the threaded program that isn't boxing to win out on all runtime measurements, including consumed RAM.
"You write "index = last_index++;" and it Just Works (tm), no need to worry about locks or atomics or other thread access."
That goes back to my comment about using better threading techniques. I write a lot of "index = last_index + 1" (mutably incrementing like that in a single expression is just bad style anywhere you see it) in my threaded code all the time without thinking much about it, because I use the model where by default a value belongs to the one actor process that has access to it at all. The problem isn't that mutation is dangerous in threaded code, the problem was people writing threaded code based on a ton of threads running around shared data structures with locking. Not only is that not the only way to write threaded code, it is literally the worst. There are many other options, all of them better in some way, many of them much better.
Contrasting the difficulty of writing threaded code to something else based on the assumption that "lots of shared state locked by semaphores" is the only way to write code is like a Haskell advocate talking about the amazing benefits of functional programming while writing as if literally every imperative program is just one big pile of unmitigated, pure spaghetti code where everything is linked together with gotos and every variable in the program is a global variable. That's not the relevant comparison any more. It hasn't been for a long time. If anyone's program is scrambled because they did write a big pile of gotos and global variables or they did write a big pile of shared state with semaphores everywhere, that's on them. A vast array of better techniques of all shapes and sizes was available to them.
This is fair, but is a hundred thousand OS threads really a reasonable load on reasonable OSes?
I've (helped to) run systems with millions of Erlang processes on a node, so I'm not most people and my attitudes might well be skewed. I would expect that the reasonable ceiling for OS threads is closer to 10k than 100k, although I don't think I've seen many articles about findinf the limits... maybe everything just quietly works?
If the limit is 10k, I think there's a lot of real situations where thread per connection or thread per task runs out of headroom. If the limit is 100k, a lot fewer people are going to hit that. Common wisdom is "thread per connection doesn't scale", but it has a desirable programming model, so the question becomes how to get the programming model and scale, at least to modest size.
If it's fine to mostly just use threads (hopefully without so much of the shared everything model that makes everything hard), it would be great if people knew that instead of spending so much time adding async/await to everything. :P
https://learn.microsoft.com/en-us/cpp/build/reference/stack-...
That's true but I'm puzzled by the decision rationale. It's undeniably a major undertaking to add first class, fine-grained processes to a language and its runtime. But time invested there gets the multiplicative upside that all language users benefit from the investment. Instead, Async/Await transfers the complexity to users of the language, as TFA describes.
As an Erlang and now gleam developer, I'm continuously grateful for the BEAM's support for fine-grained processes (note these are VM processes, not OS level). If I want to do things in parallel, I spawn a new process to do it. Do I want that concurrency because of io latency or parallel computation? Doesn't matter. Processes handle both. If I want an actor - a long(ish) lived "object" that responds to messages sent to it - I spawn it as a process. If I want to communicate between processes, I send a message. That's the only choice. No shared memory so no semaphores, locks and whatnot.
I never have to think "hmm, should this function be sync or async?" and reason about the transitive implications through the entire call stack. I write functions to calculate values. If I want function A to be called after function B in program 1, I write them sequentially. If I want to run them concurrently in program 2, I spawn them in separate processes. Concurrency is a decision at the calling site, not when writing the function being called.
One concurrency primitive that meets all the needs. The reduction in cognitive load is palpable compared to Python (the other language I use regularly).
The usual reaction is "yeah but performance". I've never found this to be an issue in real life. Sure there are benchmarks that show C/Rust/C#/whatever is faster, often meaningfully so. In practice, for my needs: never been a problem.
I'm ever more grateful for the elegance and consistency of the BEAM concurrency model. From an ergonomic perspective, Async/Await feels like a poor abstraction by comparison.
That's not to say the BEAM (or its languages) is the final word in concurrency. The strong encapsulation boundaries from Structured Concurrency[0] would be a useful addition. Though even there, Erlang's supervisor hierarchies provide a a similar mechanism. Dataflow is another interesting area (many task-concurrent design questions are essentially dataflow problems).
Even without improvement though I'd still take Erlang's approach over Async/Await every day.
[0] https://en.wikipedia.org/wiki/Structured_concurrency
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.
And I can't honestly think of another paradigm that doesn't require callback functions or lambdas that, ergonomically, end up producing function implementations that end up drifting off the right side of the screen for anything more than a couple of sequential asynchronous operations.
“I can't honestly think of another paradigm that doesn't require callback functions” … Haskell, Scala, Rust all use various approaches to asynchrony that leverage these language features to provide you very usable abstractions without the “callbacks” you mention. Some of those lean on lambdas, but the scrolling off the right issue hasn’t been an issue there for at least a decade now.
Scala’s direct mode is interesting, as an example of library driven, blocking/imperative style interactions that provide most of the benefits of the monadic effect style, but in a way that’s far easier for a human to write and review.
This might not be ready for mass adoption yet, but I think it’s a sneak peek of where we’ll see some languages move to.
At most you get hidden behavior on exception propagation and end-of-life extent.
If I sat down and made a careful study of all the threading implementations we might get up to a similar number of quirks.
I would suggest though that the dimensions are generally more likely to be corner cases. Some of the dimensions mentioned in that article are fairly in-your-face for an async/await implementation and can cause serious difficulties migrating between systems fairly quickly if you make the wrong assumptions, and writing correct async/await code that isn't just straightline "await everything immediately" code has to start taking some of those things into account very quickly. The equivalent for threading is more likely to only come up rarely and in more cases the correct answer is really "don't depend on that anyhow", e.g., rather than depending on exact details of how a thread is terminated to accomplish something, just cleanly send a message with your results to whoever it is waiting for it directly and let the runtime do the cleanup without your code witnessing any effects of it. Depending on these quirks in threading code is much more likely to be bad engineering practice, rather than necessary engineering practice in the async/await case.
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...
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.
Also message loops and state machines :)
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
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?
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.
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)
e.g. for Rust/tokio printing B is skipped, because the process exits and cancels the future before the task can print B
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.
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.
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.
The point (and I'd encourage you to find that thread to not retread ground) is that we absolutely can compile most computation heavy code for these different targets reasonably well - what we cannot garentee is that the resulting code is optimal given context. But gosh we can do so much - I’d encourage you to look into in profile guided, target aware, and autotuning optimization etc. (and then of course, there are LLM guided optimizations, but that's a whole other kettle of fish)
if it is really that good, why didn't Rust adopt the same thing?
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.
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.
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).
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.
After all these years doing cooperative multitasking again
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.
I think that's definitely right. Knowing the semantics of the language you mainly use is important.