Skip to content

The Rust Engineering Handbook / Chapter 52

Send, Sync, and Thread-Safety Boundaries

Reason from Rust's Send and Sync capabilities to safe movement, sharing, composition, and public concurrency contracts.

Part IX begins with two questions, not a thread-spawning API:

  1. May ownership of this value move to another thread?
  2. May multiple threads receive shared references to this value?

Rust names those capabilities Send and Sync. Everything that follows—threads, channels, mutexes, atomics, work stealing, and parallel iterators—depends on answering them correctly. They are not performance endorsements, liveness guarantees, or proofs that an algorithm is logically correct. They are the type-system boundary that lets safe concurrency mechanisms trust a value’s movement and sharing rules.

The ledger-core review made the consequence concrete: a future parallel importer turns these capabilities into caller-visible compatibility promises even though the crate itself does not spawn a thread. Part IX begins at that public boundary, before choosing any concurrency mechanism.

Send means a value can safely transfer ownership across a thread boundary. Sync means shared references to a value can safely be used across thread boundaries. The standard library gives the relationship a precise form:

T: Sync if and only if &T: Send.

That equivalence is the best retrieval rule in this chapter. Do not translate Sync as “internally synchronized” or Send as “contains a lock.” Plain immutable data can be both. A lock can contain a value that is not eligible for the capability being requested. Start from what access crosses the boundary, then inspect the composition.

Auto traits make composition the default

Send and Sync are unsafe auto traits. “Auto” means the compiler normally derives them from a type’s fields. “Unsafe trait” means an incorrect manual implementation can permit safe code to trigger undefined behavior, and unsafe code is allowed to rely on the implementation’s truth.

For an ordinary struct, capability propagates structurally. A pair of String and u64 is Send and Sync because its components are. A Vec<T> is conditionally capable according to T; its internal raw pointer does not make the public collection unconditionally thread-unsafe because the standard library owns and proves that representation. Add an Rc<T> field and the enclosing type loses both capabilities. Add a thread-affine marker and the compiler carries that restriction outward.

A two-lane auto-trait map distinguishes moving values with Send from sharing references with Sync, shows composition through fields, identifies common blockers, states the reference relationship, and puts unsafe implementations behind an audit-proof gate.

The graph is intentionally conditional. Arc<T> is not a spray-on thread-safety coating. Its reference count is atomic, but access to T must still satisfy the relevant bounds. Likewise, Vec<T> and Box<T> inherit their element’s capability rather than laundering it.

Write positive compiler witnesses for public promises:

fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}

assert_send::<Ledger>();
assert_sync::<Ledger>();
assert_send::<SharedLedger>();
assert_sync::<SharedLedger>();

These zero-runtime functions turn accidental field changes into compilation failures. They are especially useful for public futures, iterators, callbacks, guards, and handles whose concrete types may change during refactoring. The witness proves only the trait bound under the tested compiler and feature configuration; it does not prove the type’s operations scale or avoid deadlock.

A capability table beats intuition

Use a table before reaching for wrappers:

Type or access Send? Sync? Reasoning consequence
u64, String, Vec<u8> yes yes owned, ordinary data composes safely
&T when T: Sync when T: Sync moving a shared reference is sharing T
&mut T when T: Send when T: Sync unique access may move; sharing the reference does not grant mutation through it
Rc<T> no no reference count is shared without atomic synchronization
Arc<T> when T: Send + Sync when T: Send + Sync atomic ownership count; contents retain their own requirements
Cell<T> / RefCell<T> conditionally Send no mutation through &self is unsynchronized
Mutex<T> when T: Send when T: Send the mutex may safely mediate cross-thread access to movable protected state
raw pointer no by default no by default provenance, aliasing, lifetime, and synchronization are untracked
ThreadAffineToken fixture no no private PhantomData<Rc<()>> propagates an intentional restriction

The exact standard-library implementations are authoritative; the table is a reasoning aid, not a replacement for documentation. Bounds can be subtler than “all fields have both traits.” For example, moving a RefCell<T> to another thread can be safe when T: Send because ownership remains exclusive. Sharing &RefCell<T> is not safe because runtime borrow checking does not synchronize threads.

This distinction prevents a common overcorrection. Interior mutability is not inherently wrong and not inherently concurrent. It means mutation is possible through a shared reference. The mechanism must match the aliasing domain: Cell and RefCell for single-threaded dynamic access; atomics, mutexes, or read-write locks for appropriately synchronized cross-thread access. Later chapters examine those mechanisms. Here the important question is what capability each mechanism exposes.

Derive the reference relationships instead of memorizing slogans

Start from the definitions.

If T: Sync, then it is safe to move &T to another thread. That is exactly the T: Sync ⇔ &T: Send relationship. A shared reference cannot ordinarily mutate T, but interior-mutability types are the reason the property needs semantic enforcement rather than syntax alone.

An &mut T may be Send when T: Send: the unique reference transfers exclusive access to the other thread for its lifetime. It does not create a second mutable alias. An &mut T can be Sync when T: Sync because sharing a reference to the mutable reference does not let either holder extract mutable access; shared access remains read-only unless another synchronized mechanism exists.

Those statements often feel surprising because “mutable reference” is read as “mutation everywhere.” Rust’s capability depends on the access a particular layer permits. &mut T represents exclusivity, while & &mut T is still a shared outer reference.

Lifetimes remain separate. A borrowed reference can satisfy Send and still fail thread::spawn because the spawned closure requires data that can outlive the current stack scope. 'static does not mean “stored forever” or “thread-safe”; it means the value contains no non-'static borrow for that requirement. Chapter 53 will use scoped threads to separate ownership capability from lifetime duration.

Rc and Arc solve different ownership domains

Rc<T> provides shared ownership within one thread. Cloning updates a non-atomic count. If Rc could cross threads, clones on different threads could race while modifying that count. Therefore Rc<T> is neither Send nor Sync, regardless of whether T itself is immutable.

Arc<T> uses atomic operations for the ownership counts and supports cross-thread shared ownership when T meets the required bounds. That atomic count protects the lifetime bookkeeping, not arbitrary fields inside T. Arc<RefCell<T>> does not become a sound shared mutable container because the RefCell still lacks Sync. For shared mutation, Arc<Mutex<T>> may be appropriate because the mutex mediates access, but it introduces lock acquisition, poisoning policy, contention, possible deadlock, priority interactions, and critical-section design.

Three repairs for a rejected Rc<RefCell<State>> design express different architectures:

  • Keep it thread-local and run all owners on one event-loop thread. This preserves cheap non-atomic ownership and may be the correct GUI or interpreter model.
  • Move the state to one worker and send owned commands through channels. This preserves a single mutator and makes queue capacity and shutdown part of the contract.
  • Share Arc<Mutex<State>> and define lock scope, invariant recovery, ordering, and performance budgets. This permits direct access but distributes synchronization obligations.

Choosing among them is a system-design decision. “Replace Rc with Arc until it compiles” cannot decide ownership topology, mutation protocol, backpressure, or liveness.

Raw pointers stop automatic trust on purpose

Raw pointers do not carry Rust lifetimes, uniqueness, ownership, or synchronization. The address might point into a live unique allocation, aliased foreign memory, thread-local storage, a moved object, or freed memory. The compiler cannot infer which story applies, so raw pointers block automatic Send and Sync propagation.

This is conservative. Standard collections contain raw pointers internally and still implement the traits under proven generic bounds. The difference is an abstraction with a safety case: allocation and deallocation rules, aliasing discipline, access methods, element bounds, and destructor behavior are controlled.

Suppose a wrapper is proposed:

struct ForeignBuffer {
    ptr: std::ptr::NonNull<u8>,
    len: usize,
    api: &'static ForeignApi,
}

unsafe impl Send for ForeignBuffer {}
unsafe impl Sync for ForeignBuffer {}

The two empty bodies are not the proof. They are claims consumed by safe code. Review them separately. Ownership transfer might be safe while shared access is not. A foreign allocation may be readable from any thread but required to be freed on its creating thread. A callback may mutate it concurrently. len may be stale. The API table may point into an unloaded library. ForeignApi being a shared reference adds its own Sync requirement.

Before accepting Send, establish who uniquely owns the allocation, whether all reachable state may move, whether destruction is permitted on another thread, and whether foreign thread-local state is involved. Before accepting Sync, establish which operations are callable through &ForeignBuffer, whether reads race with writes or deallocation, how aliasing is controlled, and which synchronization creates the required happens-before relationships. If the proof works only under an external lock, encode or require that lock rather than asserting unconditional Sync.

Bounds matter. An owning generic pointer wrapper might be Send when T: Send and Sync when T: Sync, not for every T. Copying standard-library-looking bounds without matching its ownership and access design is cargo cult unsafe code.

Thread affinity is a legitimate public contract

Some resources must remain on the creating thread: GUI objects, event-loop registrations, interpreter handles, platform contexts, and guards whose underlying primitive requires same-thread release. A non-Send type is not defective when it represents that rule. It prevents a use the external system cannot support.

On stable Rust, downstream crates generally cannot write negative implementations such as impl !Send as an ordinary production technique; the relevant language feature remains unstable. The fixture expresses thread affinity compositionally:

pub struct ThreadAffineToken {
    id: u64,
    _not_send_or_sync: PhantomData<Rc<()>>,
}

The private marker has no runtime size, but it participates in auto-trait and variance reasoning. Because Rc<()> is neither Send nor Sync, the enclosing token inherits those restrictions. A compile_fail doctest verifies that an assert_send::<ThreadAffineToken>() call remains rejected.

This technique needs a comment explaining the external invariant. A mysterious marker may be deleted as “unused” during refactoring. The type should also avoid public escape hatches that reconstruct an unrestricted handle. If only destruction is thread-affine but work may execute elsewhere, consider a proxy, command channel, or owner task rather than marking every associated data value local.

Negative reasoning should not depend only on a diagnostic observed once. Keep the field that structurally enforces the property, a compiler-fail witness, and documentation stating why the restriction exists. Review feature configurations because a conditional field can change auto traits.

Public APIs export capability, even without naming it

Returning a type that is Send or Sync lets downstream code place it in thread-spawning, executor, channel, or shared-state abstractions that require those bounds. Removing the capability later can be a source-breaking change even if no method signature visibly changes. Adding a non-Send field to a public handle can break worker placement far away from the crate.

Chapter 51’s Ledger has Vec<Amount> and ordinary integers, so the compiler establishes both traits. That does not make Ledger::post(&mut self, ...) concurrently callable through &Ledger; safe mutation still requires exclusive access. SharedLedger deliberately changes the access model by placing Ledger behind Arc<Mutex<_>> and exposing methods on &self.

The wrappers promise different things:

  • Ledger: movable and shareable for read-only access; mutation requires an exclusive borrow.
  • SharedLedger: cloneable shared ownership with serialized mutation; poison recovery policy and lock cost are observable design concerns.
  • ThreadAffineToken: use and destruction remain on its owning thread.

Document the one callers need. Do not expose Arc<Mutex<T>> merely to announce thread safety; that leaks synchronization and lets callers hold guards or compose locks in ways the library cannot govern. A wrapper can keep critical sections, recovery, and future representation private. Conversely, an application with one owner may prefer channel commands and never need a shared handle.

Trait objects and callbacks require explicit capability too. Box<dyn Handler> does not imply Send; use Box<dyn Handler + Send> when transfer is part of the contract, and add Sync only if shared invocation is valid. A callback that is Send may move between threads, but concurrent calls usually require Sync or exclusive sequencing by the owner. State whether calls overlap, which thread invokes them, and what happens on panic.

Async functions carry auto traits through their generated future state. Holding an Rc, guard, or thread-affine handle across an .await can make the returned future non-Send. If downstream multithreaded spawning is promised, assert the returned future’s capability in tests. If local execution is intentional, make it part of the API rather than hiding it behind a late compiler error.

Thread safety is narrower than concurrency correctness

Rust’s type system prevents data races in safe code when unsafe implementations are sound. It does not prevent every race condition. Two threads can safely perform atomic or locked operations in the wrong logical order. A check-then-act sequence can violate a business invariant while every memory access is synchronized. A channel can deadlock through a shutdown cycle. A mutex can starve a high-priority request. An unbounded queue can exhaust memory without a data race.

Separate five review dimensions:

  1. Memory safety: no invalid access or undefined behavior.
  2. Race freedom: no unsynchronized conflicting memory access.
  3. Logical atomicity: multi-step domain invariants appear indivisible where required.
  4. Liveness: progress, shutdown, deadlock, starvation, and cancellation behavior.
  5. Throughput and latency: contention, cache traffic, scheduling, and load response.

Send and Sync primarily authorize safe movement and sharing under the first two dimensions. A mutex can help establish logical atomicity if the complete invariant is protected under one guard. It does not automatically guarantee fairness or acceptable tail latency. Atomics can eliminate a lock while making logical ordering harder. These are reasons to build on the capability model, not reasons to dismiss it.

Operational observability also sits outside the traits. Measure lock wait and hold time, queue depth, worker utilization, retries, cancellation, and stalled shutdown where relevant. A type-level promise tells you what executions are permitted; production evidence tells you how the chosen execution behaves.

Exercise: review an unsafe boundary claim

You receive the proposed ForeignBuffer wrapper above plus these statements from its author: “The C library is thread-safe,” “the pointer is only bytes,” and “we put it in an Arc.” Conduct separate Send and Sync reviews.

Produce:

  1. An ownership and alias ledger for allocation, clones or references, callbacks, reads, writes, close, and drop.
  2. The foreign documentation that specifies which threads may allocate, access, register callbacks, and free the buffer.
  3. A list of every operation reachable through &ForeignBuffer, including indirect mutation and callback activity.
  4. Required T or API bounds, if the wrapper becomes generic.
  5. A destruction analysis for move, panic, thread exit, library unload, and process shutdown.
  6. A synchronization account naming the primitive and the happens-before edge for every shared mutation.
  7. Positive compile assertions for supported capabilities and compile-fail witnesses for intentionally rejected ones.
  8. A test plan using stress, sanitizer, model, or foreign-library tooling appropriate to the implementation—while explaining what each tool cannot prove.
  9. One of four decisions for each trait: derive automatically by redesigning fields; write a bounded unsafe implementation with a safety comment; keep the type non-capable; or expose a thread-owning proxy.

Inject adversarial facts one at a time: the library invokes a callback from an internal worker; free must run on the allocating thread; a read function updates a cache; the pointer can be retained after close; the API’s “thread-safe” statement applies only to distinct contexts. Re-evaluate both traits after each fact. The exercise fails if Send and Sync receive one combined justification or if Arc is treated as synchronization for the pointed-to bytes.

Boundary review questions

  • Is ownership moving, or is shared access crossing the thread boundary?
  • Which fields establish or block the auto trait, under every feature configuration?
  • Does a reference relationship explain the result, especially T: Sync ⇔ &T: Send?
  • Is interior mutation single-threaded, atomic, locked, or governed by another explicit protocol?
  • Does Arc protect only shared ownership, or is access to the contents also synchronized?
  • What provenance, aliasing, lifetime, and destruction facts are absent from a raw pointer?
  • Must the resource be used or dropped on one thread?
  • Can stable composition encode thread affinity without a manual unsafe implementation?
  • Does the public API promise a capability that tests should witness?
  • Are callback, trait-object, iterator, guard, and future bounds intentional?
  • What logical atomicity, liveness, and performance properties remain unproved after the traits hold?

Send and Sync are small marker traits with system-wide consequences. Treat them as capabilities derived from ownership and access, not labels applied after an architecture is chosen. The next chapter can then place values onto real thread lifetimes and work lanes without confusing “the compiler permits this transfer” with “this decomposition is correct and fast.”

Sources and version note

The authoritative standard-library contracts are Send and Sync; the latter documents the reference relationships and interior-mutability examples. The Rustonomicon’s Send and Sync chapter explains unsafe implementation obligations and raw-pointer conservatism, while the standard Arc, Rc, and UnsafeCell pages define their specific behavior. The negative-implementation example in the Nomicon is feature-gated; this chapter uses stable marker-field composition instead. Fixture claims are tested under Rust 1.97.0 and the declared Rust 1.85.0 MSRV, Rust 2024 Edition.