---
title: 'Thread Safety in the Type System'
source: 'https://academia.sh/en/courses/rust-concurrency/thread-safety-in-the-type-system'
course: 'Concurrency and Asynchronous Rust'
language: en
updated: '2026-09-15T04:58:44+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "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'
---

# 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.

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.

```rust
// 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.

```rust
// 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.
