Hacker News new | ask | show | jobs
by klabb3 1014 days ago
I mean.. I appreciate that there are proponents and people trying to improve the state of async rust but to allude that everything is dandy is either dishonest or more likely a strong curse of knowledge bias.

I’ve worked deeply in an async rust codebase at a FAANG company. The vast majority chooses a dialect of async Rust which involves arcs, mutices, boxing etc everywhere, not to mention the giant dep tree of crates to do even menial things. The ones who try to use proper lifetimes etc are haunted by the compiler and give up after enough suffering.

Async was an extremely impressive demo that got partially accepted before knowing the implications. The remaining 10% turned out to be orders of magnitude more complex. (If you disagree, try to explain pin projection in simple terms.) The damage to the ecosystem from fragmentation is massive.

Look, maybe it was correct to skip green threads. But the layer of abstraction for async is too invasive. It would have been better to create a “runtime backend” contract - default would be the same as sync rust today (ie syscalls, threads, atomic ops etc – I mean it’s already half way there except it’s a bunch of conditional compilation for different targets). Then, alternative runtimes could have been built independently and plugged in without changing a line of code, it’d be all behind the scenes. We could have simple single-threaded concurrent runtimes for embedded and maybe wasm. Work stealing runtimes for web servers, and so on.

I’m not saying it would be easy or solve all use-cases on a short time scale with this approach. But I do believe it would have been possible, and better for both the runtime geeks and much better for the average user.

5 comments

> Try to explain pin projection in simple terms.

Pin projection is the proccess of getting a pinned reference to a struct's field from a pinned reference to the whole struct. Simple concept, but the APIs currently on offer for it (`unsafe` code or macro hackery) are very subpar.

Your position wrt green threads sounds like Graydon's (https://graydon2.dreamwidth.org/307291.html).
Yeah, perhaps. But I am not part of that minuscule subset of people who have deep expertise in both compiler internals and runtime architecture to have well founded opinion on the design. There is FFI and stack issues that’d need some incredibly bright engineers to sort out.

My argument is more along the lines of: modularity is the (only) way to reduce complexity. We already have modular runtimes in other languages (project loom in Java, webassembly etc). Most people should not care about runtimes much. The ecosystem cost of async ended up being high. Thus, runtimes should be an implementation detail for most users.

Doesn’t mean Rome has to be rebuilt. Perhaps the async we have can be saved, but even so it involves biting the apple of actually defining precisely what a runtime is so that crate authors can think of them just like they think of allocators today (ie not at all).

I do tend to agree with you, but just to note that both the approaches you listed for this are more recent than the decisions rust made on this. It's not "good approaches to modular runtimes were already rock solid, why didn't they consider them?". It's "people have done promising work on this in the last decade, maybe rust could figure out how to incorporate it in some way moving forward".
Oh for sure. Armchair pointing in hindsight is trivial, or at least easy. The folks who fleshed this out at such an early stage did an extremely impressive job.
What an amazing article. I can't believe I missed it when he wrote it.

I followed Rust in the very early days and definitely came away with the sense in this article. I would have said (and may have said to some people) that Graydon is really great, but that the exciting things about Rust weren't the things he liked or cared about; basically the expressivity and zero cost abstractions sections of this article.

But reading the article he linked about first class modules, I think that seems pretty good, and I think he's definitely right about making borrowing "second class" without explicit lifetimes (or at least discouraging them more so than the language does today), and about existential types (I'm always surprised I don't see these more in library APIs).

I also had no idea he wanted built in bignums. In pre-1.0 (and pre-cargo) rust, I created a very incomplete library for that, and would have loved to have it built in instead. Also yeah, decimal literals would be excellent.

But I didn't find the async vs. green threads section convincing. The green thread implementation wasn't a great fit at the time it existed, and I haven't seen anything since then that convinces me there was some great solution available to make it work better. Async isn't great in rust, but it's a much better fit, and I think it can be used well. I have hopes that best practices developing over time and maybe language features or changes can push people in a more sane direction of usage (once it becomes more clear what that should be).

I don't have a degree in CS so I always feel out of my element in these discussions...

Is the runtime something the compiler adds to the binary to make sure it is able to correctly interact with the system it is built for?

It seems like people argue that green threads require a runtime as if async doesn't? I don't understand the arguments on either side. In terms of what code looks like I far prefer being able to just declare green threads like golang does.

Honestly I wish I understood on a deep level, but I've been programming for 17+ years and the fact that I still don't implies to me that I never will.

A Runtime generally refers to "things added to the program to make it work". That can mean libc, it can mean a GC, etc.

> I don't understand the arguments on either side. In terms of what code looks like I far prefer being able to just declare green threads like golang does.

Under the hood `async` is sugar over a function such that the function returns a `Future<T>` instead of a `T`. What is done with that future is up to the caller.

In most cases this is handed off to a runtime (your choice of runtime, generally speaking) that will figure out how to execute it. You could also manually poll the future until it's complete, which does happen sometimes if you're manually implementing the Future trait.

If you have no async code you can simply avoid having an async runtime altogether, reducing the required runtime for an arbitrary program.

> I far prefer being able to just declare green threads like golang does

This relies on an implicit runtime. That's fine - lots of Rust libraries that work the way you're suggesting will just assume a runtime exists.

That lets you write:

    spawn(async {println!("hello from async");});
And, just like a goroutine, it will be scheduled for execution by the implicit runtime (or it will panic if that runtime is not there).

Note that this implicit runtime has to be there or you'll panic. This means that the reasonable behavior would be to always provide such a runtime, which would mean that even "sync" programs would need it. Or otherwise you'd need to somehow determine that no "async" code is ever actually called and statically remove it. That is a major reason why you wouldn't want this model in a language that tries to minimize its runtime.

> but I've been programming for 17+ years and the fact that I still don't implies to me that I never will.

I think it's just a matter of exposure. Try writing in more languages like C, C++, Rust, etc, and dig into these features.

> That's fine - lots of Rust libraries that work the way you're suggesting will just assume a runtime exists.

Is this a new development? Last time I checked, every library seemed to be tied to a specific runtime (usually tokio).

That's what I'm saying. When a library uses "spawn" it is generally assuming a runtime, typically tokio (although in my experience a lot of libraries are generic).
I don’t think that has changed.
I don't really understand when you say that spawning async would be just like a green thread (goroutine). I thought they were fundamentally different.
They aren't different. They are the same. The only difference is that in Go the `yield` points are implicit (the compiler inserts them) whereas in Rust they are explicit (.await).

Otherwise they both schedule a task to be executed with the implicit runtime - in Rust that may be tokio or something else, in Go that would be the Go runtime.

I would say they function similarly. There are quite a few internal differences. The main benefit of Rust is that futures are just regular types and don’t need a stack when idle. And the main benefit of Go is that there is only one type of function, so no coloring.

> they both schedule a task to be executed with the implicit runtime

To be pedantic, in rust the runtime is referenced with a global or thread-local variable, but it’s still explicit. This means crate authors can’t spawn tasks without depending on a runtime… unless there’s been recent developments.

Sure, their implementation differs in all sorts of ways. But for the purposes of this conversation, with regards to scheduling a future without having to manually pin it up into a your call graph, they are the same.

> but it’s still explicit.

I guess it just comes down to your definition of explicit. There's a dependency, but from a caller's perspective it's implicit. It doesn't matter though, I think the point is clear enough.

I generally agree, but specific to your point about arcs/mutexes - what would be the alternative for shared mutable data? Or do you mean people use it for stuff that isn't shared too?
> Or do you mean people use it for stuff that isn't shared too?

Exactly. RAII works beautifully in regular Rust, so you create references with the static ownership rules and pass them around, before the value is dropped at a deterministic place. This is like the main value prop of Rust.

In async Rust OTOH (in fact regular threads as well) it’s much harder to use references when they normally would make sense. So instead of `&T` and `&mut T` you need `Arc<T>` and `Arc<Mutex<T>>`, respectively.

Then you lose both on performance (the initial blog post claimed that the pervasive arcing is worse than GC) but also UX. Arcs are much easier to leak, for instance.

> what would be the alternative for shared mutable data?

You can use atomics if the data fits in a machine word. That's a lot faster than a full mutex.

> The vast majority chooses a dialect of async Rust which involves arcs, mutices, boxing etc everywhere

And?

> not to mention the giant dep tree of crates to do even menial things.

Again, And?

I don't really care about having to pull in crates. That has always been how Rust does things - it prefers many small crates over fewer large crates. Async is no different.

And I don't care about Arc either. Writing `let x = blah()` is not much better than `let x = Arc::new(blah())`.

If you're talking about something else, like idk, maintaining mutability across multiple threads, yeah that's going to be more painful. It's also painful in most other languages and is generally avoided for that reason.

> The ones who try to use proper lifetimes etc are haunted by the compiler and give up after enough suffering.

You say "proper lifetimes" as if lifetimes are desirable. In async code they are not - your lifetime is often "arbitrary" and that's what an Arc gives you. The solution is, as mentioned, using an Arc or Box or Mutex.

> Async was an extremely impressive demo that got partially accepted before knowing the implications.

I think this is a totally ignorant characterization of async, which was years in the making, took lessons learned from decades of async in other languages, and was frankly led by some of the most knowledgeable people in regards to these sorts of systems.

> (If you disagree, try to explain pin projection in simple terms.)

The vast majority of people will never have to know what a pin projection is, let alone how it works. It rarely comes up, and virtually only if you're writing libraries. I could explain it but I see no reason to do so here (it is not complicated at all, `Pin` is probably the harder one to explain).

> The damage to the ecosystem from fragmentation is massive.

It's not even noticeable lol like, what? What fragmentation? I've never run into an issue of fragmentation and I've written 100s of thousands of lines of Rust.

> It would have been better to

How nice to sit on the sidelines and throw out a paragraph sized proposal. Everything looks great when you hand wave away the complexity of the problem space.

Async Rust isn't perfect (I frankly don't think there is a "perfect" solution, that should not be contentious I hope) and I welcome criticism, but your post is totally unconstructive and unsubstantial.

I am of the same impression — Rust as a language forces you to come at terms with your own ideas of how code should look.

That being said I still have a deep dislike of having to read through nested Arc Mutexes or whatever to figure out what the code does in principle before I figure out what is going on in detail with the ownership.

I know there are no perfect solutions and there are trade-offs to be made, but I wish there was a way to have it more readable.

So instead of this:

    let s = Arc::new(Mutex::new(Something::new("foo")));
something a bit like this:

    let s = Something::new("bar").arc()
                                 .mutex();
FWIW Arc implements From<T> so you can do `let foo = blah().into()` and if `foo` is passed to something expecting Arc it'll be an Arc. This probably works for Arc<Mutex<T>> as well so you'd be able to do `.into().into()` but I'm not sure.
Iirc you can't chain .into() because the compiler can't be sure whether the first is meant to do the whole conversion and the second is the identity (impl From<T> for T), or if the first does one step and the second the next or if the first is the identity and the second does the whole conversion. (In fact I think the existence of the first and third options alone is enough to preclude chained .into() from working due to the ambiguity.)
You're right.
Looks like you're asking for syntax sugaring for T::new wrapping, e.g. `"bar" |> Something |> Arc |> Mutex`.