Arc and Mutex in Rust
itsallaboutthebit.com
itsallaboutthebit.com
Well, maybe there is some truth to it, but I want to say that having started learning Rust in 2022, I found it's multi-threadedness model very refreshing. I thought it was such a delight to write code in, and personally loved it at least one order of magnitude than Go's goroutines. It's safe, embedded in type system (Send etc), has tons of standard tools (Arc, Mutex, mspci channel etc...) that are hard to shoot your foot with.
When people talk about Rust's complicated nature, I wish more emphasis is made on how it completely eliminates entire categories of bugs even within a multi-threaded context. As a corrolary, it really makes some parts of programming hard in other languages, nearly trivial in Rust.
I've been doing Rust for about two years. This difficulty eventually subsides and writing "safe" code becomes second-nature. I rarely wrestle with the compiler any more.
It's a real good feeling when you can finally write 100 lines or so that rustc takes on the first try. Even if it was all comments...
The experience now that futures are in the standard library and tokio etc are actually stable is much better. Most async libraries are thread-safe, contrary to the JavaScript or Python asyncio worlds, so you are free to call call block_on(func(...)) in whatever thread if that is your preferred coding style and the library doesn't already expose a blocking interface. The low-level "zero-cost" future composition is available to you if you need to squeeze a lot of performance but you absolutely don't have to wrestle with the "monstrosity" if you don't want to.
I can understand they chose this path to gain market share by having tons of web services written in Rust. And async might be great fit for it. But now one can't just go around and say 'Oh, Rust async is not for plebs they can continue with something much simpler to fulfill their needs'.
And one thing in your stack is async, everything else ends up following.
Rust's problem with async isn't that async itself is a bad idea. The problem is that rust got halfway through adding async support and then didn't finish the job.
If you have half a house, your problem isn't that houses are a bad idea. You just need a builder. (Or, a wrecking ball. Which I'd be more than happy with at this point.)
I doubt that would be necessary or appropriate. A lot of thought, discussion, and work has already gone into the design, and I'm inclined to assume that the people involved knew what they were doing. Rust's zero-overhead approach to async is much more ambitious than the async support in other languages. More work on the interactions with other language features would certainly be useful, but I think suggesting that the existing implementation be discarded is more likely to frustrate the people who have been doing all of this hard work than to accomplish anything useful.
To be fair, many of those newcomers have heard that threaded code is very hard to get right (which is repeated by so many people these days), and/or come from the JS world where async is pretty much the only choice you have. This is something that may be partially fixed by more communication. For example, the Rust book currently doesn't cover async at all. Adding a chapter on async and when to use it might help. Another issue is that lots of people come to Rust from/for webdev. The most popular web frameworks (actix-web, rocket, ...) are all using async. I don't have any idea on how to fix this.
If what you say is true, that "It's impossible that async could ever be easier than threading", then I think that's something that people should know and tell other people.
The alternative to "async" is non-blocking IO and an event loop written by hand, possibly with some threading thrown into the mix for certain tasks. This is exactly what the async runtime is doing behind the scenes, and what languages without first class async support have to do to support this form of concurrency.
Doing an event loop manually is probably going to end up being more difficult and less safe than using the language level async abstractions.
Sometimes just spawning threads is the best solution, but it's fairly wasteful to spawn threads for something like IO, especially if there are many operations in flight (1000+).
If you look at Rust from this perspective then it becomes much more valuable to consider learning because it teach you a different way to modelling your data, and how to break-up your code; and while a language like Java may not force you to consider the borrow checker you will probably be a better overall Engineer knowing how to make it happy even when working in other languages.
On the topic discussed in the actual article: I can't wait for scoped threads to finally go stable since being forced for throw an Arc around almost everything you want to share between threads does not really suit Rust which really emphasise zero-copy in their standard library.
Rust's ecosystem pushes users toward using async for IO, and async support in the language & compiler is wildly incomplete. And it has been for years - the last big async feature (the async keyword) landed 2.5 years ago [1]. But its barely usable as-is. Building software with async today feels like trying to build a house out of daggers. Traits? Nope. Closures? Nope. Regular types? Only if they're Pinned. Your types are pinned, right? Iterators over async objects? Nope that doesn't work. Library support? Without traits, libraries are limited in what they can provide. Async rust? Batteries are not included.
We need TAIT, GAT, async closures, async iterators / streams, and a bunch of other little quality of life improvements before I'd consider it "done". I don't know where async's momentum went, but its a bit disappointing how halfbaked async is given how nice the rest of the language is.
Most Rust applications I've worked on which have used some kind of parallelism/concurrency would be fine with a thread pool of workers and good old sync channels.
Agree.
And the problem is not (mainly) async, the borrow checker, etc.
Rust is truly different to other "imperative looking, functional looking" languages. I suspect the choice of make it look too close to C is part of the issue: You come to it and quickly start producing code as if it were another C/python/C# and that is a major mistake.
In specially, because Rust make SO MUCH SO EASIER... but not the things that other langs make easier (use of `String` anyone?), so is a major shock to see that supposedly "simple" things "not work".
Is only after getting that the core of Rust is `Struct Enum Pattern Matching Moves & Borrows`, and pls, stick all together with this `Traits` & composition, that are part of this important `Paradigms` is when Rust click.
This is the shock: Rust is not about the use of some `types` and then apply algorithms. Is the MODELING of behaviours (traits) and the INTERACTION of the types/traits.
That is the mind-bending. Is like see programming flipped!
Note that this is only for lexical scopes so each set of scoped threads you create must be destroyed in the same lexical scope.
In practice, this means that thread pooling (and in particular async runtimes) cannot use them.
In fact, we used to have the closure passed to thread and join handle had a lifetime param 'a (not 'static), but this was unsound in combination with Arc etc that didn't require those. If you're interested, it's related to the drop guarantees and safety of `mem::leak`. There was a big debacle right before 1.0 and, imo, the team rushed this decision and we're paying for it with Arcs - any async code base is riddled with it.
Moving to rust, a lot of the struggle I've experienced has come from trying to write rust the way I would javascript - by using rust's async primitives. Async support in rust is complex, immature and incomplete. And its been this way for years.
For a project a few years ago, I tried making an HTTP server which supported a custom SSE-style endpoint. The resulting code was atrocious - it was long, complicated, and I needed to invoke deep lifetime magic that I still don't fully understand to make it compile. I just want to implement both sides of an async stream - but no; thats apparently too hard. I got hundreds of lines in, and the code still wasn't finished. I rewrote it in about 20 lines of javascript and it works great.
I've decided to stay away from async rust until the language & ecosystem mature. Synchronous rust is a delight. I'm going to need to do more IO soon, and I'm dreading it. I don't really want to use threads, but I think it might be the right choice given the current state of async rust.
If you stick on the golden path then the compiler has your back, deviate, and then you are stuck in a deep hell of compiler suggestions that don't pan out. One observation I've had is that complex lifetime problems are usually solved by copy's or the use of unsafe in most production code. I would love to see a future equivalent of "clean code" for Rust, as the current situation is reminiscent of early java programming.
The problem is that async code doesn't interact properly with the rest of the rust language. You can't make async traits, async iterators or streams, or use async closures. The problem isn't async. Its that the rest of the language is apparently (still!) incompatible with async code.
All of this stuff is "coming" - and by that I mean, there have been RFCs kicking around since 2017 aiming to fix these problems. Blergh.
Eg:
GATs: https://github.com/rust-lang/rust/issues/44265 (This one might land soon!)
Async closures: https://github.com/rust-lang/rust/issues/62290 (seems to have stalled)
TAIT: https://github.com/rust-lang/rust/issues/63063 (seems to have stalled)
And so on.
This is wrong. Async traits and iterators will be huge improvements to the language, and there is more in progress.
Can you provide examples of copy/unsafe being required for complex problems?
If you control the hardware, then you need to compare the dollar cost of using a GC language and just throwing more hardware at the problem, vs the dollar cost of using Rust and throwing more developer time at the problem.
Absolutely true. This is the submerged part of the iceberg for any programming language. Whenever I see a new language, I skip the hello worlds and the syntax sugar and go straight to see how they solved concurrency and parallelism. Chances are you're gonna need concurrency. If you do, it'll likely be the largest source of complexity in your program at large. Sound concurrency is paramount, and a huge challenge for PL designers.
Rust has solved some major problems and deserves recognition for that. It is already influencing new languages, and we will be better off in the long term. That said, in order to achieve their extremely ambitious goals, they needed to push the type system to its absolute limits, and in a painfully user-facing way. When you are writing async Rust today, you're spending a large part of your limited attention/complexity budget trying to satisfy the compiler, even when your mental model of the business logic is sound. This is what people mean when they say async Rust is hard.
Not to say anything about C++, python and pre-async JS, but if you compare with async in modern languages and especially if you compare with Go, async Rust leaves a lot to desire imo, mostly in terms of ergonomics and total system complexity.
> but it's not unnecessarily complicated: basically everything is there for a reason
Yes and no. Let me be careful with my words here. Everything is there for a reason, which is always locally coherent. However, globally speaking, these reasons can get more and more detached from the end goal, including the core principles of Rust such as zero-cost and fearless concurrency. I believe this is exactly what happened to async Rust.
A simple example would be lack of async traits. Traits are one of Rust's best features, and everyone has to work around them, meaning that you have to think differently about the same problem depending on if you're in async vs not.
Another example is implementing the Future trait (implementing a trait is standard practice in Rust). Only this often requires awareness around auto traits like Send, HRTBs and pin projection, (which happens to be one of the most complex things I've done), and all I'm trying to do is wrap some user provided future. These deep topics are more of a convoluted type system puzzle (and if you like those it can be fun). But it has very little to do with concurrency and much less with whatever business logic we're trying to manage.
Concurrency does automatically not imply async outside of Javascript.
I am in the camp that async/await in Rust is a mistake.
There are certain things like "Concurrent Data Structures" that don't even have well-defined semantics in a non-GC language. I think "async" is in a similar category.
I return to Swift/Kotlin for new UIs and wrestle with their errors.
Man, "wrestling the compiler" in Rust is SOOOO much better!
... and this is even considering very simple things (like see how they choke in simple syntax error with so small help provided)
I've seen programmers fight the borrow checker a fight they can't win, because `Arc<Mutex<Object>>` has a non-zero overhead and looks uglier than a plain `Object` or `&Object`. However, if the program has object lifetimes that aren't statically known and/or shared mutable state, these are simply the correct types to use (e.g. event-based systems that can attach listeners have to use these). Rust is just being very explicit about ownership and putting all overheads in your face. In something like Python there's no extra syntax for this, because every object is automatically under a lock and has a refcount.
let user = todo!();
let handle: JoinHandle<User> = spawn(move || {
// do stuff with user
// hand back user
user
});
// wait for thread to stop and retake ownership of user
let user = handle.join();
I used this recently to remove locking in a worker thread and it was a big performance boost, and made the code nicer -- win/win.I've also found Rust to be hard until I got a decent understanding of the borrow checker and how it works.
After that, the only times I found Rust to be hard were when I was trying to make it conform to my way of thinking, rather than changing how I approached the problem to match the way Rust was designed. I've had a similar experience in other complex and well-designed languages such as Clojure, Erlang, and Haskell.
In my experience the benefits of Rust (safety-guarantees, speed, good ease of use vs complexity tradeoffs) much outweigh the quirks and learning curve.
The compiler could have a special case for looking at joins. But that's extra complexity that gets you almost nothing in return. Instead, Rust developers decided that it's better to do it in a library and created the scoped thread the article talks about.
That comes from the undecidability of any interesting property in Turing complete code. It means that if you are doing static analysis on your code, you have to decide if you are going with false errors or mark bad code a correct, because no analysis can always give you the correct result.
On this case, you get a false error.
The most common exception to this is when you implement a type that has a `!Send` attribute, but you still mark it as `Send`, because you implement an API that guards against misusing the type. I honestly can't think of any practical example of the opposite (ie. you have a type with all `Send` attributes), but that would probably be similar: you have a type with all `Send` attributes, but you implement some kind of API that makes it unsafe to sent the type between threads.