Thread Safety in C++ and Rust
Lately I’ve been experimenting with Rust, and I want to report some of what I’ve learned about thread-safety. I am an enthusiastic dabbler in Rust: I spend most of my time in C and C++, but I’m always looking for an excuse to learn more about Rust’s approach to the techniques I use every day in C and C++.
When studying Rust’s threading model, I came to see some correspondence between C++ and Rust terminology that I had not seen published previously. Here are my findings, which hopefully can help people with C++ background understand Rust (or vice-versa).
C++
The C++ standard does not define the term “thread-safe”, but it is common practice now within the C++ community to define it in the following way:
- thread-safe: A type is thread-safe if it is is safe to invoke any of its methods concurrently. To provide this guarantee, a type must generally take some special measures to avoid data races, eg. using a mutex or atomic operations internally. This generally comes with performance and/or complexity costs, so most types will not be thread-safe.
- thread-compatible: A type is thread-compatible if it is safe to invoke
const
methods concurrently. Any concurrent call to a non-const
method must be synchronized by the caller. Most types in C++ are thread-compatible, as this guarantee comes mostly comes for free: it happens naturally for any type that is const-correct (ie. avoidsmutable
members orconst_cast
).
Thread-compatible types compose nicely and avoid synchronization overheads.
Suppose you have 10 thread-compatible objects that you want to access
concurrently together. You can wrap a Mutex
around all 10 and pay only a
single synchronization cost. If you have 10 thread-safe objects, you pay 10
separate synchronization costs as each of them perform their own internal
synchronization. If you are using an object in only one thread, you may not
need synchronization at all, but the thread-safe type won’t know this and will
pay the cost regardless. For all of these reasons, thread-compatible types are
generally preferred.
Here is an example of a thread-safe type in C++. It achieves thread-safety
by using std::atomic
:
// C++ Usage of thread-safe type.
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
// A very simple thread-safe type.
class ThreadSafeCounter {
public:
ThreadSafeCounter() : counter_(0) {}
void Increment() { counter_.fetch_add(1); }
int GetCount() const { return counter_; }
private:
std::atomic_int32_t counter_;
};
int main() {
ThreadSafeCounter counter;
const int n = 10;
std::vector<std::thread> threads;
// Spawn `n` threads that all share a single counter.
for (int i = 0; i < n; i++) {
threads.push_back(std::thread([&counter] {
// Unsynchronized call of a non-const method.
// Only safe because the type is thread-safe.
counter.Increment();
}));
}
for (auto& thread : threads) { thread.join(); }
// This will ultimately print `n`.
std::cout << counter.GetCount() << "\n";
return 0;
}
Rust
Rust’s model for thread-safety has some notable differences. Rust’s thread-safety story centers around two traits:
- The
Sync
trait indicates that a type can be safely shared between threads. - The
Send
trait indicates that a type can be safely moved between threads.
The Sync
trait ends up mapping closely to the C++ concept of
thread-compatible. It indicates that concurrent access of a type is safe, as
long as neither of the concurrent operations operates on a mutable reference.
Just as most types in C++ are thread-compatible, most types in Rust are Sync
.
However, Rust and C++ differ far more when we talk about thread-safe types. Rust forbids shared mutable access at the language level. That means that the C++ way of modeling thread-safety won’t work at all in Rust. Even if we tried to make a Rust type that offered the C++ thread-safety guarantee, safe Rust code would never be able to take advantage of this guarantee, because the code would fail to compile.
For example, let’s try to port the C++ code above to Rust:
use std::thread;
use std::sync::atomic::{AtomicI32, Ordering};
struct ThreadSafeCounter {
count: AtomicI32,
}
impl ThreadSafeCounter {
fn increment(&mut self) { self.count.fetch_add(1, Ordering::SeqCst); }
}
pub fn main() {
let n = 10;
let mut counter = ThreadSafeCounter { count: AtomicI32::new(0) };
let mut threads = Vec::new();
for _ in 0..n {
threads.push(thread::spawn( || {
// Rust won't allow this. We are attempting to mutably borrow
// the same value multiple times.
counter.increment();
}));
}
for thread in threads { thread.join(); }
println!("{}", counter.count.load(Ordering::SeqCst));
}
This fails to compile with:
error[E0499]: cannot borrow `counter` as mutable more than once at a time
--> <source>:18:37
|
18 | threads.push(thread::spawn( || {
| - ^^ `counter` was mutably borrowed here in the previous iteration of the loop
| ______________________|
| |
19 | | // Rust won't allow this. We are attempting to mutably borrow
20 | | // the same value multiple times.
21 | | counter.increment();
| | ------- borrows occur due to use of `counter` in closure
22 | | }));
| |__________- argument requires that `counter` is borrowed for `'static`
Because Rust fundamentally allows only a single mutable reference to any given object, we have to express the C++ concept of thread-safety in a different way.
The Rust answer to thread-safety is to allow mutation on an immutable reference. Rust calls this “interior mutability.” With one small change, the previous example compiles and works as expected:
use std::thread;
use std::sync::atomic::{AtomicI32, Ordering};
struct ThreadSafeCounter {
count: AtomicI32,
}
impl ThreadSafeCounter {
// increment() uses "interior mutability": it accepts an immutable
// reference, but ultimately mutates the value.
fn increment(&self) { self.count.fetch_add(1, Ordering::SeqCst); }
}
pub fn main() {
let n = 10;
let mut counter = ThreadSafeCounter { count: AtomicI32::new(0) };
let mut threads = Vec::new();
for _ in 0..n {
threads.push(thread::spawn( || {
counter.increment();
}));
}
for thread in threads { thread.join(); }
println!("{}", counter.count.load(Ordering::SeqCst));
}
As a C++ programmer, interior mutability strikes me as a bit of a fib: an
operation that is in fact mutable, both logically and physically, is allowed on
an immutable reference. This is very similar to mutable
and const_cast
in
C++, which are both frowned on.
I found a nice explanation of the Rust perspective in this Stack Overflow answer:
In a way, Rust’s
mut
keyword actually has two meanings. In a pattern it means “mutable” and in a reference type it means “exclusive”. The difference between&self
and&mut self
is not really whether self can be mutated or not, but whether it can be aliased.
This helps explain the rationale behind interior mutability. When applied to a reference, “immutable” in Rust doesn’t really mean “immutable”, it means “non-exclusive.” This point is covered in more depth in another article, Accurate mental model for Rust’s reference types.
There was even a proposal several years
back
to rename &mut
to &my
, &only
, or &uniq
, to emphasize that the key
property of such references is not that they are mutable, but that they are
unique.
A type we would consider thread-safe in C++ will need to use interior
mutability in Rust and implement the Sync
trait. It will need to allow
mutating operations to be performed through an immutable reference.
This difference is reflected in the API of the atomics we were using above:
- In C++,
std::atomic_int32_t::fetch_add()
is a non-const
operation. This makes sense, as the operation does in fact mutate the atomic. Callers have to read the documentation to know that concurrent calls to this non-const
method are safe. - In Rust,
std::sync::atomic::AtomicI32::fetch_add()
is an immutable operation (takes a non-mut
reference). This is the interior mutability “fib” (asfetch_add()
will in fact mutate the atomic), but it has the benefit of expressing the the type’s thread-safety guarantee within the type system, which allows the compiler to automatically check it.
Rust has the obvious advantage of having thread-safety modeled within the type system, and checked by the compiler. Rust even allows a single type to have both thread-safe and non-thread-safe methods. An example is std::sync::Mutex, which provides both Mutex::lock(&self), a thread-safe method that locks the mutex, and Mutex::get_mut(&mut self), which allows access to the mutex’s data without any synchronization costs. If the caller holds a unique reference, synchronization is unnecessary, and Rust lets us avoid this overhead. This is all modeled within the type system and checked automatically.
Mapping between C++ and Rust terminology
The analysis above leaves us with the following mapping between terms:
C++ | Rust | Example |
---|---|---|
thread-compatible | implements Sync |
most types (eg. Vec or vector ) |
thread-safe | implements Sync with interior mutability |
AtomicI32 |
thread-unsafe | doesn’t implement Sync |
Cell , RefCell in Rust |
We can also put things in quadrants like so:
Sync |
!Sync |
|
---|---|---|
interior mutability | thread-safe (AtomicI32 ) |
thread-unsafe (Cell ) |
no interior mutability | thread-compatible (Vec ) |
thread-unsafe (proc_macro ) |
The two quadrants under !Sync
are both labeled “thread-unsafe.” The C++
terminology does not distinguish between these two cases, and neither does the
type system. In Rust there are interesting differences between them. With
interior mutability come types like Cell
and RefCell
that can provide safe
interior mutability by constraining the circumstances under which mutation can
occur. The “no interior mutability” case here initially seems not useful, but
as pointed out to me on
lobste.rs, it can
actually be quite useful when combined with !Send
, as it allows one to create
a handle to thread-local data that can only be safely used within a single
thread.
Thanks to Matt Brubeck for reading a draft version of this article. Matt has an article that delves more deeply into unique vs shared references.