4 ms·
Here's one of my go-to examples. This is spawning 10 threads, each of which appends some characters to a shared string. First the C++ version: shared_ptr<p
by oconnor663 5y ago
Here's one of my go-to examples. This is spawning 10 threads, each of which appends some characters to a shared string. First the C++ version:
shared_ptr<pair<mutex, string>> my_pair =
make_shared<pair<mutex, string>>();
vector<thread> thread_handles;
for (int i = 0; i < 10; i++) {
thread thread_handle([=] {
lock_guard<mutex> guard(my_pair->first);
my_pair->second += "some characters";
});
thread_handles.push_back(std::move(thread_handle));
}
for (auto &thread_handle : thread_handles) {
thread_handle.join();
}
And now the exact same code in Rust:
let my_string: Arc<Mutex<String>> =
Arc::new(Mutex::new(String::new()));
let mut thread_handles = Vec::new();
for _ in 0..10 {
let arc_clone = my_string.clone();
let thread_handle = thread::spawn(move || {
let mut guard: MutexGuard<String> =
arc_clone.lock().unwrap();
guard.push_str("some characters");
});
thread_handles.push(thread_handle);
}
for thread_handle in thread_handles {
thread_handle.join().unwrap();
}
===== Highlighting some visible differences between these two examples =====
- C++ doesn't actually need shared_ptr here. It would be happy to access a string and mutex on the caller's stack, and the only reason I didn't do it that way here was to keep the behavior as close as possible to the Rust code. However, Rust requires the reference-counted smart pointer (Arc = "atomic reference counted"), to avoid holding direct references to objects on the callers stack that might hypothetically not live long enough. Basically, Rust doesn't understand that the join loop makes it safe.* If you want to access caller stack variables from threads in Rust, there are ways to do it (see Rayon or Crossbeam), but the basic std::thread::spawn won't let you.
- Mutex in Rust is a container of one element, kind of like shared_ptr and Arc are. The MutexGuard that you get from it is also a smart pointer to the contained element. You dereference it to access the String on the inside, here implicitly with the `.` operator. This is a big part of Rust's safety story: it's syntactically impossible to access the String without locking.
- Because C++ copy constructors are implicit, moving `my_pair` into the closure invokes its copy constructor and bumps its reference count. But the equivalent syntax in Rust does a bitwise move, which doesn't run any type-specific code. So to get the effect of a type-specific copy constructor in Rust we use the explicit `.clone()` method, which bumps the reference count of our Arc. (If we had forgotten this and tried to move the original, it would be a compiler error, because Rust moves are destructive, and the compiler knows the for-loop will move more than once.)
===== My thoughts about this =====
- This Rust code is genuinely difficult for beginners to write, no doubt about it. You really have to familiarize yourself with all the relevant library types before you can get threading code to compile.
- That said, there are so many mistakes we could make in either case, and all of them are compiler errors in Rust. For example we could forget to use a lock entirely, initialize our lock guard inappropriatey, or accidentally take a shared/read lock when we meant to take a unique/write lock. Or perhaps(*), we might throw/panic before completing all the joins. Those mistakes will compile in C++ and fail TSan (or abort) at runtime, but they won't compile in Rust.
- I think the scariest mistake here might be accidentally keeping a pointer to the shared string past the point where you unlock. For example, maybe we pass the string to some method that implicitly takes a string_view of it, and stashes that view for later. Rust catches this too! It knows that references to the string borrow the MutexGuard, and it will not let them live past the point where the MutexGuard is destructed.
- I think it's interesting to ask why Mutex isn't a container in other languages. (Or at least, in other languages that support generic containers.) I think the previous bullet is the answer. It's nice to get a syntactic guarantee that you've locked the Mutex before you touch its contents, but when there's nothing stopping you from keeping a reference too long, you can't actually make the strong guarantees that you want to at compile time.
- oconnor663 5y agoHere's a runnable version of the Rust code, with some type annotations removed: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=4d68d3972b6b89a82baf621922480917 https://play.rust-lang.org/?version=stable&mode=debug&editio... And here are a few broken versions to show the compiler errors you see when you make mistakes: - Not using Mutex at all: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=b17ef279b82a775ed6da5dcc69ceeeaf https://play.rust-lang.org/?version=stable&mode=debug&editio... - Replacing Mutex with RwLock, and then taking a read lock instead of a write lock: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=a283aa367774440058e51dfc9b7c85a3 https://play.rust-lang.org/?version=stable&mode=debug&editio... - My favorite, trying to retain a reference past unlocking: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=359462a6bc6893c6378964bd32344ae4 https://play.rust-lang.org/?version=stable&mode=debug&editio... That last example is the most condensed picture of "what Rust is all about" that I know of. Rust itself has no idea what a Mutex (or a thread) is. Mutex is just some library type. But it's able to use the borrow checker to enforce a high-level guarantee, that the contained object is never accessed without holding the lock. This guarantee is checked at compile time, with no runtime overhead. Ownership, borrowing, and lifetimes are about more than just memory safety: library authors can use them to express invariants.
- tene 5y agoYou mention this, but I want to clarify that you only need reference counting due to using only std, not due to limitations in the language. Scoped threads are available in crossbeam, and they're pretty great, as sometimes it's convenient to borrow something from the stack. Here's how I'd write that with scoped threads: let my_string = Mutex::new(String::new()); crossbeam::scope(|s| { for _ in 0..10 { s.spawn(|_| { let mut guard = my_string.lock().unwrap(); guard.push_str("some characters"); }); } }).unwrap();
- oconnor663 5y agoYeah Crossbeam is awesome. And the pre-1.0 story about a similar interface that existed in std, until it was found to be unsound, is totally fascinating: https://github.com/rust-lang/rust/issues/24292 https://github.com/rust-lang/rust/issues/24292. (Prior to that, std::mem::forget was marked unsafe!) Another detail that's worth clarifying for folks reading along: The API in this example will automatically join all threads at the end of the crossbeam::scope. (That is indeed what makes it safe.) So if your goal is to fire off a background thread that will outlive the current function, you usually need to go back to std::thread::spawn and live with its 'static requirement.