Top
Best
New

Posted by zdw 3 days ago

What async promised and what it delivered(causality.blog)
173 points | 197 commentspage 2
rstuart4133 3 days ago|
Async is a Javascript hack that inexplicably got ported to other languages that didn't need it.

The issue arose because Javascript didn't have threads, and processing events from the DOM is naturally event driven. To be fair, it's a rare person who can deal with the concurrency issues threads introduce, but the separate stacks threads provide a huge boon. They allow you to turn event driven code into sequential code.

    window.on_keydown(foo);

    // Somewhere far away
    function foo(char_event) { process_the_character(char_event.key_pressed) };
becomes:

    while (char = read())
        process_the_character(char);
The latter is easy to read linear sequence of code that keeps all the concerns in one place, the former rapidly becomes a huge entangled mess of event processing functions.

The history of Javascript described in the article is just a series of attempts to replace the horror of event driven code with something that looks like the sequential code found in a normal program. At any step in that sequence, the language could have introduced green threads and the job would have been done. And it would have been done without new syntax and without function colouring. But if you keep refining the original hacks they were using in the early days and don't the somewhat drastic stop of introducing a new concept to solve the problem (separate stacks), you end up where they did - at async and await. Mind you, async and await to create a separate stack of sorts - but it's implemented as a chain objects malloc'ed on the heap instead the much more efficient stack structure.

I can see how the javascript community fell into that trap - it's the boiling frog scenario. But Python? Python already had threads - and had the examples of Go and Erlang to show how well then worked compared to async / await. And as for Rust - that's beyond inexplicable. Rust has green threads in the early days and abandoned them in favour of async / await. Granted the original green thread implementation needed a bit of refinement - making every low level choose between event driven and blocking on every invocation was a mistake. Rust now has a green thread implementation that fixes that mistake, which demonstrates it wasn't that hard to do. Yet they didn't do it at the time.

It sounds like Zig with its pluggable I/O interface finally got it right - they injected I/O as a dependency injected at compile time. No "coloured" async keywords and compiler monomorphises the right code. Every library using I/O only has to be written once - what a novel concept! It's a pity it didn't happen in Rust.

rafaelmn 10 hours ago||
async/await came out of C# (well at least the JS version of it).

There are a bunch of use cases for it outside of implementing concurrency in a single threaded runtime.

Pretty much every GUI toolkit I've ever used was single threaded event loop/GUI updates.

Green threads are a very controversial design choice that even JVM backed out of.

ziml77 9 hours ago|||
Yep and I loved when C# introduced it. I worked on a system in C# that predated async/await and had to use callbacks to make the asynchronous code work. It was a mess of overnested code and poor exception handling, since once the code did asynchronous work the call stack became disconnected from where the try-catches could take care of them. async/await allowed me to easily make the code read and function like equivalent synchronous code.
ngruhn 9 hours ago||||
> async/await came out of C# (well at least the JS version of it).

Not sure if inspired by it, but async/await is just like Haskells do-notation, except specialized for one type: Promise/Future. A bit of a shame. Do-notation works for so many more types.

- for lists, it behaves like list-comprehensions.

- for Maybes it behaves like optional chaining.

- and much more...

All other languages pile on extra syntax sugar for that. It's really beautiful that such seemingly unrelated concepts have a common core.

rafaelmn 9 hours ago||
I knew someone was going to bring up monads that's why I put JS version :) JS took the C# syntax.
WorldMaker 7 hours ago||
Similarly F#'s computation expressions predate C#'s syntax, and there is some evidence that C# language designers were looking at F#'s computation expressions. Since the Linq work, C# has been very aware of Monads, and very slow and methodical about how it approaches them. Linq syntax is a subtly compromised computation expression and async/await is a similar compromise.

It's interesting to wonder about the C# world where those things were more unified.

It's also interesting to explore in C# all the existing ways that Linq syntax can be used to work with arbitrary monads and also Task<T> can be abused to use async/await syntax for arbitrary monads. (In JS, it is even easier to bend async/await to arbitrary monads given the rules of a "thenable" are real simple.)

ngruhn 3 hours ago||
> use async/await syntax for arbitrary monads. (In JS, it is even easier to bend async/await to arbitrary monads given the rules of a "thenable" are real simple.)

I tried once to hack list comprehensions into JS by abusing async/await. You can monkey patch `then` onto Array and define it as flatMap and IIRC you can indeed await arrays that way, but the outer async function always returns a regular Promise. You can't force it to return an instance of the patched Array type.

Ygg2 10 hours ago|||
> Green threads are a very controversial design choice that even JVM backed out of.

Did they? Project Loom has stabilized around Java 21, no?

voidifremoved 9 hours ago|||
Virtual Threads aren't quite the same as green threads (they don't block the OS thread) and they work extremely well now.
gf000 5 hours ago||
They are not even remotely the same, there is no reason to compare them at all.
rafaelmn 9 hours ago|||
I stand corrected, I stopped keeping track of JVM years ago, was referring to initial green threads implementation.
captainmuon 10 hours ago|||
JavaScript got async in 2017, Python in 2015, and C# in 2012. Python actually had a version of it in 2008 with Twisted's @inlineCallbacks decorator - you used yield instead of await, but the semantics were basically the same.
aw1621107 9 hours ago|||
> And as for Rust - that's beyond inexplicable. Rust has green threads in the early days and abandoned them in favour of async / await.

There was a fair bit of time between the two, to the point I'm not sure the latter can be called much of a strong motivation for the former. Green threads were removed pre-1.0 by the end of 2014 [0], while work on async/await proper started around 2017/2018 [1].

In addition, I think the decision to remove green threads might be less inexplicable than you might otherwise expect if you consider how Rust's chosen niche changed pre-1.0. Off the top of my head no obligatory runtime and no FFI/embeddability penalties are the big ones.

> Rust now has a green thread implementation that fixes that mistake

As part of the runtime/stdlib or as a third-party library?

[0]: https://github.com/rust-lang/rust/issues/17325

[1]: https://without.boats/blog/why-async-rust/

senfiaj 8 hours ago|||
> Python already had threads

But for a long time (I think even till today despite that there is as an optional free-threaded build) CPython used Global Interpreter Lock (GIL) which paradoxically makes the programs run slower when more threads are used. It's a bad idea to allow to share all the data structure across threads in high level safe programming languages.

JS's solution is much better, it has worker threads with message passing mechanisms (copying data with structuredClone) and shared array buffers (plain integer arrays) with atomic operation support. This is one of the reasons why JavaScript hasn't suffered the performance penalty as much as Python has.

josephg 7 hours ago|||
> At any step in that sequence, the language could have introduced green threads and the job would have been done.

The job wouldn’t have been done. They would have needed threads. And mutexes. And spin locks. And atomics. And semaphores. And message queues. And - in my opinion - the result would have been a much worse language.

Multithreaded code is often much harder to reason about than async code, because threads can interleave executions and threads can be preempted anywhere. Async - on the other hand - makes context switching explicit. Because JS is fundamentally single threaded, straight code (without any awaits) is guaranteed to run uninterrupted by other concurrent tasks. So you don’t need mutexes, semaphores or atomics. And no need to worry about almost all the threading bugs you get if you aren’t really careful with that stuff. (Or all the performance pitfalls, of which there are many.)

Just thinking about mutexes and semaphores gives me cold sweats. I’m glad JS went with async await. It works extremely well. Once you get it, it’s very easy to reason about. Much easier than threads.

rdw 6 hours ago|||
Once you write enough code, you'll realize you need synchronization primitives for async code as well. In pretty much the same cases as threaded code.

You can't always choose to write straight code. What you're trying to do may require IO, and then that introduces concurrency, and the need for mutual exclusion or notification.

Examples: If there's a read-through cache, the cache needs some sort of lock inside of it. An async webserver might have a message queue.

The converse is also true. I've been writing some multithreaded code recently, and I don't want to or need to deal with mutexes, so, I use other patterns instead, like thread locals.

Now, for sure the async equivalents look and behave a lot better than the threaded ones. The Promise static methods (any, all, race, etc) are particularly useful. But, you could implement that for threads. I believe that this convenience difference is more due to modernity, of the threading model being, what 40, 50, 60 years old, and given a clean-ish slate to build a new model, modern language designers did better.

But it raises the idea: if we rethought OS-level preemptible concurrency today (don't call it threads!), could we modernize it and do better even than async?

gf000 5 hours ago|||
Now you are comparing single threaded code with multi threaded, which is a completely different axis to async vs sync. Just take a look at C#'s async, where you have both async and multi threading, with all the possible combinations of concurrency bugs you can imagine.
kibwen 8 hours ago|||
> And as for Rust - that's beyond inexplicable.

No, you appear to have no idea what you're talking about here. Rust abandoned green threads for good reason, and no, the problems were not minor but fundamental, and had to do with C interoperability, which Go sacrifices upon the altar (which is a fine choice to make in the context of Go, but not in the context of Rust). And no, Rust does not today have a green thread implementation. Furthermore, Rust's async design is dramatically different from Javascript, while it certainly supports typical back-end networking uses it's designed to be suitable for embedded contexts/freestanding contexts to enable concurrency even on systems where threads do not exist, of which the Embassy executor is a realization: https://embassy.dev/

01HNNWZ0MV43FF 10 hours ago|||
What if process_the_character takes multiple seconds waiting on a network request?
Ygg2 10 hours ago||
> Rust has green threads in the early days and abandoned them in favour of async / await. Granted the original green thread implementation needed a bit of refinement - making every low level choose between event driven and blocking on every invocation was a mistake.

That's a mischaraterization. They were abandoned because having green threads introduces non-trivial runtime. It means Rust can't run on egzotic architectures.

> It sounds like Zig with its pluggable I/O interface finally got it right

That remains to be seen. It looks good, with emphasis on looks. Who knows what interesting design constraints and limitation that entails.

Looking at comptime, which is touted as Zig's mega feature, it does come at expense of a more strictly typed system.

Waterluvian 3 hours ago||
I’m not really smart on this subject but I started during callback hell and now use async in Node and front-end and I find it to be just superb. Sometimes I have to reason about queued tasks vs. micro tasks and all that but most of the time it just does what I expect and keeps the code very clean.
mkj 4 hours ago||
> Tokio’s dominance is function coloring at ecosystem scale

That isn't function colouring, but rather plain incompatible APIs/runtime. You could have the equivalent with non-async ecosystems.

twoodfin 3 hours ago|
What it really is: LLM-generated puffery.
jayd16 4 hours ago||
They get their sequential trap example wrong.

You can call async methods without immediately calling await. You can naively await as late as possible. They'll run in parallel, or at least how ever the call was configured.

cbarrick 4 hours ago|
Well, it depends on the language.

In Javascript, promises are eager and start executing immediately. They return control back to the caller when they need to wait. So in practice, all of your promises are running concurrently as soon as you create them.

In Rust, futures are lazy don't start executing until they are awaited. You have to use various features of your chosen runtime to run multiple futures concurrently (functions like `spawn` or `select`). But that interface isn't standardized and leads to the the ecosystem fragmentation issue discussed in the article. There was an attempt to standardize the interface in the `futures` crate, but none of the major runtimes actually implement the interface.

andrewstuart 3 days ago||
I like async and await.

I understand that some devs don’t want to learn async programming. It’s unintuitive and hard to learn.

On the other hand I feel like saying “go bloody learn async, it’s awesome and massively rewarding”.

marssaxman 3 days ago||
Intuition is relative: when I first encountered unix-style synchronous, threaded IO, I found it awkward and difficult to reason about. I had grown up on the callback-driven classic Mac OS, where you never waited on the results of an IO call because that would freeze the UI; the asynchronous model felt like the normal and straightforward one.
jandrewrogers 4 hours ago|||
It is an intrinsic tradeoff. With async there is significantly more code complexity with substantially higher performance and scalability.

If you don't need the performance and scalability then it is not unreasonable to argue that async isn't worth the engineering effort.

nottorp 3 days ago|||
> It’s unintuitive and hard to learn.

Funny, because it was supposed to be more intuitive than handling concurrency manually.

palata 3 days ago|||
It is a tool. Some tools make you more productive after you have learned how to use them.

I find it interesting how in software, I repeatedly hear people saying "I should not have to learn, it should all be intuitive". In every other field, it is a given that experts are experts because they learned first.

brazzy 3 days ago|||
> I find it interesting how in software, I repeatedly hear people saying "I should not have to learn, it should all be intuitive". In every other field, it is a given that experts are experts because they learned first.

Other fields don't have the same ability to produce unlimited incidental complexity, and therefore not the same need to rein it in. But I don't think there's any field which (as a whole) doesn't value simplicity.

palata 2 days ago||
I feel like it's missing my point. Using a chainsaw is harder than using a manual saw, but if you need to cut many trees it's a lot more efficient to first learn how to use the chainsaw.

Now if you take the chainsaw without spending a second thinking about learning to use it, and start using it like a manual saw... no doubt you will find it worse, but that's the wrong way to approach a chainsaw.

And I am not saying that async is "strictly better" than all the alternatives (in many situations the chainsaw is inferior to alternatives). I am saying that it is a tool. In some situations, I find it easier to express what I want with async. In others, I find alternatives better. At the end of the day, I am the professional choosing which tool I use for the job.

nottorp 3 days ago|||
Except you're hearing it from someone who doesn't have a problem handling state machines and epoll and manual thread management.
dullcrisp 9 hours ago|||
Right but how do you expose your state machine and epoll logic to callers? As a blocking function? As a function that accepts continuations and runs on its own thread? Or with no interface such that anyone who wants to interoperate with you has to modify your state machine?
palata 3 days ago|||
And that was intuitive and easy to learn?
nottorp 2 days ago||
I find state machines plus some form of message passing more intuitive than callbacks or any abstraction that is based on callbacks. Maybe I'm just weird.
palata 2 days ago||
When I did not know how to program, neither async nor message passing were intuitive. I had to learn, and now those are tools I can use when they make sense.

I never thought "programming languages are a failure, because they are not intuitive to people who don't know how to program".

My point being that I don't judge a tool by how intuitive it is to use when I don't know how to use it. I judge a tool by how useful it is when I know how to use it.

Obviously factoring in the time it took to learn it (if it takes 10 years to master a hammer, probably it's not a good hammer), but if you're fine with programming, state machines and message passing, I doubt that it will take you weeks to understand how async works. Took me less than a few hours to start using them productively.

littlestymaar 3 days ago||||
It is. A lot.

But concurrency is hard and there's so much you syntax can do about it.

afiori 3 days ago||||
Some come to async from callbacks and others from (green)threads.

If you come from callbacks it is (almost) purely an upgrade, from threads is it more mixed.

nottorp 3 days ago||
Yeah, that's what annoys me, async comes from people who only knew about callbacks and not other forms of inter thread communication.
josephg 7 hours ago||
Not true. I’ve used both, and I often prefer the explicitness of async await. It’s easier to reason about. The language guarantees that functions which aren’t async can’t be preempted - and that makes a lot of code much easier to write because you don’t need mutexes, atonics and semaphores everywhere. And that in turn often dramatically improves performance.

At least in JS. I don’t find async in rust anywhere near as nice to use. But that’s a separate conversation.

shakow 3 days ago||||
Frankly, async being non-intuitive does not imply that manual concurrency handling is less so; both are a PITA to do correctly.
andrewstuart 3 days ago|||
It IS intuitive.

After you’ve learned the paradigm and bedded it down with practice.

tcfhgj 3 days ago|||
I can't follow that it's hard to learn and unintuitive
Yokohiii 4 hours ago|||
Really? async/await is the model that makes it really easy to ignore all the subtleties of asynchronous code and just go with it. You just need to trial and error where/when to put async/await keywords. It's not hard to learn. Just effort. If something goes wrong, then "that's just how things go these days".
cmrdporcupine 5 hours ago|||
Or... we've learned it and don't like it? For legitimate reasons?
brazzy 3 days ago||
What's awesome or rewarding about it?

It forces programmers to learn completely different ways of doing things, makes the code harder to understand and reason about, purely in order to get better performance.

Which is exactly the wrong thing for language designers to do. Their goal should be to find better ways to get those performance gains.

And the designers of Go and Java did just that.

swiftcoder 3 days ago|||
> It forces programmers to learn completely different ways of doing things, makes the code harder to understand and reason about, purely in order to get better performance.

Technically, promises/futures already did that in all of the mentioned languages. Async/await helped make it more user friendly, but the complexity was already there long before async/await arrived

brazzy 3 days ago||
Yes - I was really talking about "asynchronous programming" in general, not the async/await ways to do it in particular.
tcfhgj 3 days ago|||
What different way of doing things?

If I want sequential execution, I just call functions like in the synchronous case and append .await. If I want parallel and/or concurrent execution, I spawn futures instead of threads and .await them. If I want to use locks across await points, I use async locks, anything else?

oconnor663 7 hours ago||
> async/await introduced entirely new categories of bugs that threads don’t have. O’Connor documents a class of async Rust deadlocks he calls “futurelocks”

I didn't coin that term, the Oxide folks did: https://rfd.shared.oxide.computer/rfd/0609. I want to emphasize that I don't think futurelocks represent a "fundamental mistake" or anything like that in Rust's async model. Instead, I believe they can be fixed reliably with a combination of some new lint rules and some replacement helper functions and macros that play nicely with the lints. The one part of async Rust that I think will need somewhat painful changes is Stream/AsyncIterator (https://github.com/rust-lang/rust/issues/79024#issuecomment-...), but those aren't yet stable, so hopefully some transition pain is tolerable there.

> The pattern scales poorly beyond small examples. In a real application with dozens of async calls, determining which operations are independent and can be parallelized requires the programmer to manually analyze dependencies and restructure the code accordingly.

I think Rust is in an interesting position here. On the one hand, running things concurrently absolutely does take deliberate effort on the programmer's part. (As it does with threads or goroutines.) But on the other hand, we have the borrow checker and its strict aliasing rules watching our back when we do choose to put in that effort. Writing any sort of Rust program comes with cognitive overhead to keep the aliasing and mutation details straight. But since we pay that overhead either way (for better or worse), the additional complexity of making things parallel or concurrent is actually a lot less.

> At the function level, adding a single i/o call to a previously synchronous function changes its signature, its return type, and its calling convention. Every caller must be updated, and their callers must be updated.

This is part of the original function coloring story in JS ("you can only call a red function from within another red function") that I think gets over-applied to other languages. You absolutely can call an async function from a regular function in Rust, by spinning up a runtime and using `block_on` or similar. You can also call a regular function from an async function by using `spawn_blocking` or similar. It's not wonderful style to cross back and forth across that boundary all the time, and it's not free either. (Tokio can also get mad at you if you nest runtimes within one another on the same thread.) But in general you don't need to refactor your whole codebase the first time you run into a mismatch here.

time4tea 9 hours ago||
No mention of JVM.. which is a bit odd as recently is kinda solved this problem. Sure, not all use cases, but a lot.

It uses N:M threading model - where N virtual threads are mapped to M system threads and its all hidden away from you.

All the other languages just leak their abstractions to you, java quietly doesn't.

Sure, java is kinda ugly language, you can use a different JVM language, all good.

Don't get me wrong, love python, rust, dart etc, but JVM is nice for this.

ubercow13 9 hours ago|
It is mentioned
time4tea 8 hours ago||
Ah yeah, you are right. It was easy to miss, as it was ~30 words in a massive article.
holybbbb 8 hours ago||
No mention of Novell Netware. This was a solved problem decades ago and Windows had it for almost as long.

The next decade will be a proliferation of hackers having fun with io_uring coming up with all sorts of patterns.

cdaringe 3 days ago||
Surely by section 7 well be talking (or have talked) about effect systems
twoodfin 3 days ago|
Wasn’t in the prompt.
paulddraper 3 days ago|
> This was bad enough that Node.js eventually changed unhandled rejections from a warning to a process crash, and browsers added unhandledrejection events. A feature designed to improve error handling managed to create an entirely new class of silent failures that didn’t exist with callbacks.

Java has this too.

More comments...