Skip to content

Comment on Asynchronous clean-up

Comments

Reading about async Rust is like reading about a large complicated infrastructure project that keeps getting delays and cost overruns. Rust will end up spending a significant part of its complexity budget on async alone. I wonder if it's worth it, for a problem that gets significantly easier if you are prepared to be just a bit more wasteful (allocations, memory, etc). Compare with Ocaml or Haskell for instance.

The way I see it, Rust is trying to do something novel -- imperative-style async/await in a 'true' systems language. That's a fundamentally hard problem, so their implementation is going to have warts. But it will serve as a great reference for the next batch of languages developed which want to do such things. The work is valuable for the state of computing, even if we don't get something perfectly and spotless in Rust itself.

It's hard to call something virtually every other systems language does 'novel' given for example C++ has async/await though?

Rust shipped async/await before C++ (Rust in 2019, C++ in 2020). C++'s version is not memory safe.

C++'s version is not memory safe.

As a C++ engineer with 20+ years of experience, I recently had an employer project migrate to C++20 and shortly then C++23. We started using coroutines. Oh goodness they're fun. The simple things are indeed simple and easy.

And actually fairly easy to get wrong, too. Way too easy. I might know how coroutines work but goodness it's been difficult training the rest of the team.

It's when you start to get true asynchronicity with multiple jobs running concurrently and each job might have a different workflow... well all of the synchronization and waiting for multiple jobs doesn't quite exist in the standard yet. So the standard provides the language tools but third party libraries provide the actual functionality -- to various degrees of success. We use boost asio's awaitable and there's clearly some warts and even gaps in functionality that we've had to work around.

I like early adopting many things. Adopting coroutines, even in C++23, was perhaps a little too-early.

I really wish my employer would give me time (say... 2 years) to clean up a ton of things not just in our codebase but also in the libraries we use and even propose fixes to the standard itself.

something virtually every other systems language

Huh? Apart from C++?

Is C++ safe?

It's way ahead of C++ in terms of async.

So much time is spent on async because it's important and most other languages have ignored it for a long time.

Ever tried libuv in C? That was just a nightmare to work with, callbacks within callbacks within callbacks.

Rust's approach to async is the best I've seen so far in any language, even high level languages like javascript that depend on async to even function.

Rust's approach to async is the best I've seen so far in any language

I'm not a Rust programmer but have used async/await in both Python and C#. I've also written concurrent code in Erlang. I'd choose the Erlang approach over async/await every time. One concurrency primitive - the process - and an ergonomic, coherent set of supporting features (message passing, supervisors). No function colours and all the baggage that comes with that. Less well discussed but no less important: it puts concurrency decisions in the hands of the function caller, not the implementer.

I understand Rust's focus on zero cost abstractions and, whilst I wouldn't pretend to understand the innards and consequences, get why green threads might not be compatible with that. OTOH that restriction doesn't hold for languages with a runtime like C# and Python. I'm increasingly convinced the compromises of async/await make it a poor language design for the concurrency problem when the language has a runtime.

Perhaps we'll see some comparitive studies now Java has green threads. Erlang is different from C#/Python in many ways so straight comparison is hard. Java is much closer to C# so should be a much better basis for comparison.

This post illustrates a common and unfortunate misconception about async/await.

It's not "hurr durr don't block thread" which is where a lot of developers stop reading at, at their own loss. And then come asking about green threads in github issues in a classic X Y problem fashion.

It is a paradigm where method calls represent an asynchronously produced result, a Task/Promise. Therefore, if all you do is always just await all the results right after calling async methods, you have not used the other 80% of the features.

Task<T> is about composing, chaining and interleaving the tasks, sometimes hundreds or thousands at a time, to achieve (sometimes massively) parallel and concurrent execution of application logic. And we are blessed with C# making it as easy as it gets.

You don't need the syntax tho. You can do green threading without function colouring. Rust might be the only language with an excuse to do it without green threading but only insofar as async doesn't presuppose native threading. But then why not just have a trait when you need to ensure you're the only thing executing on a single thread.

Please re-read the comment, thank you.

In addition, there are single-threaded and thread-per-core executors with different future bounds. It is that Tokio puts more requirements on Send and Sync because it is a proper implementation with worker-per-core (configurable to be otherwise) + work stealing.

async/await is a language design feature to enable fine-grained concurrency. Where "fine grained" means "more fine" than OS processes or threads allow.

Task<T> is about composing, chaining and interleaving the tasks, sometimes hundreds or thousands at a time, to achieve (sometimes massively) parallel and concurrent execution of application logic.

Replace Task<T> with Erlang processes and the statement holds. Except without coloured functions; without the async decision being in the wrong place; without codebases where dual versions of functions proliferate (DoSomething() / DoSomethingAsync()).

At its heart, concurrency is about being able to express multiple sequences of actions such that there's no undesired interaction between them when running. Thread blocking is one form of undesired interaction. So "hurr durr don't block thread" does matter even if it's not the only thing.

In C#, interleaving various tasks is as easy as

    var data1 = service1.GetData();
    var data2 = service2.GetAnotherData();

    var aggregate1 = Aggregate(await data1);

    var result = Handle(await aggregate1, await data2);
No need to deal with writing three lines per each operation to just schedule it in a "fork" way like in Java. In "colorless" (which is always a lie) async runtimes you have to go out of your way to make it concurrent. Perhaps Erlang process isn't coarse grained abstraction as you say, but there are multiple aspects that make Erlang problematic. And again, I will not tire of repeating it - the article about function coloring is actively harmful to the industry and is leading hundreds of developers with concurrency knowledge gaps assume that they need to avoid Task/Future-based code like plague when it is actually the best abstraction we have today for massively concurrent processes (if it was badly designed in whatever language of your choice - sorry).

In addition, because Task<T> is a thread-safe object in C#, you can apply all kinds of transformations and data chaining with LINQ on collections/sequences of those, or even together with parallel LINQ and tasks at the same time. Your simple average code will easily scale to all CPU cores if it does not have interdependencies/contention (a lot of LOB codebases don't, it's all straight line up to a DB or a third-party call(s)).

And last but not least, all BEAM-based languages are comparatively slow (hard performance ceiling is always imposed if you don't pay with static typing and full JIT/AOT) and unfortunately suffer from high heap footprint, even compared to the more throughput-focused GC modes in .NET and GC implementations in JVM. But no, developers are insistent on parroting quotes said 10 to 15 years ago instead of at least attempting to assess technologies on their merits of today.

"colorless" (which is always a lie)

No, colourless isn't a lie. Erlang doesn't have two types of function (sync and async). C#/Python now do. As a function/method implementer in those languages, for every single function implementation, I am faced with the following:

1. My function has to be async, because someone, somewhere, in the call chain of functions I want to invoke, decided to make their function async. So I have to deal with the downsides (lower performance, debugging complexity) whether I need the upsides or not.

2. I need to decide whether to make my function async because the decision hasn't been taken out my hands somewhere down the call stack. If I'm writing a library function that means I'm now having to judge how callers will use my function. If I decide async, then I've imposed constraints on the caller as per #1.

3. I need to implement sync & async versions of my function so as not to constrain the choices of my callers.

That emerges because of (1) the asymmetric constraint imposed by async/await and (2) the requirement for function implementers to make the decision, not callers. A sync function can't call an async one. That's the basis of colouring. It's not a lie.

the article about function coloring is actively harmful to the industry

No, the article about colouring very clearly explains the asymmetric nature of sync/async and the limitation it imposes. Its use of colour as a metaphor very clearly illustrates the issue. Sure, there's a risk that some people won't fully read and understand it - and then just parrot "yeah, coloured is bad". That's not the fault of the article though.

There's nothing inherently wrong with Futures as a concurrency construct: it's essentially enabling cooperative multitasking. The issue is that async/await as an implementation causes codebase bifurcation.

all BEAM-based languages are comparatively slow

Emphasis on "comparative". I don't disagree that in certain dimensions - notably raw compute performance and memory usage - BEAM languages are comfortably down the benchmark tables compared to C#. That doesn't always translate to real world practicality though (and even throughput is getting better given the active JIT work).

developers are insistent on parroting quotes said 10 to 15 years ago instead of at least attempting to assess technologies on their merits of today.

Futures have merit per above. Async/await brings syntactic convenience which, in isolation, is an improvement. But with it comes significant cost. Cost that isn't there with green threads and isn't intrinsic to Futures either. You can't just sweep those limitations under the carpet by promulgating the hubris that the arguments are old.

Do you have experience with C#? If yes, how much?

I mean, libuv is entirely incomparable to how async is done in C++ too.

You'd do it like this: https://github.com/boostorg/cobalt or like this: https://github.com/danvratil/qcoro

Interesting thank you!

Setting aside the issue of delays (I agree with that; this is why I started blogging about async Rust again even though I am no longer part of the project), Rust cannot solve the problem except with async because of its prior commitment to "the C runtime." I've written about this in other posts, but this comment from PhantomZorba on lobste.rs describes the situation succinctly:

Async style language features are a compromise between your execution model being natively compatible with the 1:1 C ABI, C standard library, and C runtime and a M:N execution model. C++ async suffers from the same issues, except it’s not as strict in terms of lifetime safety (not a good thing). The cost for the native compatibility with the C/system runtime is the “function coloring” problem.
Go, Haskell, and I assume Erlang make the other compromise. They eschew the C ABI and runtime completely and implement their own standard library. All code ends up being color-clean. The cost is that integrating with code outside their ecosystem is complex and slow.

https://lobste.rs/s/jkct2m/avoid_async_rust#c_0dqqlv

100%! I'm so glad someone sees it for how it is.

At least some of the language features motivated by async are also useful in other places, e.g. RFCs 3425 and 2033

And the "bit more wasteful" part is a non-starter because people want to use async in embedded contexts.

https://www.oreilly.com/library/view/parallel-and-concurrent... explains how Haskell does async exceptions and cancellation, for comparison. I found that the most challenging chapter of The Concurrency Book, but even so it feels like a solved problem in Haskell (especially once you've read the next chapter, on stm)

I feel the same. At least, author has been transparent about the infra-project-gone-off-the-rails vibe, see https://without.boats/blog/a-four-year-plan/ :

For those who don’t know, there was a big debate whether the await operator in Rust should be a prefix operator (as it is in other languages) or a postfix operator (as it ultimately was). This attracted an inordinate amount of attention - over 1000 comments. The way it played out was that almost everyone on the language team had reached a consensus that the operator should be postfix, but I was the lone hold out. At this point, it was clear that no new argument was going to appear, and no one was going to change their mind. I allowed this state of affairs to linger for several months. I regret this decision of mine. It was clear that there was no way to ship except for me to yield to the majority, and yet I didn’t for some time. In doing so, I allowed the situation to spiral with more and more “community feedback” reiterating the same points that had already been made, burning everyone out but especially me.
a bit more wasteful

The whole point of Rust is to not be a bit more wasteful

I know that Zig is quite different from Rust and it is less mature, but I do wonder how its async compares.

Check out the Q&A in this video to hear it from the man himself, it's the first question: https://www.youtube.com/watch?v=5eL_LcxwwHg

The tl;dr is: "The previous async approach ended up not working, and had to be removed. It's currently an incredibly hard problem with no clear rodemap. The plan is to get there eventually."

Any more details about why the previous async approach ended up not working? and/or what that approach even was?

I have never used it directly, take what I say with a grain of salt.

As far as I know at least part of the idea was to eliminate the function coloring problem by letting the compiler do some nifty compile-time deductions. This had some issues (I don't know if this is still planned, it seems like the kind of thing that should not work in practice). Additionally, there were all sorts of hard technical issues with LLVM, debugging, etc.

I recommend checking the issue tracker, eg. https://github.com/ziglang/zig/issues/6025

I personally don't understand the domain well enough at all, but honestly, I feel like (if possible) Zig should try to double down on its allocator approach.

Instead of trying to use some compile-time deduction magic explicitly pass around an "async runtime/executor" struct which you explicitly have to interact with. Why not?

Interesting analysis. I tend to agree on the complexity budget.

That being said, even if we ignore wastefulness, have you tried async programming in OCaml or in Haskell? You immediately enter a CPS/monadic nightmare that makes programming way more complicated, debugging extremely hard and doesn't deal too well with errors.

These are the hard async problems that Rust is attempting to solve. Performance isn't the main blocker here.

Does it? I haven't used much ocaml and I haven't used Haskell in a while, but as I remember it all IO is already non blocking in Haskell, and the async library gives you the most painless async experience I've ever seen in any ecosystem. And for OCaml, you have explicit binds but recover neat do notation with let*?

You may be right. I haven't used OCaml or Haskell in a while, either. Last time I did concurrency in OCaml, there was no such thing as `let*` (you had to use Camlp4 to achieve anything like this), so it's entirely possible that the user experience has improved. As for Haskell, I mostly remember the complexity of getting anything like a reasonable error handling through the IO monad.

AboutSource Built by g1lg1l

Hackerly is an independent reader for Hacker News, built on the public HN API. Not affiliated with Y Combinator.