Send and Sync

On this page

Send indicates that a value of a type can be safely transferred across threads, while Sync indicates that shared references can be safely passed across threads. They are compile-time contracts; they do not automatically add locks at runtime, nor do they require internal immutability.

Formally, T: Sync if and only if &T: Send. This definition reduces "sharing an object" to "sending a shared reference to it to another thread." Send, Sync

Thread A: Owns THand over the value to another threadThread B: Receives TRequires T: SendShare the same TMultiple threads hold &TPass &T across threadsRequires T: Sync, i.e., &T: SendTwo ways to use across threads: moving values and passing shared referencesThese traits allow the corresponding access patterns; they do not guarantee business atomicity, deadlock freedom, or low latency.

Conditions for Common Types

The table below discusses common default-allocator types, omitting some generic and lifetime details:

TypeSend ConditionSync ConditionReason
Vec, BoxT: SendT: SyncOwnership and borrowing propagate to inner elements
Cell, RefCellT: SendNot SyncCan be moved exclusively, but cannot be shared for arbitrary concurrent access
RcNot SendNot SyncNon-atomic reference counting may be accessed concurrently by clones
ArcT: Send + SyncT: Send + SyncReference counting is atomic; does not automatically synchronize T
MutexT: SendT: SendSerializes internal access via locks
RwLockT: SendT: Send + SyncShared read locks allow multiple threads to read T simultaneously
*const T, *mut TNot implemented by defaultNot implemented by defaultRaw pointers cannot automatically prove resource and thread contracts

RefCell<u32> is Send but not Sync, which shows that the two are not the same "thread-safety switch." Arc<RefCell<u32>> also cannot be shared across threads just because Arc is added: Arc only maintains the ownership count, and the borrow state of RefCell does not become synchronized.

Mutex<T>'s Sync only requires T: Send because the lock allows different threads to access T exclusively in sequence, rather than sharing read access to T simultaneously. std::sync::MutexGuard does not implement Send for platform portability, to avoid releasing a lock on another thread that some platforms require to be released by the same thread; this does not conflict with Mutex's own conditions.

Moving RefCell vs. Sharing a Reference to RefCell

use std::cell::RefCell;
use std::thread;
fn main() {
    let local = RefCell::new(vec![1, 2]);
    let returned = thread::spawn(move || {
        local.borrow_mut().push(3);
        local
    }).join().unwrap();
    assert_eq!(&*returned.borrow(), &[1, 2, 3]);
}

The main thread hands over ownership of the entire RefCell to the worker thread, which returns it upon completion; no two threads operate on its dynamic borrow state simultaneously, so RefCell<Vec<i32>> can satisfy Send. If instead you pass &local to another thread while retaining access in the original thread, RefCell would need to satisfy Sync, which it does not.

Rc has stricter restrictions: even if there happens to be only one strong reference at runtime, Rc<T>'s type does not temporarily implement Send because of it. Compile-time constraints cannot derive long-term ownership proof from a single count query. You can try to extract T when it is unique, and then pass T; or if you truly need to share across threads, choose Arc which satisfies the conditions.

Why Mutex Can Wrap Non-Sync T

use std::cell::RefCell;
use std::sync::Mutex;
use std::thread;
fn main() {
    let value = Mutex::new(RefCell::new(0u32));
    thread::scope(|scope| {
        for _ in 0..2 {
            let shared = &value;
            scope.spawn(move || {
                let guard = shared.lock().unwrap();
                *guard.borrow_mut() += 1;
            });
        }
    });
    assert_eq!(value.into_inner().unwrap().into_inner(), 2);
}

Mutex ensures that threads accessing RefCell enter the critical section sequentially, so RefCell itself does not need to be Sync here. This combination is used to demonstrate trait derivation; in practice, this counter only needs Mutex<u32>; once you have exclusive access via MutexGuard, adding another layer of RefCell is often unnecessary.

RwLock's read guard, however, allows multiple threads to obtain shared references to the interior simultaneously, so its Sync condition also requires T: Sync. You cannot use "it has a lock" as a reason to assume all lock types satisfy the same conditions.

Derive Send and Sync layer by layer

Use RefCell inside Arc and then Mutex, comparing Sync. What matters is how the wrapper accesses T: Arc allows shared access, while Mutex serializes exclusive access across threads. Switching T to Rc exposes the separate limitation that T itself is not Send.

Preparing the visual
Derive Send and Sync layer by layer

Arc does not make a non-Sync value shareable; Mutex is Sync when T is Send.

Deriving via Compiler Checks

use std::cell::RefCell;
use std::sync::{Arc, Mutex};

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

fn main() {
    assert_send::<RefCell<u32>>();
    assert_send::<Arc<String>>();
    assert_sync::<Arc<String>>();
    assert_sync::<Mutex<RefCell<u32>>>();
    // assert_sync::<RefCell<u32>>(); // Does not satisfy Sync
    // assert_send::<Arc<RefCell<u32>>>(); // Arc does not supplement internal synchronization
}

The structural derivation of auto traits is the default rule, but it is also influenced by explicit implementations on the type itself. It is not true that "except for raw pointers, Rc, and a few Cells, all other types are definitely Send/Sync"; platform handles, thread-local objects, and third-party abstractions may also have restrictions.

Send and 'static Are Different Axes

Ordinary thread::spawn usually requires the closure to be Send + 'static. Send governs cross-thread usage, while 'static governs captured borrows, ensuring they do not outlive the independent thread; 'static does not mean the value must live forever.

Moving a String into a closure satisfies the condition of having no short-lived borrows, but moving a &str only moves the reference, without extending the lifetime of the referenced object. For threads that are guaranteed to complete before the scope ends, you can use thread::scope to borrow outer data; see Threads and Synchronization.

Whether an async Future is Send also depends on the state it may hold. Non-Send values surviving across suspension points often prevent the Future from being spawned on a cross-thread scheduler; do not casually use unsafe impl Send just to eliminate errors. You can narrow the scope of values or lock guards, or use local task execution mechanisms as needed.

Scoped Threads Relax Lifetimes, Not Send/Sync

use std::thread;
fn main() {
    let mut values = [1, 2, 3, 4];
    thread::scope(|scope| {
        let (left, right) = values.split_at_mut(2);
        scope.spawn(move || { for value in left { *value *= 10; } });
        scope.spawn(move || { for value in right { *value += 1; } });
    });
    assert_eq!(values, [10, 20, 4, 5]);
}

split_at_mut establishes non-overlapping regions, and threads obtain exclusive references to them; scope ensures these threads finish before leaving the scope, allowing borrowing of the local array. This avoids copying the entire array and does not require Arc to extend its ownership. Scoped Threads

However, scope does not make Rc Send, nor does it make &RefCell<T> safely shareable across threads. It solves whether borrows can remain valid, not the synchronization of shared access.

What is transferredCore condition required
TT: Send
&TT: Sync
&mut TT: Send; the original thread cannot simultaneously use this exclusive borrow
Closure with borrowsCaptured actual fields satisfy thread requirements; borrows cover the thread execution period

Future State Depends on Suspension Points and Initial Captures

use std::rc::Rc;
fn assert_send<T: Send>(_: T) {}
async fn local_work() {
    {
        let local = Rc::new(String::from("temporary"));
        assert_eq!(local.len(), 9);
    }
    std::future::ready(()).await;
}
fn main() {
    assert_send(local_work());
}

Here, Rc is created after the async function starts being polled, and its usage ends and it is released before the await, so the Future can satisfy Send. The example only checks the Future's type, without running the executor; assertions in the function body only execute when polled.

If Rc is captured via async move from outside, the Future owns it before the first poll. Even if the function body prepares to drop it before the first await, you cannot simply apply "no cross-await means Send." When diagnosing, check initial captures, local values saved during suspension, and return value requirements simultaneously. Specific runtime spawn implementations may also require Future::Output: Send, which is a separate constraint from the Future itself being Send.

PhantomData Expresses Logical Relationships, Not Synchronization

use std::marker::PhantomData;
use std::rc::Rc;

struct ThreadBound {
    handle: usize,
    _thread_bound: PhantomData<Rc<()>>,
}
fn main() {
    let resource = ThreadBound { handle: 7, _thread_bound: PhantomData };
    assert_eq!(resource.handle, 7);
}

Even if handle is just an integer, the marker expresses that this wrapper should not automatically be Send/Sync. PhantomData does not store a real Rc, but it participates in the derivation of auto traits, variance, and drop checks; changing the PhantomData type may simultaneously change multiple meanings, not just toggle a single thread attribute button.

Conversely, adding PhantomData<T> does not automatically prove Send for a container containing raw pointers. When you really need to write unsafe impl, you must prove that all public operations, callbacks, and drops involving moving/sharing adhere to thread contracts, rather than claiming "the underlying library is probably thread-safe."

Entry Requirementspawn requires Send + lifetimeClosure / Future CaptureIs it capturing T, &T, or Arc<T>?Specific FieldsCounts, borrows, affinityEncountering thread constraint errors, locate layer by layer along the actual passed typesAdjust ownership, borrow scope or synchronizationunsafe impl requires proof; it does not fix the ownership modelThe final non-Send field reported by the compiler is usually the most valuable; the outer error location is not necessarily where the design problem lies.

unsafe impl Must Cover Drops and Aliasing, Not Just One Read/Write Operation

When wrapping external handles, even if all methods are locked, if the underlying requirement is to destroy the handle on the thread that created it, allowing the owner to move to any thread may still violate the contract. Similarly, if a method returns an internal pointer that bypasses synchronization, or if a callback can access the same state outside the lock, it breaks the seemingly protected model.

You should check item by item: who owns the resource, what aliases can exist, which thread performs the drop, and whether all public operations maintain the same rules. If the underlying API is inherently thread-bound, you should send tasks back to the thread owning the handle, or retain an explicit non-Send wrapper. Hand-written unsafe impl must have proofs consistent with these paths, not just citing "used Mutex."

Compiling Does Not Equal Concurrent Business Correctness

Arc<Mutex<Account>> can avoid unsynchronized data access, but it does not automatically turn operations across two accounts into atomic transactions, nor does it eliminate lock-order deadlocks. Sync's requirement for interior mutability is for safe synchronization, not to guarantee that every group of multi-step operations satisfies business invariants.

When designing interfaces, first decide on sharing, transferring, or message passing, and then let trait conditions reflect the actual model. Send/Sync errors usually indicate that some resource or lifetime decision has not yet been clarified.