[go: up one dir, main page]

Hacker News new | past | comments | ask | show | jobs | submit login
Playground Wisdom: Threads Beat Async/Await (pocoo.org)
118 points by samwillis 23 hours ago | hide | past | favorite | 63 comments





I expected to see Swift but seems like most such discussions overlook it. Here’s a great discussion that goes deeper into it: https://forums.swift.org/t/concurrency-structured-concurrenc...

The article seems specific to JavaScript, C# is different.

> you cannot await in a sync function

In C# it’s easy to block the current thread waiting for an async task to complete, see Task.Wait method.

> since it will never resolve, you can also never await it

In C#, awaiting for things which never complete is not that bad, the standard library has Task.WhenAny() method for that.

> let's talk about C#. Here the origin story is once again entirely different

Originally, NT kernel was designed for SMP from the ground up, supports asynchronous operations on handles like files and sockets, and since NT 3.5 the kernel includes support for thread pool to dispatch IO completions: https://en.wikipedia.org/wiki/Input/output_completion_port

Overlapped I/O and especially IOCP are hard to use directly. When Microsoft designed initial version of .NET, they implemented thread pool and IOCP inside the runtime, and exposed higher-level APIs to use them. Stuff like Stream.BeginRead / Stream.EndRead available since .NET 1.1 in 2003, the design pattern is called Asynchronous Programming Model (APM).

Async/await language feature introduced in .NET 4.5 in 2012 is a thin layer of sugar on top of these begin/end asynchronous APIs which were always there. BTW, if you have a pair of begin/end methods, converting into async/await takes 1 line of code, see TaskFactory.FromAsync.


You're probably right that this is leaning in on JavaScript and Python more, but I did try to make a point that the origin story for this feature is quite a bit different between languages. C# is the originator of that feature, but the implications of that feature in C# are quite different than in for instance JavaScript or Python. But when people have a discussion about async/await it often loses these nuances very quickly.

> Async/await language feature introduced in .NET 4.5 in 2012 is a thin layer of sugar on top of these begin/end asynchronous APIs which were always there.

You are absolutely right. That said, it was a conscious decision to keep the callback model and provide "syntactic sugar" on top of it to make it work. That is not the only model that could have been chosen.


Seems like this article conflates threads C# with asynchronous operations a little.

The way I see it, threads are for parallel & concurrent execution of CPU-bound workloads, across multiple CPU cores. And typically use Task Parallel Library. Async/await won’t help here.

Whereas async/await for IO bound workloads, and freeing up the current CPU thread until the IO operation finishes. As mentioned, syntactic sugar on top of older callback-based asynchronous APIs.


I would make the argument it does not matter what the intention is, in practice people await CPU bound tasks all the time. In fact, here is what the offical docs[1] say:

> You could also have CPU-bound code, such as performing an expensive calculation, which is also a good scenario for writing async code.

[1]: https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous...


Task.Wait() is just using the normal "thread" (in the way the author defines it later) blocking logic to do that in said case but I think the author is trying to talk about pure async/await approaches there as an example of why you still want exactly that kind of non-async "thread" blocking to fall back on for differently colored functions.

Task.WhenAny() is similar to Promise.any()/Promise.race(). I'm not sure this is where the author is focusing attention on though. Regardless if your execution is able to move on and out of that scope those other promises may still never finish or get cleaned up.


> Originally, NT kernel was designed for SMP from the ground up, supports asynchronous operations on handles like files and sockets, and since NT 3.5 the kernel includes support for thread pool to dispatch IO completions: https://en.wikipedia.org/wiki/Input/output_completion_port

Say what you will about Microsoft in that era (and there's a lot to be said), the NT kernel team absolutely crushed it for their customers' use cases. IOCP were years ahead of anything else.

I pretty much hated all of the userspace Win32 work I did (MIDL, COM, DCOM, UGGGGGGGGH), but the Kernel interfaces were wonderful to code against. To this day I have fond memories of Jeffrey Richter's book.


It's not enough to have a nicish abstraction, how did it work in practice and eek out performance? I've heard Bryan Cantrell say there wasn't much there and would be curious to really know what the truth is and more explanation on both sides.

> In C#, awaiting for things which never complete is not that bad, the standard library has Task.WhenAny() method for that.

It's not that bad in JS either. JS has both Promise.any and Promise.race that can trivially set a timeout to prevent a function from waiting infinitely for a non-resolving promise. And as someone pointed out in the Lobsters thread, runtimes that rely on multi-threading for concurrency are also often prone to deadlocks and infinite loops [1].

  import { setTimeout } from 'node:timers/promises'
  
  const neverResolves = new Promise(() => {})
  
  await Promise.any([neverResolves, setTimeout(0)])
  await Promise.race([neverResolves, setTimeout(0)])
  
  console.trace()

[1] https://lobste.rs/s/hlz4kt/threads_beat_async_await#c_cf4wa1

> Promise.race

Ding! You now have a memory leak! Collect your $200 and advance two steps.

Promise.race will waste memory until _all_ of its promises are resolved. So if a promise never gets resolved, it will stick around forever.

It's braindead, but it's the spec: https://github.com/nodejs/node/issues/17469


This doesn't even really appear to be a flaw in the Promise.race implementation [1], but rather a natural result of the fact that native promises don't have any notion of manual unsubscription. Every time you call the then method on a promise and pass in a callback, the JS engine appends the callback to the list of "reactions" [2]. This isn't too dissimilar to registering a ton of event listeners and never calling `removeEventListener`. Unfortunately, unlike events, promises don't have any manual unsubscription primitive (e.g. a hypothetical `removePromiseListener`), and instead rely on automatic unsubscription when the underlying promise resolves or rejects. You can of course polyfill this missing behavior if you're in the habit of consistently waiting on infinitely non-settling promises, but I would definitely like to see TC39 standardize this [3].

[1] https://issues.chromium.org/issues/42213031#comment5

[2] https://github.com/nodejs/node/issues/17469#issuecomment-349...

[3] https://github.com/cefn/watchable/tree/main/packages/unpromi...


This isn't actually about removing the promise (completion) listener, but the fact that promises are not cancelable in JS.

Promises in JS always run to completion, whether there's a listener or not registered for it. The event loop will always make any existing promise progress as long as it can. Note that "existing" here does not mean it has a listener, nor even whether you're holding a reference to it.

You can create a promise, store its reference somewhere (not await/then-ing it), and it will still progress on its own. You can await/then it later and you might get its result instantly if it had already progressed on its own to completion. Or even not await/then it at all -- it will still progress to completion. You can even not store it anywhere -- it will still run to completion!

Note that this means that promises will be held until completion even if userspace code does not have any reference to it. The event loop is the actual owner of the promise -- it just hands a reference to its completion handle to userspace. User code never "owns" a promise.

This is in contrast to e.g. Rust promises, which do not run to completion unless someone is actively polling them.

In Rust if you `select!` on a bunch of promises (similar to JS's `Promise.race`) as soon as any of them completes the rest stop being polled, are dropped (similar to a destructor) and thus cancelled. JS can't do this because (1) promises are not poll based and (2) it has no destructors so there would be no way for you to specify how cancellation-on-drop happens.

Note that this is a design choice. A tradeoff. Cancellation introduces a bunch of problems with promise cancellation safety even under a GC'd language (think e.g. race conditions and inconsistent internal state/IO).

You can kinda sorta simulate cancellation in JS by manually introducing some `isCancelled` variable but you still cannot act on it except if you manually check its value between yield (i.e. await) points. But this is just fake cancellation -- you're still running the promise to completion (you're just manually completing early). It's also cumbersome because it forces you to check the cancellation flag between each and every yield point, and you cannot even cancel the inner promises (so the inner promises will still run to completion until it reaches your code) unless you somehow also ensure all inner promises are cancelable and create some infra to cancel them when your outer promise is cancelled (and ensure all inner promises do this recursively until then inner-est promise).

There are also cancellation tokens for some promise-enabled APIs (e.g. `AbortController` in `fetch`'s `signal`) but even those are just a special case of the above -- their promise will just reject early with an `AbortError` but will still run to (rejected) completion.

This has some huge implications. E.g. if you do this in JS...

  Promise.race([
    deletePost(),
    timeout(3000),
  ]);
...`deletePost` can still (invisibly) succeed in 4000 msecs. You have to manually make sure to cancel `deletePost` if `timeout` completes first. This is somewhat easy to do if `deletePost` can be aborted (via e.g. `AbortController`) even if cumbersome... but more often than not you cannot really cancel inner promises unless they're explicitly abortable, so there's no way to do true userspace promise timeouts in JS.

Wow, what a wall of text I just wrote. Hopefully this helps someone's mental model.


But if you really truly need cancel-able promises, it's just not that difficult to write one. This seems like A Good Thing, especially since there are several different interpretations of what "cancel-able" might mean (release the completion listeners into the gc, reject based on polling a cancellation token, or both). The javascript promise provides the minimum language implementation upon which more elaborate Promise implementations can be constructed.

Why this isn't possible is implicitly (well, somewhat explicitly) addressed in my comment.

  const foo = async () => {
    ... // sync stuff A
    await someLibrary.expensiveComputation()
    ... // sync stuff B
  }
No matter what you do it's impossible to cancel this promise unless `someLibary` exposes some way to cancel `expensiveComputation`, and you somehow expose a way to cancel it (and any other await points) and any other promises it uses internally also expose cancellation and they're all plumbed to have the cancellation propagated inward across all their await points.

Unsubscribing to the completion listener is never enough. Implementing cancellation in your outer promise is never enough.

> The javascript promise provides the minimum language implementation upon which more elaborate Promise implementations can be constructed.

I'll reiterate: there is no way to write promise cancellation in JS userspace. It's just not possible (for all the reasons outlined in my long-ass comment above). No matter how elaborate your implementation is, you need collaboration from every single promise that might get called in the call stack.

The proposed `unpromise` implementation would not help either. JS would need all promises to expose a sort of `AbortController` that is explicitly connected across all cancellable await points inwards which would introduce cancel-safety issues.

So you'd need something like this to make promises actually cancelable:

  const cancelableFoo = async (signal) => {
    if (signal.aborted) {
      throw new AbortError()
    }

    ... // sync stuff A

    if (signal.aborted) {
      // possibly cleanup for sync stuff A
      throw new AbortError()
    }

    await someLibrary.expensiveComputation(signal)

    if (signal.aborted) {
      // possibly cleanup for sync stuff A
      throw new AbortError()
    }

    ... // sync stuff B

    if (signal.aborted) {
      // possibly cleanup for sync stuff A
      // possibly cleanup for sync stuff B
      throw new AbortError()
    }
  }

  const controller = new AbortController()
  const signal = abortController.signal

  Promise.cancelableRace(
    controller, // cancelableRace will call controller.abort() if any promise completes
    [
      cancellableFoo(signal),
      deletePost(signal),
      timeout(3000, signal),
    ]
  )
And you need all promises to get their `signal` properly propagated (and properly handled) across the whole call stack.

People should try Janet (the programming language). Its fiber abstraction got everything right IMO.

Functions in Janet don't have "colors", since fiber scheduling is built-in to the runtime in a lower level. You can call "async" functions from anywhere, and Janet's event loop would handle it for you. It's so ergonomic that it almost feels like Erlang.

Janet has something akin to Erlang's supervisor pattern too, which, IMO, is a decent implementation of "structured concurrency" mentioned in the article.


I actually think out of any language async/await makes the most sense for javascript.

In the first example: there is no such thing as a blocking sleep in javascript. What people use as sleep is just a promise wrapper around a setTimeout call. setTimeout has always created microtasks, so calling a sleep inline would do nothing to halt execution.

I do agree that dangling Promises are annoying and Promise.race is especially bad as it doesn't do what you expect: finish the fastest promise and cancel the other. It will actually eventually resolve both but you will only get one result.

Realistically in JS you write your long running async functions to take an AbortController wrapper that also provides a sleep function, then in your outer loop you check the signal isn't aborted and the wrapper class also handles calling clearTimeout on wrapped sleep functions to stop sleeping/pending setTimeouts and exit your loop/function.


As someone who has only written serious applications in single-threaded, or manually threaded C/C++, and concurrent applications in go using goroutines, channels, and all that fun stuff, I always find the discussion around async/await fascinating. Especially since it seems to be so ubiquitous in modern programming, outside of my sphere.

But one thing is: I don't get it. Why can't I await in a normal function? await sounds blocking. If async functions return promises, why can't I launch multiple async functions, then await on each of them, in a non-async function that does not return a promise?

I get there are answers to my questions. I get await means "yeald if not ready" and if the function is not async "yeald" is meaningless. But I find it a very strange way of thinking nonetheless.


I get it - you'd like await semantics in a function without having to expose that detail to the caller.

You can't get it directly in javascript but you're only one step away. Just not awaiting your function in the caller 'breaks the chain' so to speak, so that at least the caller doesn't have to be async. That way you can avoid tagging your function as async completely.

Therefore one syntax workaround while still being able to use await semantics would just be to nest this extra level inside your function -- wrap those await calls in an anonymous inner function which is tagged async, which you just instantly call that without await, so the function itself doesn't have to be (and does not return a promise).


The `await` keyword means "turn the rest of this function into a callback for the when the Task I'm waiting on finishes, and return the resulting Task". Returning a Task only works if your function is declared to return a Task.

The `async` keyword flags functions that are allowed to be transformed like that. I assume it could have been made implicit.

You can do a blocking wait on a Task or collection of Tasks. But you don't want to do that from a place that might be called from the event loop's thread pool (such as anything called from a Task's completion callback), since it can lock up.


> I assume it could have been made implicit

Not quite. It gets ambiguous whether to wrap return or not. Example:

    function foo(): Promise<number> {
        if (...) { return Promise.resolve(5) }
        ...
    }
but async version is:

    async function foo(): Promise<number> {
        if (...) { return 5 }
        ...
    }
Although you can bake into the language one way or another.

"The `await` keyword means "turn the rest of this function into a callback for the when the Task I'm waiting on finishes, and return the resulting Task"."

Oh my god thank you. I've been trying to wrap my head around the whole async/await paradigm for years, basically writing code based on a few black magic rules that I only half understand, and you finally made it all clear in once sentence. Why all those other attempts to explain async/await don't just say this I can't imagine.


It's because implementing this is not that easy: there are differences between the implementation of coroutines and await that makes it tricky (especially waiting for both CPU tasks and network events).

For Python I loved this talk by David Beazley:

https://www.youtube.com/watch?v=MCs5OvhV9S4&t=2510s

He's implementing async/await from coroutines ground up by live coding on the stage


You can:

https://hackage.haskell.org/package/async-2.2.5/docs/Control...

As long as you don't mind - what did the article say? -

>> transcending to a higher plane and looking down to the folks who are stitching together if statements, for loops, make side effects everywhere, and are doing highly inappropriate things with IO.


[flagged]


Thank goodness Captain Imperative scampered out of his goto hovel to remind us wizards to feel bad for being better than everyone. ;)

Your purely functional insults have no side effect on me, wizard.

What is with the unprovoked condescension?

I guess I get the article author was trying to be provocative but what are you doing?


To lay it all out in full:

TFA wrote out a bunch of problems with imperative code asked you to take it on faith that imperative is not inferior to functional. Then he added a bunch of snark, implying that functional programming is just a superiority complex (which is what I quoted).

I quoted the snark to indicate that I was very aware that the author was mocking FP.

serbuvlad asked why you can't just X. I linked to the exact function that does that for you.

do_not_redeem then suggested I was unaware that the author was mocking FP.


FWIW I (author) didn’t want to mock functional programming. I consider myself an aficionado of functional programming patterns :)

At least in JavaScript, you could mark all of your functions as `async`.

This would mean that function would have to return a Promise and go back to the event loop which would add overhead. I imagine it'd kill performance since you'd essentially be context switching on every function call.

The obvious workaround for this is to say "I want some of my code to run serially without promises", which is essentially is asking for a `sync` keyword (or, `async` which would be the inverse).


I found it all very confusing until I eventually wrote a little async task scheduler in Lua. Lua has an async / cooperative-coroutine API that is both very simple and easy to express meaningful coroutines with. The API is almost like a sort of system of safer gotos, but in practice it’s very much like Go channel receives, if waiting for a value from a channel was how you passed control to the producer side, and instead of a channel, the producer was just a function call that would return the next value every time you passed it control.

What’s interesting is that C++20 coroutines have very nearly the same API and semantics as Lua’s coroutines. Still haven’t taken the time to dive into that, but now that 23 is published and has working ranges, std::generator looks very promising since it’s kind of a lazy bridge between coroutines and ranges.


> Why can't I await in a normal function?

You can. promise.then(callback). If you want the rest of your logic to be "blocking" then the rest of it goes in the callback. the 'then' method itself returns a promise, so you can return that from a non async function, if you like.

> why can't I launch multiple async functions, then await on each of them, in a non-async function that does not return a promise?

Typically? Exception handling semantics. See the difference between Promise.race, Promise.all and Promise.allSettled.


`await` is only logically blocking. Internally the code in an async function is split up between each `await` so that each fragment can be called separately. They are cooperatively scheduled so `await` is sugar for 1) ending a fragment, 2) registering a new fragment to run when X completes, and 3) yielding control back to the scheduler. None of this internal behavior is present for non-async functions - in C# they run directly on bare threads like C++.

Go's goroutines are comparable to async/await but everything is transparent. In that case it's managed by the runtime instead of a bit of syntactic sugar + libraries.


In C# you can do a collection of Task<T>, start them and then do a Task.WaitAll() on the collection. For example a batch of web requests at the same time and then collect the results once everything is done. I'm not sure how it's done in other languages but I imagine there's something similar.

You can await in a normal function in better languages, just not in JavaScript.

> Why can't I await in a normal function? await sounds blocking

> You can await in a normal function in better languages, just not in JavaScript.

Await, per common definition, makes you wait asynchronously for a Task/Promise. How on earth are you going to "await" for a Promise which also runs on the same thread on a synchronous function? That function needs to be psuedo-async as in "return myPromise.then(() => { /* all fn code here */ }), or you need to use threads, which brings us to the second point...

With the closest thing to threads (workers) in JavaScript and using SharedArrayBuffer and a simple while loop, perhaps (didn't think too much on it), you can implement the same thing with a user defined Promise alternative but then why would you want to block the main thread which usually has GUI/Web-Server code?


At least in node, its because the runtime is an event loop.

In my experience with web browsers, you can't do this because Javascript can NEVER block. For example, if a function takes too long to run, it blocks rendering of the page. If there were ways to make Javascript asynchronously, browsers would have implemented it already, so I assume they can't do it without potential backward incompatibility.

One exception is alert(), which blocks and shows a dialog. But I don't think I've ever seen a website use it instead of showing a "normal" popup with CSS. It looks ugly so it's only used to debug that code actually runs.

I'm not knowledgeable about low-level interruptions, but I think you would need at least some runtime code to implement blocking the thread. In any case, even if the language provides this, you can't use it because the main thread is normally a GUI thread that can't respond to user interaction if it's blocked by another thread. That's the main point of using (background) threads in the first place: so the main thread never blocks from IO bottlenecks.


yeald -> yield

I view Swift‘s Tasks as a thread-like abstraction that does what the author is asking for. Not every Task is providing structured concurrency in the strict sense, because cancellation has to be managed explicitly for the default Task constructor. But Tasks have a defined runtime, cancellation, and error propagation, if one chooses to use a TaskGroup, async let, or adds some glue code. The tools to achieve this are all there.

> Your Child Loves Actor Frameworks

It turns out, Promises are actors. Very simple actors that can have one and only one message that upon resolution they dispatch to all other subscribed actors [0]. So children might love Promises and async/await then?

Personally, I've often thought the resolution to the "color" debate would be for a new language to make all public interfaces between modules "Promises" by default. Then the default assumption is "if I call this public function it could take some time to complete". Everything acting synchronously should be an implementation detail that is nice if it works out.

https://en.wikipedia.org/wiki/Futures_and_promises#Semantics...


That's a nice mental model for promises.

But it is not always true that one promise instance can be awaited in multiple places.

In Swift you cannot get the ref to the Promise instance, so you cannot store it or await it at multiple places.

Once you start an async fn the compiler forces you to await it where it was started (you can use `await task.value`, but that is a getter fn that creates a new hidden promise ref on every call).


If I understand the use case correctly, then, in Swift, Tasks provide exactly what you have described. You can use the Task object in multiple places to await the result.

I'm not familiar with Swift, but it still sounds like it's describing an actor model, just one with a subset of the functionality.

> Go, for instance, gets away without most of this, and that does not make it an inferior language!

Yes, it does. Among other things.


Threads are definitely not _the_ answer but _an_ answer.

You can have as many threads as hardware threads, but in each thread you want continuation passing style (CPS) or async-await (which is a lot like syntactic sugar for CPS). Why? Because threads let you smear program state over a large stack, increasing memory footprint, while CPS / async-await forces you to make all the state explicit and compressed, thus optimizing memory footprint. This is not a small thing. If you have thread-per-client services, each thread will need a sizeable stack, each stack with a guard page -- even with virtual memory that's expensive, both to set up and in terms of total memory footprint.

Between memory per client, L1/L2 cache footprint per client, page faults (to grow the stack), and context switching overhead, thread-per-client is much more expensive than NPROC threads doing CPS or async-await. If you compress the program state per client you can fit more clients in the same amount of memory, and the overhead of switching from one client to another is lower, thus you can have more clients.

This is the reason that async I/O is the key to solving the "C10K" problem: it forces the programmer to compress per-client program state.

But if you don't need to cater to C10K (or C10M) then thread-per-client is definitely simpler.

So IMO it's really about trade-offs. Does your service need to be C10K? How much are you paying for the hardware/cloud you're running it on? And so on. Being more efficient will be more costly in developer cycles -- that can be very expensive, and that's the reason that research into async-await is ongoing: hopefully it can make C10K dev cheaper.

But remember, rewrites cost even more than doing it right the first time.


> Does your service need to be C10K?

It's incorrect question. The correct one "Do your downstream services could handle C10K?" For example a service with a database should almost never be bothered with C10K problem unless most of the requests could skip db access.

Every time you introduce backpressure handling in C10K-ready app it's a red flag you should simply use threads.


Almost as an aside the article makes an interesting point: memory accesses can block. Presumably if it blocks because it's accessing a piece of hardware the operating system schedules another thread on that core ... but what if it blocks on a 'normal' memory access? Does it stall the core entirely? Can 'hyperthreading' briefly run another thread? Does out of order execution make it suddenly not a problem? Surely it doesn't go all the way down to the OS?

> what if it blocks on a 'normal' memory access?

If the CPU gotta wait for memory it's gotta wait, and so it just won't make progress. Though we typically say that the CPU has stalled.

How long depends on if it's found in one of the caches, they're progressively slower, or main memory.

All the fancy techniques like out of order execution, speculative execution and hyperthreads are mainly there to trigger memory reads as soon as possible to reduce how long it is stalled.

Some nice detailed SE answer here[1] with some details.

[1]: https://electronics.stackexchange.com/a/622912


> but what if it blocks on a 'normal' memory access? Does it stall the core entirely?

You won't be able to suspend a virtual thread, so that OS thread is going to be blocked no matter what. As far as kernel threads are concerned I think in practice when a page fault happens the kernel yields and lets another thread take over.


Hyperthreading is a feature where a single core can process two unrelated instruction streams (i.e. two threads) which is useful for software that executes few instructions per cycle.

Nice read, and the article got me to take a look at Java’s project Loom and then Eric Normand’s writeup on Loom and threading options for Clojure.

Good stuff.


I thought that was interesting and I definitely get the frustration in some aspect. I'm mostly familiar with Python and the function "coloring" issue is so annoying as it forces you to have two APIs depending on async or not (look at SQLAlchemy for example). The ergonomics are bad in general and I don't really like having to deal with, for example, awaiting for a result that will be needed in a sync function.

That being said, some alternatives were mentioned (structured concurrency à la Go) but I'd like to hear about people in BEAM land (Elixir) and what they think about it. Though I understand that for system languages, handling concurrency through a VM is not an option.


> structured concurrency à la Go

Go does not have structured concurrency. Goroutines as far as I know don't have much of a relationship with each other at all.


My bad, thanks for the correction.

Sorry, I kind of spaced on reading the article, but from BEAM land, everything is built around concurrent processes with asynchronous messaging.

You can send a message to something else and wait for the response immediately if you want to write in a more blocking style. And you can write a function that does bot the sending a message and the waiting, so you don't really need to think about it if you don't want to. All I/O pretty much feels the same way, although you get into back pressure with some I/O where if there's a queue you can opt to fail immediately or block your process until the send fits in the queue.

The underlying reality is that your processes don't actually block, BEAM processes are essentialy green threads that are executed by a scheduler (which is an OS thread), so blocking things become yields, and the VM also checks if it should yield at every function call. BEAM is built around functional languages, so it lacks loops and looping is handled by recursive function calls, so a process must make a function call in a finite amount of code, and so BEAM's green threading is effectively pre-emptive.

The end result of all this is you can spawn as many processes as you like (i've operated systems with one process per client connection, and millions of client connections per node). And you can write most of your code like normal imperitive blocking code. Sometimes you do want to separate out sending messages and receiving responses, and you can easily do that too. This is way nicer than languages with async/await, IMHO; there's no trickyness where calling a blocking function from a async context breaks scheduling, and calling an async function from a non-async context may not be possible... You do still have the possibility of a function blocking when you didn't expect it to, but it will only block the process that called it and transitively, those processes that are waiting for messages from the now blocked process.

Java's Project Loom seems like it will get to a pretty similar place, eventually. But I've seen articles about some hurdles on the way; there's some things that still actually block a thread rather than being (magically) changed to yielding.

Again, IMHO, people didn't build async/await because it is good. They built it because threads were unavailable (Javascript) or to work around the inability to run as many threads as would make the code simple. If you could spawn a million OS threads without worrying about resource use, only constrained languages would have async/await. But OS threads are too heavy to spawn so many, and too heavy to regularly spawn and let die for ephemeral tasks.


BEAM very much falls into the same camp as the author's description of Scratch does at the beginning of the article. You have a lot more granular control than Scratch, of course, but it also loosely follows the actor model

Feels academic because despite the concerns raised, I only experience async/await as a good thing in real world.

I don't. Now what? I agree with the author, especially in Python. The core Python developers so lost their minds fleeing from the GIL that they forgot historical lessons about how much more ergonomic preemptive multitasking is vs cooperative.

> so lost their minds fleeing from the GIL that they forgot historical lessons

I just don't agree. `async def` gets the fact that we've departed Kansas, good Toto, right out front.

Async moves the coder a step in the direction of the operating system itself. The juice is not worth the squeeze unless the project bears fruit.

I hardly do enough networky stuff to make this facility useful to me, but I'm grateful to the hours poured into making it part of Python.

Contra the author of The Famous Article, threads seem gnarlier still, and I would likely architect out any async parts of a system into another service and talk to it over a port.


I think most of the arguments in this essay rely on this single premise: "The second thing I want you to take away is that imperative languages are not inferior to functional ones."

There is an implied assumption that async/await is a "functional feature" that was pushed into a bunch of innocent imperative languages and polluted them. But there is one giant problem with this assumption: async/await is not a functional feature. If anything, it's the epitome of an imperative flow-control feature.

There are many kinds of functional languages out there, but I think the best common denominator for a primarily functional language nowadays is exactly this: in functional languages control flow structures are first class citizens, and they can be customized by the programmer. In fact, most control flow structures are basically just functions, and the one that aren't (e.g. pattern matching in ML-like languages and monadic comprehensions in Haskell-inspired languages) are extremely generic, and their behavior depends on the types you feed into them. There are other emphasis points that you see in particular families of languages such as pattern matching, strict data immutability or lazy computation — but none of these is a core functional concept.

The interesting point I want to point out is that no primarily functional language that I know actually has async/await. Some of them have monads and these monads could be used for something like async/await but that's not a very common use, and monad comprehensions can be used for other things. For instance, you could use do expressions in Haskell (or for expressions in Scala) to operate on multiple lists at once. The same behavior is possible with nested for-loops in virtually every modern imperative language, but nobody has blamed Algol for "polluting" the purity of our Fortran gotos and arithmetic ifs with this "fancy functional garbage monad from damned ivory tower Academia". That would be absurd, not only because no programming language with monadic comprehensions existed back then, but also because for loops are a very syntax for a very specific thing that can be done with monadic expression. They turn a very abstract functional concept into a highly specific — and highly imperative — feature. The same is true for await. It's an imperative construct that instructs the runtime to suspend (or the compiler to turn the current function into a state machine).

So no, async/await does not have anything to do with functional-language envy and is, in fact, a feature that is quite antithetical to functional programming. If there is any theoretical paradigm behind async/await (vs. just using green threads), it's strong typing and especially the idea of representing effects by types. This is somewhat close to fully-fledged Effect Systems (in languages such as a Koka), but not as powerful. The general idea is that certain functions behave in a way that is "infective" — in other words, if foo() calls bar() which in-turn calls doStuff(), it might be impacted by some side-effect of doStuff(). In order to prevent unpleasant surprises, we want to mark this thing that doStuff does in the function signature (either using an extra argument, a return type wrapper or just an extra modifier like "async").

In a pure language like Haskell, everything from I/O to mutable memory requires specifying an effect and this is usually done through monadic return types. But even the very first version of Java (Ron Pressler's ideal untarnished "imperative" language) has effects (or "colors") which still remain in the language: checked exceptions. They are just as infective as async I/O. If you don't handle exceptions in place, a function marked with "throws IOException" (basically almost any function that deals with I/O) can only be called by another function marked with "throws IOException". What's worse, unlike JavaScript which only has two colors (async and non-async), Java has an infinite number colors!

The description above sounds horrible, but it's not. Checked exceptions are widely believed to be a mistake[1], but they don't bother Java developers enough to make the language unusable. You can always just wrap them with another exception and rethrow. The ergonomics could have been made slightly better, but they're decent enough. But the same can be said for async/await. If you take a language with a similar feature that is close to Java (C# or Kotlin), you'll see the asynchronous functions can still run as blocking code from inside synchronous functions, while synchronous functions can be scheduled on another thread from a synchronous function. The ergonomics for doing that are not any harder than wrapping exceptions.

In addition to that, the advantages of marking a function that runs asynchronous I/O (just like marking a function that throws an exception) are obvious, even if the move itself is controversial. These functions generally involve potentially slow network I/O and you don't want to call them by mistake. If you think that never happens, here is the standard Java API for constructing an InetAddress object from a string representing an IPv4 or IPv6 address: InetAddress.getByName()[2]. Unfortunately, if your IP address is invalid, this function may block while trying to resolve it as a domain name. That's plain bad API design, but APIs that can block in surprising ways are abundant, so you cannot argue that async/await doesn't introduce additional safety.

But let's face it — in most cases choosing async/await vs. green threads for an imperative language is a matter of getting the right trade-off. Async/Await schedulers are easier to implement (they don't need to deal with segmented/relocatable/growable stacks) and do not require runtime support. Async/await also exhibits more efficient memory usage, and arguably better performance in scenarios that do not involve a long call-graph of async functions. Async/await schedulers also integrates more nicely with blocking native code that is used as a library (i.e. C/C++, Objective C or Rust code). With green threads, you just cannot run this code directly from the virtual thread and if the code is blocking, your life becomes even harder (especially if you don't have access to kernel threads). Even with full control of the runtime, you'd usually end up with a certain amount of overhead for native calls[3].

Considering these trade-offs, async/await is perfect in scenarios like below:

1. JavaScript had multiple implementations. Not only were most of them single-threaded, they would also need a major overhaul to support virtual threads even if a thread API was specified.

2. Rust actually tried green threads and abandoned them. The performance was abysmal for a language that seeks zero-cost abstraction and the system programming requirements for Rust made them a deal breaker even if this wasn't the case. Rust just had to support pluggable runtimes and mandating dynamic stacks just won't work inside the Kernel or in soft real-time systems.

3. Swift had to interoperate with a large amount of Objective C called that was already using callbacks for asynchronous I/O (this is what they had). In addition, it is not garbage-collected language, and it still needed to call a lot of C and Objective C APIs, even if that was wrapped by nice Swift classes.

4. C# already had a Promise-like Task mechanism that evolved around wrapping native windows asynchronous I/O. If .Net was redesigned from scratch nowadays, they could have very well went with green threads, but the way .Net developed, this would have just introduced a lot of compatibility issues for almost no gains.

5. Python had the GIL, as the article already mentioned. But even with patching runtime I/O functions (like greenlet — or more accurately, gevent[4] — did), there were many third party libraries relying on native code. Python just went with the more compatible approach.

6. Java did not have any established standard for asynchronous I/O. CompletableFuture was introuced in Java 8, but it wasn't as widely adopted (especially in the standard library) as the C# Task was. Java also had gauranteed full control of the runtime (unlike JavaScript and Rust), it was garbage collected (unlike Rust and Swift) and it had less reliance on native code than Swift, Pre-.NET Core C# or Python. On the other hand, Java had a lot of crucial blocking APIs that haven't been updated to use CompletableFuture, like JDBC and Servlet (Async Servlets were cumbersome and never caught on). Introducing async/await to Java would mean having to rewrite or significantly refactor all existing frameworks in order to support them. That was not a very palatable choice, so again, Java did the correct thing and went with virtual threads.

If you look at all of these use cases, you'd see all of these languages seem to have made the right pragmatic choice. Unless you are designing a new language from scratch (and that language is garbage collected and doesn't need to be compatible with another language or deal with a lot of existing native code), you can go with the ideological argument of "I want my function to be colorless" (or, inversely, you can go with the ideological argument of "I want all suspending functions to be marked explicitly"). In all other cases, pragmatism should win.

---

[1] Although it mostly comes to bad composability — checked result types work very well in Rust.

[2] https://docs.oracle.com/en/java/javase/17/docs/api/java.base...

[3] See the article blelow for the overhead in Go. Keep in mind that the Go team has put a lot of effort into optimizing Cgo calls and reducing this overhead, but they still cannot eliminate it entirely. https://shane.ai/posts/cgo-performance-in-go1.21/

[4] https://www.gevent.org/


> What's worse, unlike JavaScript which only has two colors (async and non-async), Java has an infinite number colors!

Your comment is great, but I need to point out that the above sentence is misrepresenting Java. You can call any function from a Java function. The fact that you may need to handle an Exception when calling some doesn't make it a "colored" function because you can easily handle the Exception and forget about the color, and if you remember the color problem, it was problematic that colors are infectious, i.e. you just can't get rid of the color, which is not the case in Java. Some claim that's actually bad because it prevents things like structured concurrency (because Java can start a Thread anywhere and there's no way for you to know that a function won't... if there was a "color", or better said, effect, for starting a Thread, you could guarantee that no Thread would be started by a function lacking that effect).


Thank you for writing this - it is more detailed that I could come up with!

I would like to add that I feel like functional approaches are more the "future" of programming than trying to iterate over imperative ones to make them as "nice" to use. So I don't really see the big deal of trying to add-on features to existing languages when you can adopt new ones (or experiment with existing ones e.g. https://github.com/getkyo/kyo for a new take on effects in Scala).


> There is an implied assumption that async/await is a "functional feature" that was pushed into a bunch of innocent imperative languages and polluted them. But there is one giant problem with this assumption: async/await is not a functional feature. If anything, it's the epitome of an imperative flow-control feature.

async/await comes from C# and C# got this as an "appoximation" of what was possible with F#. You can go back to 2011 where there are a series of videos on Channel 9 by Anders Hejlsberg where he goes into that.

That said, I don't think my post relies on the premise that this is a fight about imperative to functional programming. If anything the core premise is that there is value in being able to yield anywhere, and not just at await points.

> If you look at all of these use cases, you'd see all of these languages seem to have made the right pragmatic choice.

Potentially, who am I to judge. However that choice was made at a certain point in time and the consequences are here to stay. Other than in JavaScript where it's self evident that this is a great improvement over promise chaining (sans the challenge of unresolved promises), I'm not sure the benefits are all that evident in all languages. I do a fair amount of async programming in JavaScript, Python and Rust and the interplay between threads and async code is very complex and hard to understand, and a lot of the challenges on a day to day would really feel like they are better solved in the scheduler and virtual threads.

> Unless you are designing a new language from scratch (and that language is garbage collected and doesn't need to be compatible with another language or deal with a lot of existing native code), you can go with the ideological argument of "I want my function to be colorless" (or, inversely, you can go with the ideological argument of "I want all suspending functions to be marked explicitly"). In all other cases, pragmatism should win.

I will make the counter argument: even in some languages with async/await like Python, you could very pragmatically implement virtual threads. At the end of the day in Python for instance, async/await is already implemented on top of coroutines anyways. The "only" thing that this would require, is to come to terms with the idea that the event loop/reactor would have to move closer to the core of the language. I think on a long enough time horizon Python would actually start moving towards that, particularly now that the GIL is going and that the language is quite suffering from the complexities of having two entirely incompatible ecosystems in one place (two sets of future systems, two sets of synchronization directives, two independent ways to spawn real threads etc.).




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: