Skip to content
academia.sh

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

  • Send says a type’s ownership can be moved to another thread, Sync says a reference to a type can be read from another thread; both are checked at compile time.
  • A struct whose fields are all Send/Sync can cross the boundary when placed inside Arc<Mutex<...>>; in the measurement, first advanced on its own, and second waited until first’s join completed.
  • Rc<RefCell<..>> cannot cross the boundary, because neither of its counters is atomic; the accepted counterpart is provided jointly by Arc (an atomic counter) and Mutex (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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close