What is Send and Sync

Send and Sync are Rust traits that ensure types can be safely moved to or shared between threads.

When threads collide

You're building a service that processes requests. You have a DatabaseConfig struct holding a connection string and a timeout. You spawn a worker thread to handle incoming jobs. You try to pass the config to the thread. The compiler rejects you. You try to share a reference instead. The compiler rejects you again. The error message cites Send and Sync.

These aren't methods you call. They aren't runtime checks. They are compiler-enforced guarantees about thread safety. Rust uses them to prevent data races before your code ever runs. If you understand Send and Sync, you understand how Rust lets you write concurrent code without fear.

Send moves ownership. Sync shares references.

Send controls ownership transfer. Sync controls shared access.

A type is Send if you can move it to another thread. Ownership crosses the thread boundary. The old thread loses access. The new thread becomes the sole owner.

A type is Sync if you can share a reference to it across threads. Multiple threads can hold &T at the same time. The value itself stays put. Only the view moves.

Here is the trick that clicks everything into place. Sync is just Send for references. A type T is Sync if and only if &T is Send. You can send a reference to another thread. That is what sharing means.

This equivalence simplifies the mental model. The compiler only really needs to track Send. Sync is derived from whether a reference is Send. When you see T: Sync, read it as &T: Send.

Minimal example

Most types you use are Send and Sync by default. The compiler derives these traits automatically based on the fields of your struct.

struct Config {
    host: String,
    port: u16,
}

fn main() {
    let config = Config { host: String::from("localhost"), port: 8080 };

    // Config is Send because String and u16 are Send.
    // The compiler derives this automatically.
    std::thread::spawn(move || {
        // Ownership moves here.
        println!("Starting on {}:{}", config.host, config.port);
    });
}

The move keyword forces the closure to take ownership of config. The compiler checks if Config is Send. It looks at the fields. String is Send. u16 is Send. Since all fields are Send, Config is Send. The code compiles.

If you remove move and try to capture config by reference, the compiler checks if Config is Sync. String is Sync. u16 is Sync. The reference is safe to share. The code compiles.

How the compiler decides

The compiler performs a recursive check. It inspects your type definition. If every field implements Send, the struct gets Send. If every field implements Sync, the struct gets Sync.

This happens at compile time. There is no runtime overhead. No hidden locks. No checks. The generated binary has no trace of these traits. They are purely static analysis.

The recursion stops at primitive types. Integers, floats, booleans, and unit are all Send and Sync. Pointers depend on what they point to. Box<T> is Send if T is Send. Vec<T> is Send if T is Send.

The compiler also checks for UnsafeCell. This is the marker for interior mutability. If a type contains UnsafeCell, it is not Sync by default. Interior mutability allows mutation through shared references. That is safe in a single thread. Across threads, it causes data races. The compiler blocks it.

Some types contain UnsafeCell but implement Sync manually. Mutex<T> is the classic example. Mutex wraps UnsafeCell to allow mutation. It also implements Sync because the lock protects the cell. You cannot access the data without holding the lock. The synchronization primitive restores safety.

Realistic example: Rc vs Arc

The most common encounter with Send and Sync happens when you try to share data across threads using Rc.

Rc<T> provides shared ownership. It uses reference counting. When the last Rc drops, the data is freed. Rc is fast. It uses non-atomic operations for the counter. It is not thread-safe.

use std::rc::Rc;
use std::thread;

fn main() {
    let data = Rc::new(vec![1, 2, 3]);
    let data_clone = Rc::clone(&data);

    // This fails to compile.
    thread::spawn(move || {
        println!("{:?}", data_clone);
    });
}

The compiler rejects this with E0277 (Rc<Vec<i32>> cannot be sent between threads safely). Rc is not Send. You cannot move it to another thread.

The reference counter in Rc updates without atomic instructions. If two threads update the counter simultaneously, the count corrupts. Memory leaks or double-frees follow. The compiler prevents this by marking Rc as not Send.

The fix is Arc<T>. Arc stands for Atomic Reference Counted. It uses atomic operations for the counter. It is thread-safe. Arc is Send and Sync.

use std::sync::Arc;
use std::thread;

fn main() {
    let data = Arc::new(vec![1, 2, 3]);
    let data_clone = Arc::clone(&data);

    // This compiles. Arc is Send.
    thread::spawn(move || {
        println!("{:?}", data_clone);
    });
}

Swap Rc for Arc. The rest of your code stays the same. The atomic overhead is the price of thread safety.

Convention aside: use Arc::clone(&data) instead of data.clone(). Both compile. Both work. The explicit form signals to readers that you are cloning the pointer, not the data. It prevents the mental model of a deep copy.

Pitfalls and compiler errors

Cell<T> and RefCell<T> are not Sync. They allow interior mutability without borrowing checks. Sharing them across threads causes data races. The compiler rejects any attempt to share a reference to RefCell across threads.

Raw pointers *const T and *mut T are neither Send nor Sync. The compiler has no way to verify what they point to or how they are used. You must wrap them in a safe abstraction that implements these traits.

If you try to use a non-Send type in a thread, you get E0277. The error message lists the type that fails the bound. It often points to a field inside a struct. Fix the field. Replace Rc with Arc. Replace RefCell with Mutex. Add Send bounds to generics.

Generics can hide Send requirements. If you have a function that spawns a thread, the closure must be Send. The types it captures must be Send.

fn spawn_worker<F>(f: F)
where
    F: FnOnce() + Send + 'static,
{
    std::thread::spawn(f);
}

The Send bound on F ensures the closure can move to the new thread. If F captures a non-Send value, the compiler rejects the call. The error traces back to the captured value.

Treat UnsafeCell as a boundary. If you see it in a type definition, you need synchronization. UnsafeCell marks memory that can be mutated through shared references. That is the definition of a data race risk. The compiler assumes the worst. You must prove safety with a lock or atomic.

Decision matrix

Use Send when you need to transfer ownership of a value to a different thread. Use Sync when you need to share a reference to a value across multiple threads. Reach for Arc<T> when you need shared ownership across threads; Rc<T> is not Send. Reach for Mutex<T> or RwLock<T> when you need interior mutability across threads; RefCell<T> is not Sync. Implement Send or Sync manually only when you are building a safe wrapper around unsafe code and can prove thread safety with a SAFETY comment.

Send and Sync are not features you enable. They are guarantees the compiler enforces. They keep data races in the compile phase. Trust the borrow checker. If it says a type is not Send, there is a reason. Fix the type, don't fight the trait.

Where to go next