Remix.run Logo
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 8 hours ago | parent | next [-]

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 6 hours ago | parent | next [-]

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 7 hours ago | parent | prev | next [-]

> 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 7 hours ago | parent [-]

I knew someone was going to bring up monads that's why I put JS version :) JS took the C# syntax.

WorldMaker 4 hours ago | parent [-]

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 24 minutes ago | parent [-]

> 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 8 hours ago | parent | prev [-]

> 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 7 hours ago | parent | next [-]

Virtual Threads aren't quite the same as green threads (they don't block the OS thread) and they work extremely well now.

gf000 3 hours ago | parent [-]

They are not even remotely the same, there is no reason to compare them at all.

rafaelmn 7 hours ago | parent | prev [-]

I stand corrected, I stopped keeping track of JVM years ago, was referring to initial green threads implementation.

captainmuon 8 hours ago | parent | prev | next [-]

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.

8 hours ago | parent [-]
[deleted]
aw1621107 7 hours ago | parent | prev | next [-]

> 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 6 hours ago | parent | prev | next [-]

> 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 5 hours ago | parent | prev | next [-]

> 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 4 hours ago | parent | next [-]

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 3 hours ago | parent | prev [-]

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 6 hours ago | parent | prev | next [-]

> 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 8 hours ago | parent | prev | next [-]

What if process_the_character takes multiple seconds waiting on a network request?

Ygg2 8 hours ago | parent | prev [-]

> 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.