[HN Gopher] A Few Words About Async
___________________________________________________________________
A Few Words About Async
Author : vinhnx
Score : 76 points
Date : 2025-11-02 01:10 UTC (21 hours ago)
(HTM) web link (yoric.github.io)
(TXT) w3m dump (yoric.github.io)
| neonsunset wrote:
| Thank you for the article. I noticed the statement
|
| > A second drawback is that async/await has a performance cost.
| CPU-bound code written with async/await will simply never be as
| fast or as memory-efficient as the equivalent synchronous code.
|
| If you are interested, .NET is actively improving at this and
| .NET 11 will ship with "Runtime Async" which replaces explicitly
| generated state machines with runtime suspension mechanism. It's
| not """zero-cost""" for now (for example it can block object
| escape analysis), and the async calling convention is different
| to sync, but the cost is massively reduced, the calls can be
| inlined, optimized away, devirtualized and more in the same way
| standard sync calls can. There will be few drawbacks to using
| async at that point, save for the syntax noise and poor default
| habit in .NET to append Async suffix to such methods. In your own
| code you can write it tersely however.
|
| As for Rust, it also can optimize it quite well, the "call-level
| overhead" is much less of a problem there, although I have not
| studied compiler output for async Rust in detail so hopefully
| someone with more familiarity can weight in.
| Yoric wrote:
| (author) Thanks, I'll need to read up on this!
| sema4hacker wrote:
| > I'll try and write a followup with benchmarks.
|
| That would definitely keep the story from being all-hat-and-no-
| cattle. I can't recall reading something with so many alternate
| versions of how to implement something but with zero benchmarks.
| jiggunjer wrote:
| Recently had to familiarize myself with python async because a
| third party SDK relies on it.
|
| In many cases the lib will rely on threads to handle calls to
| synchronous functions, got me wondering if there's a valid use
| case for running multiple async threads on a single core.
| conradludgate wrote:
| I frequently use single threaded async runtimes in Rust.
| Particularly if it's background processing that doesn't need to
| be particularly high throughput.
|
| Eg in a user application you might have the performance
| sensitive work (eg rendering) which needs to be highly parallel
| - give it a bunch of threads. However when drawing the UI,
| handing user input, etc you usually don't need high throughput
| - use only 1 thread to minimise the impact on the rendering
| threads
|
| In my work with server side code, I use multiple async
| runtimes. One runtime is multithreaded and handles all the real
| traffic. One runtime is singlethreaded and handles management
| operations such as dispatching metrics and logs or garbage
| collecting our caches
| electroglyph wrote:
| i would say: probably not
|
| if your async thread is so busy that you need another one, then
| it's probably not an async workload to begin with.
|
| i work on a python app which uses threads and async, but only
| have one async thread because it's more than enough to handle
| all the async work i throw at it.
| travisgriggs wrote:
| That was "quite a few" words. I wish the author had taken more
| time with Elixir/Erlang.
|
| Languages like rust/python that use lots of reserved keywords,
| especially for control flow seem to have reached for that arrow
| to solve the "event loop" problem as described.
|
| In BEAM languages, that very event loop stays front and center,
| you don't have this awkward entanglement between reserved
| keywords and event loop. If you want another chunk of thing to
| happen later because of an event, you just arrange for an event
| of that nature to be delivered. No callbacks. No async coloring.
| Just events. The solution to the event problem is to double down
| and make your event loop more generally usable.
| evnc wrote:
| Interesting. Does that mean if you want to say, make an
| asynchronous http request, you do something like
| "fire_event(HttpRequestEvent(...))" which returns immediately,
| and somewhere else define a handler like
| "on_event(HttpResponseEvent, function (event) { ... })" ? So
| you kind of have to manually break your function up into a
| state machine composed of event handlers? How do you associate
| a given HttpResponseEvent with a specific HttpRequestEvent?
| koakuma-chan wrote:
| Isn't that just callback
| travisgriggs wrote:
| It is a callback of sorts. But most languages do callbacks
| with some sort of anonymous closure mechanism (or more
| primitively, pass function pointer/identifiers). What makes
| BEAM interesting is its prevalence of generalizing
| callbacks themselves as more events (messages).
| koakuma-chan wrote:
| You can also define a callback function in, e.g.,
| JavaScript, and pass its name instead of an anonymous
| closure. Does "BEAM" do anything that JavaScript can't or
| doesn't?
| Yoric wrote:
| There are a few very large features that BEAM offers
| that, as far as I can tell, no other industrial
| language/VM implements. In particular, BEAM is meant for
| distributed computation.
|
| You can spawn new processes, communicate between
| processes (which don't have to be on the same computer),
| sending any kind of data between them including closures.
|
| BEAM also has an error model designed to handle
| concurrent and distributed failures, e.g. a process may
| fail and another process (which, again, may or may not be
| on the same machine) monitoring it may decide to restart
| it, or to do some recovery, etc.
|
| BEAM builds into it a number of the features for which we
| use orchestration, observability, just simpler and
| (generally) more flexible. And this is a platform that
| has been used in the industry since the 90s.
| travisgriggs wrote:
| Elixir IS very state machine like.
|
| So yes, your event loop processes the results of asynchronous
| work launched because of earlier events the same way.
|
| Part of what makes this work, is the awesome function clause
| matching. Coordinating origin of async work and result of
| async work is really easy because you can match on any form
| of terms your heart desires, and everything is always
| immutable.
| vlovich123 wrote:
| > In practice, things are a bit more complicated. In fact, I
| don't know of any async/await embedding on top of io_uring in any
| language yet, because it doesn't quite match this model. But
| generally, that's the idea.
|
| Glommio and monoio are async runtimes in rust on top of io_uring
| and Tokio has an optional io_uring backend. Does that not count?
| This is such a well researched article that this kind of
| statement makes me think I'm missing something - surprising the
| author would get this wrong.
| koakuma-chan wrote:
| As far as I know those libraries only implement basic things.
| They don't use registered buffers, registered file descriptors,
| etc, and don't implement advanced features like chained
| operations.
| ozgrakkurt wrote:
| They are async libraries built on io-uring though. Other
| mainstream async libraries also don't go as deep as possible
| on epoll or other things either afaik
| Yoric wrote:
| (author here)
|
| I didn't mention tokio's io_uring because, as far as I
| understand, it is unmaintained. I vaguely recall a conversation
| in which someone (a contributor?) was claiming that it was not
| possible to implement most of the features of tokio on io_uring
| due to conflicting models. [source needed], obviously.
|
| I will admit the very existence of glommio or monoio had
| entirely slipped my mind. I'll probably need to add a few
| paragraphs about thread-per-core runtimes. Thanks!
| unscaled wrote:
| This is a pretty in depth overview of a complex topic, which
| unfortunately most people tends to dumb down considerably.
| Commonly cited articles such as "What Color is Your Function?" or
| Revisiting Coroutines by the de Moura and Ierusalimschy are
| insightful, but they tend to pick on a a subset of the properties
| that make up this complex topic of concurrency. Misguided
| commentators on HN often recommends these articles as reviews,
| but they are not reviews and you are guaranteed to learn all the
| wrong lessons if you approach them this way.
|
| This article looks like a real review. I only have one concern
| with it: It oversells M:N concurrency with green threads over
| async/await. If I understand correctly, it claims that
| async/await (as implemented by Rust, Python C# and Kotlin - not
| JavaScript) is less efficient (both in terms of RAM and CPU) than
| M:N concurrency using green threads. The main advantages it has
| is that No GC is required, C library calls carry no extra cost
| and the cost of using async functions is always explicit. This
| makes async/await great for a systems language like Rust, but it
| also pushes a hidden claim that Python, C# and Kotlin all made a
| mistake by choosing async/await. It's a more nuanced approach
| than what people take by incorrectly reading the articles I
| mentioned above, but I think it's still misguided. I might also
| be reading this incorrectly, but then I think the article is just
| not being clear enough about the issues of cost.
|
| To put it shortly: Both green threads and async/await are
| significantly costlier than single-threaded code, but their cost
| manifests in different ways. With async/await the cost mostly
| manifests at "suspension points" (whenever you're writing
| "await"), which are very explicit. With green threads, the cost
| is spread everywhere. The CPU cost of green threads includes not
| only the wrapping C library calls (which is mentioned), but also
| the cost of resizing or segmenting the stack (since we cannot
| juts preallocate a 1MiB stack for each coroutine). Go started out
| with segmented stacks and moved on to allocating a new small
| stack (2KiB IIRC) for each new goroutine and copying it to a new
| stack every time it needs to grow[1]. That mechanism alone
| carries its own overhead.
|
| The other issue that is mentioned with regards to async/await but
| is portrayed as "resolved" for green threads is memory
| efficiency, but this couldn't be farther from the truth: when
| it's implemented as a state machine, async/await is always more
| efficient than green threads. Async/await allocates memory on
| every suspension, but it only saves the state that needs to be
| saved for this suspension (as an oversimplification we can say it
| only saves the variables already allocated on the stack). Green
| threads, on the other hand, always allocate extra space on the
| stack, so there would always be some overhead. Don't get me wrong
| here: green threads with dynamic stacks are considerably cheaper
| than real threads and you can comfortably run hundreds of
| thousands of them on a single machine. But async/await state
| machines are even cheaper.
|
| I also have a few other nitpicks (maybe these issues come from
| the languages this article focuses on, mainly Go, Python, Rust
| and JavaScript)
|
| - If I understand correctly, the article claims async/await
| doesn't suffer from "multi-threading risks". This is mostly true
| in Rust, Python with GIL and JavaScript, for different reasons
| that have more to do with each language than async/await:
| JavaScript is single-threaded, Python (by default) has a GIL, and
| Rust doesn't let you have write non-thread-safe code even if
| you're using plain old threads. But that's not the case with C#
| or Kotlin: you still need to be careful with async/await in these
| languages just as you would be when writing goroutines in Go. On
| the other hand, if you write Lua coroutines (which are equivalent
| to Goroutines in Go), you can safely ignore synchronization
| unless you have a shared memory value that needs to be updated
| across suspension points.
|
| - Most green thread implementations would block the host thread
| completely if you call a blocking function from a non-blocking
| coroutine. Go is an outlier even among the languages that employ
| green threads, since it supports full preemption of long-running
| goroutines (even if no C library code is called). But even Go
| only added full support for preemption with Go 1.14. I'm not
| quite since when long-running Cgo function calls have been
| preemptible, but this still shows that Go is doing its own thing
| here. If you have to use green threads on another language like
| Lua or Erlang, you shouldn't expect this behavior.
|
| [1] https://blog.cloudflare.com/how-stacks-are-handled-in-go/
| zozbot234 wrote:
| > But that's not the case with C# or Kotlin: you still need to
| be careful with async/await in these languages just as you
| would be when writing goroutines in Go.
|
| C# and Kotlin are safe from data races; Go is not. If you do
| not explicitly synchronize in C#/Kotlin you may see torn writes
| and other anomalies, but these will not directly impact safety
| unlike in Go.
| Yoric wrote:
| (author here)
|
| 1. Thanks for your remarks on memory efficiency. I wrote that
| piece a few months ago, I'll have to reread it, but if I
| implied something wrong, I'll try and amend it!
|
| 2. Regarding "multi-threading risks", I don't think I claim
| that. I have definitely encountered race conditions in single-
| threaded async code. You don't encounter the same kind of
| memory corruptions as in, say, multi-threaded C, but you can
| definitely break invariants on data structures. If I
| miswrote/wrote something unclear, I'll need to fix that, too!
| valcron1000 wrote:
| > async/await is also available in a bunch of other languages,
| including F#, C#8, Haskell[...]
|
| Haskell (GHC) does not provide async/await but uses a green
| thread model.
| LtWorf wrote:
| How are green threads implemented?
| whatevaa wrote:
| A runtime with it's own scheduling. Something rust doesn't
| want to require.
| kaoD wrote:
| Aren't green threads and async-await orthogonal concepts?
|
| As I understand it async-await is syntax sugar to write a state
| machine for cooperative multitasking. Green "threads" are
| threads implemented in user code that might or might not use OS
| threads. E.g.:
|
| - You can use Rust tokio::task (green threads) with a manually
| coded Future with no async-await sugar, which might or might
| not be parallelized depending on the Tokio runtime it's running
| on.
|
| - ...or with a Future returned by an async block, which allows
| async-await syntax.
|
| - You can have a Future created by an async function call and
| poll it manually from an OS thread.
|
| - Node has async-await syntax to express concurrency but it has
| no parallelism at all since it is single-threaded. I think no
| green threads either (neither parallel or not) since Promises
| are stackless?
|
| Is this a new usage of the term I don't know about? What does
| it mean? Or did I misinterpret the "but"?
|
| As a non-Haskeller I guess it doesn't need explicit async-await
| syntax because there might be some way to express the same
| concept with monads?
| valcron1000 wrote:
| You don't need "monads" (in plural) since GHC provides a
| runtime where threads are not 1:1 OS threads but rather are
| managed at the user level, similar to what you have in Go.
| You can implement async/await as a library though [1]
|
| [1] https://www.cambridge.org/core/services/aop-cambridge-
| core/c...
| Yoric wrote:
| (author here)
|
| Well, I haven't used Haskell in a few years, so I could
| absolutely be wrong. That being said, I'm almost sure that I
| saw a presentation by Simon Marlowe 15-20 years ago
| demonstrating GHC with a multicore scheduler (alongside `seq`
| and `par`). Also, from the very same Simon Marlowe, there's a
| package called `async`
| https://hackage.haskell.org/package/async which basically
| provides async (no await, though).
| anonymoushn wrote:
| it is frustrating that the post opens by describing latency and
| then saying that it is called throughput.
| littlestymaar wrote:
| In a single-task setting (the situation described in the intro)
| throughput and latency are just the inverse of one another (in
| the mathematical sense of "inverse": throughput = nb task per
| seconds = 1/time taken to process the task = 1/latency).
|
| They only diverge when you consider multiple tasks.
| derriz wrote:
| That's not the way "latency" is commonly used in my
| experience.
|
| Latency numbers always include queuing time - so the measures
| are not related or derivable from each other.
|
| A process might have a throughput of 1 million jobs per
| second but if the average size of the queue is 10 million
| then your job latency is going to be 10 seconds on average
| and not 1 microsecond.
| ballpug wrote:
| raw synchronisation costs, 2-5us during each context-switch to
| replace registers, pointers, interrupt handlers, etc.
|
| for python syntax to enumerate the fibonacci sequence:
|
| #fibonacci(n - 1) + fibonacci(n - 2)
|
| Which computes event.arg
| mont_tag wrote:
| Do you want to take advantage of having multiple cores?
|
| * Processes do this right out of the box. * Threads only do this
| on Python's new GIL builds. * Async, not so much.
___________________________________________________________________
(page generated 2025-11-02 23:02 UTC)