Data-Race Protection
Rust makes the bold claim that it offers "fearless concurrency" and no more data-races (within a program; it can't do much about remote calls). That's a very bold claim, and one I've found to be true so far---I'm much more likely to contemplate writing multi-threaded (and async) code in Rust now that I understand how it prevents me from shooting myself in the foot.
An Example of a Data Race
Here's a little modern C++ program with a very obvious data-racing problem (it's in the cpp/data_race
directory):
#include <thread>
#include <iostream>
int main() {
int counter = 0;
std::thread t1([&counter]() {
for (int i = 0; i < 1000000; ++i) {
++counter;
}
});
std::thread t2([&counter]() {
for (int i = 0; i < 1000000; ++i) {
++counter;
}
});
t1.join();
t2.join();
std::cout << counter << std::endl;
return 0;
}
The program compiled and ran without any warnings (although additional static analysis programs would probably flag this).
The program fires up two threads. Each loops, incrementing a counter. It joins the threads, and prints the result. The predictable result is that every time I run it, I get a different result: 1015717, 1028094, 1062030 from my runs.
This happens because incrementing an integer isn't a single-step operation:
- The CPU loads the current counter value, into a register.
- The CPU increments the counter.
- The CPU writes the counter back into memory.
There's no guaranty that the two threads won't perform these operations while the other thread is also doing part of the same operation. The result is data corruption.
Let's try the same thing in Rust. We'll use "scoped threads" (we'll be covering threading in a later session) to make life easier for ourselves. Don't worry about the semantics yet:
fn main() { let mut counter = 0; std::thread::scope(|scope| { let t1 = scope.spawn(|| { for _ in 0 .. 1000000 { counter += 1; } }); let t2 = scope.spawn(|| { for _ in 0 .. 1000000 { counter += 1; } }); let _ = t1.join(); let _ = t2.join(); // let _ means "ignore" - we're ignoring the result type }); println!("{counter}"); }
And now you see the beauty behind the "single mutabile access" rule: the borrow checker prevents the program from compiling, because the threads are mutably borrowing the shared variable. No data race here!
Atomics
If you've used std::thread
, you've probably also run into atomic types. An atomic operation is guaranteed to be completed in one CPU operation, and optionally be synchronized between cores. The following C++ program makes use of an std::atomic_int
to always give the correct result:
#include <thread>
#include <iostream>
#include <atomic>
int main() {
std::atomic_int counter = 0;
std::thread t1([&counter]() {
for (int i = 0; i < 1000000; ++i) {
++counter;
}
});
std::thread t2([&counter]() {
for (int i = 0; i < 1000000; ++i) {
++counter;
}
});
t1.join();
t2.join();
std::cout << counter << std::endl;
return 0;
}
Rust gives you a similar option:
This code is in
projects/part2/atomics
use std::sync::atomic::Ordering::Relaxed; use std::sync::atomic::AtomicU32; fn main() { let counter = AtomicU32::new(0); std::thread::scope(|scope| { let t1 = scope.spawn(|| { for _ in 0 .. 1000000 { counter.fetch_add(1, Relaxed); } }); let t2 = scope.spawn(|| { for _ in 0 .. 1000000 { counter.fetch_add(1, Relaxed); } }); let _ = t1.join(); let _ = t2.join(); // let _ means "ignore" - we're ignoring the result type }); println!("{}", counter.load(Relaxed)); }
So Rust and C++ are equivalent in functionality. Rust is a bit more pedantic---making you specify the ordering (which are taken from the C++ standard!). Rust's benefit is that the unsafe version generates an error---otherwise the two are very similar.
Why Does This Work?
So how does Rust know that it isn't safe to share an integer---but it is safe to share an atomic? Rust has two traits that self-implement (and can be overridden in unsafe code): Sync
and Send
.
- A
Sync
type can be modified - it has a synchronization primitive. - A
Send
type can be sent between threads - it isn't going to do bizarre things because it is being accessed from multiple places.
A regular integer is neither. An Atomic integer is both.
Rust provides atomics for all of the primitive types, but does not provide a general Atomic wrapper for other types. Rust's atomic primitives are pretty much a 1:1 match with CPU intrinsics, which don't generally offer sync+send atomic protection for complicated types.
Mutexes
If you want to provide similar thread-safety for complex types, you need a Mutex. Again, this is a familiar concept to C++ users.
Using a Mutex in C++ works like this:
#include <iostream>
#include <thread>
#include <mutex>
int main() {
std::mutex mutex;
int counter = 0;
std::thread t1([&counter, &mutex]() {
for (int i = 0; i < 1000000; ++i) {
std::lock_guard<std::mutex> guard(mutex);
++counter;
}
});
std::thread t2([&counter, &mutex]() {
for (int i = 0; i < 1000000; ++i) {
std::lock_guard<std::mutex> guard(mutex);
++counter;
}
});
t1.join();
t2.join();
std::cout << counter << std::endl;
return 0;
}
Notice how using the Mutex is a two-step process:
- You declare the mutex as a separate variable to the data you are protecting.
- You create a
lock_guard
by initializing the lock withlock_guard
's constructor, taking the mutex as a parameter. - The lock is automatically released when the guard leaves scope, using RAII.
This works, and always gives the correct result. It has one inconvenience that can lead to bugs: there's no enforcement that makes you remember to use the lock. You can get around this by building your own type and enclosing the update inside it---but the compiler won't help you if you forget. For example, commenting out one of the mutex locks won't give any compiler warnings.
Let's build the same thing, in Rust. The Rust version is a bit more complicated:
This code is in
projects/part2/mutex
use std::sync::{Arc, Mutex}; fn main() { let counter = Arc::new(Mutex::new(0)); std::thread::scope(|scope| { let my_counter = counter.clone(); let t1 = scope.spawn(move || { for _ in 0 .. 1000000 { let mut lock = my_counter.lock().unwrap(); *lock += 1; } }); let my_counter = counter.clone(); let t2 = scope.spawn(move || { for _ in 0 .. 1000000 { let mut lock = my_counter.lock().unwrap(); *lock += 1; } }); let _ = t1.join(); let _ = t2.join(); // let _ means "ignore" - we're ignoring the result type }); let lock = counter.lock().unwrap(); println!("{}", *lock); }
Let's work through what's going on here:
let counter = Arc::new(Mutex::new(0));
is a little convoluted.- Mutexes in Rust wrap the data they are protecting, rather than being a separate entity. This makes it impossible to forget to lock the data---you don't have access to the interior without obtaining a lock.
Mutex
only provides theSync
trait---it can be safely accessed from multiple locations, but it doesn't provide any safety for sending the data between threads.- To gain the
Send
trait, we also wrap the whole thing in anArc
.Arc
is "atomic reference count"---it's just like anRc
, but uses an atomic for the reference counter. Using anArc
ensures that there's only a single counter, with safe access to it from the outside. - Note that
counter
isn't mutable---despite the fact that it is mutated. This is called interior mutability. The exterior doesn't change, so it doesn't have to be mutable. The interior can be changed via theArc
and theMutex
---which is protected by theSync+Send
requirement.
- Before each thread is created, we call
let my_counter = counter.clone();
. We're making a clone of theArc
, which increments the reference count and returns a shared pointer to the enclosed data.Arc
is designed to be cloned every time you want another reference to it. - When we start the thread, we use the
let t1 = scope.spawn(move || {
pattern. Notice the move. We're telling the closure not to capture references, but instead to move captured variables into the closure. We've made our own clone of theArc
, and its the only variable we are referencing---so it is moved into the thread's scope. This ensures that the borrow checker doesn't have to worry about trying to track access to the same reference across threads (which won't work).Sync+Send
protections remain in place, and it's impossible to use the underlying data without locking the mutex---so all of the protections are in place. let mut lock = my_counter.lock().unwrap();
locks the mutex. It returns aResult
, so we're unwrapping it (we'll talk about why later). The lock itself is mutable, because we'll be changing its contents.- We access the interior variable by dereferencing the lock:
*lock += 1;
So C++ wins slightly on ergonomics, and Rust wins on preventing you from making mistakes!
Summary
Rust's data race protection is very thorough. The borrow-checker prevents multiple mutable accesses to a variable, and the Sync+Send
system ensures that variables that are accessed in a threaded context can both be sent between threads and safely mutated from multiple locations. It's extremely hard to create a data race in safe Rust (you can use the unsafe
tag and turn off protections if you need to)---and if you succeed in making one, the Rust core team will file it as a bug.
All of these safety guarantees add up to create an environment in which common bugs are hard to create. You do have to jump through a few hoops, but once you are used to them---you can fearlessly write concurrent code knowing that Rust will make the majority of multi-threaded bugs a compilation error rather than a difficult debugging session.