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.