Lesson 03 / 10
Thread Safety in the Type System
Whether a value can cross a thread boundary is a condition written into the type: a design that can cross is actually carried across and its record is read, but the type permitting this does not show that a shared computation will give the same result as an independent assumption would.
Published Updated
In this lesson
- Explains that a value's ability to cross a thread boundary is a condition checked in the type system at compile time
- Runs a design that can cross the boundary and describes in prose why a type that cannot cross is rejected
- Shows by running it that a sharing a type permits can give a different result from an independent assumption
Contents
The previous lesson showed a value’s ability to cross from one thread to another with two examples: a list, a message struct. Both crossed without any trouble, but that crossing itself was never questioned — as though every type could pass through a channel or a shared container. In fact, there are types that cannot cross, and this permission is checked not at runtime but at compile time. This lesson’s question is: what does a type’s ability to cross the boundary depend on, and where is that condition written in the source?
The JVM, Concurrency and Performance course established the concept of a data race: a situation where two threads access the same memory concurrently, at least one of them writing, and the result is undefined. This concept is not redefined here. Rust’s difference is that instead of catching this risk at runtime with a detector, it stops types that carry the risk from crossing a thread boundary in the first place. Whether a value is open to a data race is known while the source compiles, before that value is ever shared.
Send and Sync: A Permission Written Into the Type
The Ownership and the Memory Model course established the concepts of ownership, borrowing, and
references, but that course explained them under a single-thread assumption. A value’s being
movable to another thread and a value’s being referenceable from another thread are two
separate conditions, and Rust ties these two to two separate types: Send and Sync. If a
type is Send, a value of that type can have its ownership safely moved to another thread. If
a type is Sync, a reference to that type (&T) can be safely read from another thread — that
is, if T is Sync, then &T is Send.
These two are type names that are not translated, and for most types they are established
automatically by the compiler: if all of a struct’s fields are Send, the struct itself is
Send too; the same rule holds for Sync. A programmer usually never writes these two types
at all; only when trying to move a value across a thread boundary does the compiler check
whether that value’s type holds this permission. If the permission is absent, the source does
not compile — but this restriction is not run as an error in this lesson, it is only described
in prose.
This automatic derivation also explains why the previous two lessons’ examples crossed without
ever being questioned. The Vec<i32> and the Message struct from the previous lesson — a
plain struct carrying a string field and an integer field — are both Send and Sync. The
reason is that both are built only from basic types and from collections the standard library
itself marks Send/Sync. For a type to lose one of these permissions, it has to carry a
special sharing mechanism inside it — exactly like the Rc and RefCell seen in the next
section. The compiler derives this permission by looking at the type definition; even if the
programmer never writes the word Send or Sync in the source at all, every struct and
enum declaration falls implicitly into one of these two classifications.
A Design That Can Cross
The Atomic Reference Counting lesson of the Ownership and the Memory Model course introduced
Arc, but the only thing measured in that lesson was whether the counter was atomic — the
question of crossing a boundary was explicitly left out there. This lesson measures that
boundary: Arc’s atomic counter is exactly the condition that makes it both Send and Sync.
If the type inside it holds these same two permissions too (Mutex<T> is always Send, and if
the T inside it is Send then Mutex<T> is also Sync), Arc<Mutex<T>> becomes a type that
can fully cross a thread boundary.
The program below places a struct whose fields are all Send into an Arc<Mutex<...>> and
shares it between two threads. Arc is both Send and Sync (as long as the type inside it
is too), and so is Mutex — which is why the record value can safely cross to both threads
through a cloned handle.
// boundary.rs — a value's ability to cross a thread boundary is a condition written into the type.
use std::sync::{Arc, Mutex};
use std::thread;
struct SharedRecord {
label: String,
value: i32,
}
fn main() {
let record = Arc::new(Mutex::new(SharedRecord {
label: String::from("creation"),
value: 5,
}));
let r1 = Arc::clone(&record);
let first = thread::spawn(move || {
let mut content = r1.lock().unwrap();
content.value += 1;
content.label = String::from("first-wrote");
});
first.join().unwrap();
let r2 = Arc::clone(&record);
let second = thread::spawn(move || {
let content = r2.lock().unwrap();
(content.value, content.label.clone())
});
let (read_value, read_label) = second.join().unwrap();
println!("crossed the boundary — value: {}, label: {}", read_value, read_label);
}
crossed the boundary — value: 6, label: first-wrote
TH4. The first thread is called while the lock is free, so it advances needing no one —
it falls into the on-its-own class. The second thread starts the same way, but its actual
dependency is that the first.join() call has already completed on the main flow: the program
joins the first thread before spawning the second clone, which is what ties the read value to
the guarantee that it is the value first wrote. The record type’s ability to cross the
boundary is the precondition for these two threads being able to access the same data in
sequence; the condition itself is provided not at runtime, but at compile time, the moment
Arc<Mutex<SharedRecord>> is written.
The Arc::clone call itself is also worth noting: r1 and r2 are two separate handles
pointing at the same Mutex, but both share the same heap object. This is the same mechanism
as cloning the channel handle in the previous lesson — Arc::clone does not copy the data
either, it only increments the shared ownership counter. The difference is that a channel’s
cloned end is a sending gate, while Arc’s cloned end is a direct access point to the shared
data. The record.lock() call comes into play at this point: Arc makes shared access
possible, while Mutex grants that access to only one side at a time. The two solve separate
problems — one solves the question “how many sides hold a reference to this data”, the other
solves “who can write right now”.
A Type That Cannot Cross: Why Rc<RefCell<..>> Cannot Pass the Boundary
The Ownership and the Memory Model course’s Shared Ownership topic introduced the Rc and
RefCell pair in a single-threaded context: Rc tracks a value having more than one owner by
counting them, while RefCell checks the borrowing rules at runtime instead of at compile
time. When the two are used together, Rc<RefCell<T>> looks like a shared, mutable container,
just like Arc<Mutex<...>> in boundary.rs — but this container cannot cross a thread
boundary.
The reason lies in the counters themselves. The reference count Rc keeps would be open to a
data race if two threads could increment and decrement it at the same time, because that
counter is not atomic; RefCell’s runtime borrow counter is not atomic either, for the same
reason. The compiler does not mark either of these two types as Send — this is not a
shortcoming, it is precisely the source of preventing the data race. If a programmer tried to
hand an Rc<RefCell<SharedRecord>> value to thread::spawn, the source would not compile;
this rejection is not shown here with an error message, only its reason is explained. The
accepted counterpart was already run above: the same need is met by Arc, whose counters are
atomic, and by Mutex, which provides protection through locking. The difference between the
two is not just a name — Arc’s counter is updated with atomic operations, and Mutex grants
access in sequence; together, the two take on, in a world with more than one thread, the role
that Rc<RefCell<..>> takes on in a single-threaded world.
This distinction can be noticed even without using Mutex on its own. A type carrying only
RefCell (without shared ownership) loses its Sync status but not its Send status — a
RefCell<T> can be moved to another thread on its own, because after being moved that thread
becomes its sole owner; but it cannot be used through a shared reference between two
threads, because the runtime borrow counter would be left unprotected. Rc<T>, by contrast,
loses both, because Rc’s reason for existing is shared ownership itself, and that sharing is
never safe between threads. These three examples — plain RefCell, plain Rc, and the
combination of the two — point to three different rejections coming from the same root:
interior mutability, reference counting, or the sum of both; whichever it is, the condition in
the source is the same, a non-atomic counter or a non-atomic checking mechanism.
Although this rejection is not shown with a compile error, its outcome resembles a pattern established in the Traits, Generics and Error Handling course: there, too, cases where a type failed to satisfy a particular bound were explained through their reasoning alone, without being put into a code block. The difference here is that the bounds measured there concerned generics (which traits a type parameter had to satisfy), while what is measured here is the permission to cross a thread boundary — both are examples of the compiler’s decision about what a type can do, given without ever running the code.
The Limits of the Measurement
A type permitting a sharing does not mean the program that uses that sharing is correct.
Arc<Mutex<i32>> prevents a data race — two threads cannot change the same integer at the same
time — but which operation applies in which order still depends on the program’s own design.
The measurement below shows this directly: if each of two threads assumes it is independently
modifying a shared number, the expected total is one value; but when they operate on the same
number in sequence, the result is a different value.
This is only one of the problems two threads sharing a locked resource can run into; another can arise when two threads each wait on a lock the other holds, and this course never runs such a situation, because its outcome is a deadlock and it produces no deterministic output. What is measured here is a narrower and more fundamental question: even if the lock itself works correctly — even with no deadlock, even with no data race — does the order of two operations working on a shared value change the result of those operations? The answer is yes, and this draws the boundary of the guarantee the type provides.
// limiter.rs — the type permits sharing, but order dependence can give the wrong result.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let start = 5;
// expected under the independent assumption: A doubles the value (10), B adds 10 on its
// own (15); if the two had run separately the total would be 10 + 15 = 25.
let independent_expected = (start * 2) + (start + 10);
let shared = Arc::new(Mutex::new(start));
let s1 = Arc::clone(&shared);
let a = thread::spawn(move || {
let mut value = s1.lock().unwrap();
*value *= 2;
});
a.join().unwrap();
let s2 = Arc::clone(&shared);
let b = thread::spawn(move || {
let mut value = s2.lock().unwrap();
*value += 10;
});
b.join().unwrap();
let actual_result = *shared.lock().unwrap();
println!("expected under the independent assumption: {}", independent_expected);
println!("actual result with the shared mutex: {}", actual_result);
println!(
"type permitted sharing, is the result the same as the independent assumption: {}",
actual_result == independent_expected
);
}
expected under the independent assumption: 25 actual result with the shared mutex: 20 type permitted sharing, is the result the same as the independent assumption: false
TH5. The expected value, 25, is computed under the assumption that the two operations run
on their own starting value, unaware of each other: A doubles five to get ten, B adds ten
to five to get fifteen, for a total of twenty-five. In the actual run, though, the two share
the same number, and the program joins A before B: A doubles five to reach ten, B
adds ten more to that ten to reach twenty. The result is twenty, not twenty-five — and this
difference comes out identical across three separate runs, because the join calls make the
order deterministic. The Mutex<i32> type permitted this sharing and did genuinely prevent the
data race; what it did not prevent is whether the programmer’s assumption that “these two
operations are independent of each other” was true. Which operation takes the lock first is
still a question that depends on the program’s design, and which one would win at random is
never measured in this lesson — only the result of this particular design, forced into a fixed
order (A first, then B), is measured.
What this measurement actually teaches is that the claims “the type is safe” and “the program
is correct” are different things. The type system around Mutex<i32> prevents two threads from
changing the same integer at the same time — this guarantee holds on every run and is never
broken. But in which order the lock is granted, which operation runs before the other,
depends entirely on the program’s own design, and the type system says nothing about that. The
Mutex and Read-Write Lock lesson will take up this gap directly: when one side will wait for
the lock, and which of the progress classes that waiting falls into.
Summary
Sendsays a type’s ownership can be moved to another thread,Syncsays a reference to a type can be read from another thread; both are checked at compile time.- A struct whose fields are all
Send/Synccan cross the boundary when placed insideArc<Mutex<...>>; in the measurement,firstadvanced on its own, andsecondwaited untilfirst’s join completed. Rc<RefCell<..>>cannot cross the boundary, because neither of its counters is atomic; the accepted counterpart is provided jointly byArc(an atomic counter) andMutex(sequential access).- A type permitting a sharing prevents a data race, but it does not guarantee that the program will give the correct result — an order-dependent computation can produce a result different from the independent assumption.
- Which operation takes the lock first is not measured in this lesson; only the deterministic result of one particular design forced into a fixed order is measured.
Next Step
In this lesson, two threads accessed the same data in sequence because the program itself
forced that order through join calls: second never started before first had completely
finished. But joining is not always a suitable tool — sometimes two threads need to run at the
same time, and only their access to the shared data at that moment needs to fall into sequence.
In that case, one thread has to wait for the other to release its lock, and that wait is a
different mechanism from joining. The next lesson measures this waiting directly: while the
lock holder advances on its own, how the side waiting for the lock advances when something else
finishes — and why a lock meant for reading carries a looser rule than one meant for writing.
To keep your progress and take notes, Log in
My notes
Log in to take notes.