Send and Sync
Every earlier lesson in this section showed you how to spawn threads and share data with Arc and Mutex. This lesson explains the mechanism that makes those tools safe in the first place: two special compiler-checked traits called Send and Sync. They are the reason Rust can promise “data races are a compile-time error” instead of a runtime crash you discover in production. Understanding them turns confusing error messages like “cannot be sent between threads safely” from a wall into a clear signal.
Overview: How Send and Sync Work
Rust’s ownership and borrowing rules (single owner, either one mutable reference or many immutable ones) are enforced by the compiler within a single thread. But threads break the simple picture: once you spawn a thread, a value can end up owned by, or referenced from, code running on a completely different call stack, possibly at the exact same instant as your original thread is still running. The borrow checker’s normal rules do not automatically know whether that is safe for a given type. Send and Sync are how the type system extends its safety guarantees across that boundary.
Think of a value as a physical parcel. Send answers the question: “can I hand this parcel to a courier and let them carry it to another building (thread), with the guarantee that I will never touch it again?” That is an ownership transfer — only one side has access at a time, so if the transfer itself is safe, there is no conflict. Sync answers a different question: “can two buildings look at the same parcel through a shared window at the same time without stepping on each other?” That is concurrent read access to one piece of data. Formally, a type T is Sync exactly when &T (a shared reference to T) is itself Send — being able to send a reference to other threads is what “safe to share” means in Rust’s vocabulary.
Both traits are marker traits: they have no methods, no associated types, nothing to implement by hand in the normal case. They exist purely so the compiler has something to check against. And both are auto traits: the compiler automatically implements Send for a type if every field inside it is Send, and automatically implements Sync if every field is Sync. A plain struct made of Strings and u32s gets both for free, with zero annotations. The traits only become interesting when a type deliberately opts out, which happens for types built around raw pointers or non-atomic shared state, because those are exactly the cases where crossing a thread boundary really is unsafe.
Concretely: Rc<T> (Reference Counted, single-threaded) increments and decrements a plain, non-atomic integer every time you clone or drop it. If two threads did that at once, the count could get corrupted, silently leaking memory or freeing a value that is still in use — a data race. So the standard library marks Rc<T> as neither Send nor Sync, and the compiler refuses to let you move or share one across a thread boundary. Arc<T> (Atomically Reference Counted) does the same job but with a hardware-level atomic counter, which is safe under concurrent access, so it is Send and Sync whenever the data inside it is. This single distinction — non-atomic vs. atomic bookkeeping — is why the course keeps telling you to reach for Arc, not Rc, the moment threads are involved.
Syntax
You will almost never write Send or Sync yourself; you mostly satisfy them implicitly. The traits themselves live in std::marker and are conceptually declared like this:
pub unsafe auto trait Send {}
pub unsafe auto trait Sync {}
unsafe trait— implementing it manually is an unsafe promise to the compiler that you have verified the thread-safety property yourself; the compiler cannot check it for you in that case.auto trait— a compiler-internal feature (not available to your own traits on stable Rust) that letsSend/Syncbe implemented automatically based on a type’s fields, with noimplblock required.- No methods — the empty
{}body means these traits carry no behavior; they exist only as compile-time facts the compiler can check with trait bounds likeT: Send.
In practice, the syntax you interact with is a trait bound on a generic function, most importantly on thread::spawn itself (covered below), or an explicit opt-out/opt-in via impl !Send for MyType {} / unsafe impl Send for MyType {} in advanced, unsafe code.
Examples
Example 1: Sending an owned value into a thread
The simplest case: move a String into a spawned thread, use it there, and send a result back through the JoinHandle.
use std::thread;
fn main() {
let message = String::from("hello from the main thread");
let handle = thread::spawn(move || {
println!("Worker received: {}", message);
message.len()
});
let length = handle.join().unwrap();
println!("Message length was {} bytes", length);
}
Output:
Worker received: hello from the main thread
Message length was 26 bytes
String is Send because its only heap pointer is uniquely owned — once move hands it to the closure, the main thread’s binding to message is gone, so there is no way two threads can touch that heap allocation at once. The compiler checks this: if message were, say, an Rc<String> instead, this exact code would fail to compile.
Example 2: Sharing mutable state safely with Arc and Mutex
To let multiple threads touch the same data, you need Sync (safe concurrent access) plus interior mutability, since the borrow checker’s normal mutable-reference rules cannot span threads. Arc<Mutex<T>> is the standard combination.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..5 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", *counter.lock().unwrap());
}
Output:
Final count: 5
Arc::clone bumps an atomic counter and hands out a new handle to the same Mutex<i32> — that clone is Send, so it can move into each new thread. Mutex<T> is Sync whenever T: Send, because the lock guarantees only one thread can access the inner value at a time, turning what would be a data race into safe, serialized access. Every thread increments once; all five are joined before the final read, so the printed count is always exactly 5.
Example 3: A custom struct is Send and Sync automatically
You do not need to opt in manually for your own types — if every field already implements the traits, your struct does too.
use std::sync::Arc;
use std::thread;
struct Config {
name: String,
max_connections: u32,
}
fn main() {
let config = Arc::new(Config {
name: String::from("prod-server"),
max_connections: 100,
});
let mut handles = Vec::new();
for id in 0..3 {
let config = Arc::clone(&config);
handles.push(thread::spawn(move || {
format!(
"Thread {} sees config '{}' with max_connections = {}",
id, config.name, config.max_connections
)
}));
}
for handle in handles {
let message = handle.join().unwrap();
println!("{}", message);
}
}
Output:
Thread 0 sees config 'prod-server' with max_connections = 100
Thread 1 sees config 'prod-server' with max_connections = 100
Thread 2 sees config 'prod-server' with max_connections = 100
Config contains only a String and a u32, both Send and Sync, so the compiler auto-derives both traits for Config with no code from you. That is what lets Arc<Config> itself be Send and Sync, and be shared read-only across three threads. Each thread returns a String instead of printing directly, and the loop joins handles in order, so the output order is deterministic even though the threads themselves may finish in any order.
How It Works Step by Step
The trait bounds actually enforcing all of this live on thread::spawn‘s signature:
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
// ...
}
- When you call
thread::spawn(closure), the compiler infers the closure’s captured-variable types from whatever it references. - It checks the closure type against
F: Send. This recursively requires every captured variable to beSend— if any capture (like anRc) is not, compilation stops right there with a trait-bound error. - It checks
F: 'static, meaning the closure cannot borrow any data that might be dropped before the spawned thread finishes — this is why you almost always seemoveclosures withthread::spawn: withoutmove, the closure tries to borrow local variables by reference, and those references are not'static. - The closure runs on the new thread and produces a value of type
T, which must itself beSend + 'staticso it can be safely handed back through theJoinHandlewhen you call.join(). - None of this happens at runtime. If your program compiles, the
Send/Syncchecks already passed — there is no possibility of a “forgot to lock” data race for types the compiler tracked correctly, which is a strong guarantee C and C++ threading code cannot make.
Reference: Common Types and Their Traits
| Type | Send? | Sync? | Why |
|---|---|---|---|
i32, bool, String |
Yes | Yes | Owned, no shared mutable state |
Rc<T> |
No | No | Non-atomic reference count |
Arc<T> (T: Send + Sync) |
Yes | Yes | Atomic reference count |
RefCell<T> (T: Send) |
Yes | No | Interior mutability checked only at runtime, not thread-safe |
Mutex<T> (T: Send) |
Yes | Yes | Lock serializes concurrent access |
Raw pointers *const T/*mut T |
No | No | No safety guarantees at all by default |
Common Mistakes
Mistake 1: Sending an Rc across threads
Trying to move an Rc into a spawned thread is one of the most common first errors:
use std::rc::Rc;
use std::thread;
fn main() {
let shared = Rc::new(5);
let handle = thread::spawn(move || {
println!("{}", shared);
});
handle.join().unwrap();
}
This fails with an error like Rc<i32> cannot be sent between threads safely, because Rc‘s reference count is not atomic and the trait bound F: Send on thread::spawn catches it before anything runs. The fix is to use Arc instead, which uses an atomic counter and is Send:
use std::sync::Arc;
use std::thread;
fn main() {
let shared = Arc::new(5);
let handle = thread::spawn(move || {
println!("Value seen from worker thread: {}", shared);
});
handle.join().unwrap();
}
Output:
Value seen from worker thread: 5
Mistake 2: Mutating through Arc without a Mutex
Sync only means shared access is safe — it does not mean mutable access is allowed. Arc<T> only derefs to &T, never &mut T, even inside a single thread:
use std::sync::Arc;
use std::thread;
fn main() {
let counter = Arc::new(0);
let handle = thread::spawn(move || {
*counter += 1;
});
handle.join().unwrap();
}
This fails with cannot assign to data in an Arc<i32>, which is behind a & reference. Arc alone never grants mutation — you need interior mutability, typically a Mutex, as shown in Example 2 above, so the lock (not the borrow checker) enforces exclusive access at runtime.
Mistake 3: Forgetting move and borrowing non-‘static data
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("{:?}", data);
});
handle.join().unwrap();
}
Without move, the closure tries to borrow data by reference. That reference is tied to main‘s stack frame, but the spawned thread could theoretically outlive main‘s local scope, so it fails the F: 'static bound with an error like closure may outlive the current function. Adding move transfers ownership of data into the closure instead of borrowing it, satisfying 'static:
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("{:?}", data);
});
handle.join().unwrap();
}
Output:
[1, 2, 3]
Advanced: Implementing Send/Sync Manually
Occasionally a type built on a raw pointer really is safe to move between threads, but the compiler cannot know that automatically, because raw pointers are !Send and !Sync by default (to be conservative). In that rare case, and only when you have personally verified the safety, you can opt back in with unsafe impl:
struct NotThreadSafe {
inner: *mut i32,
}
// SAFETY: NotThreadSafe only ever accesses `inner` from a single
// thread at a time, guaranteed by the surrounding application logic.
unsafe impl Send for NotThreadSafe {}
The unsafe keyword here is a promise to the compiler, not a promise the compiler checks — get it wrong and you reintroduce the exact data races Send/Sync exist to prevent. This pattern shows up inside low-level libraries (custom allocators, FFI wrappers) far more than in application code; reach for Arc, Mutex, and channels first.
Best Practices
- Default to
ArcoverRcthe moment a value might cross a thread boundary, even if you are not sure yet — the cost of atomic operations is small compared to the cost of a compile error later. - Combine
ArcwithMutex(orRwLockfor read-heavy workloads) when threads need to mutate shared state;Arcalone only ever gives shared, read-only access. - Let the compiler’s error messages guide you: “cannot be sent between threads safely” almost always means swap an
Rc/RefCellforArc/Mutex. - Prefer
moveclosures withthread::spawnby default; it is usually what you want, and it sidesteps'staticlifetime errors entirely. - Avoid manual
unsafe impl Send/Syncunless you are writing low-level infrastructure and can prove the invariant yourself — a wrong implementation is undefined behavior, not a compile error. - For newer code, consider
std::thread::scope, which lets scoped threads safely borrow non-'staticdata from the spawning function, removing the need forArcin some simpler sharing patterns.
Practice Exercises
- Write a program that spawns three threads, each computing the square of a different number (use a plain
Vec<i32>of inputs and pass one value to each thread by move), and prints all three results after joining. - Take the Example 2 counter program and change
Mutex<i32>toMutex<Vec<i32>>, having each of five threads push its own thread index into the vector instead of incrementing a number. Print the final vector’s length after joining all threads. - Try wrapping a value in
Rc<RefCell<i32>>and moving it into a spawned thread. Read the exact compiler error, then fix it by switching toArc<Mutex<i32>>and confirm it compiles.
Summary
Sendmeans a value can be safely transferred to another thread;Syncmeans a reference to it can be safely shared between threads at once.- Both are marker traits with no methods, auto-implemented by the compiler when every field of a type is itself
Send/Sync. Rc<T>is neitherSendnorSyncbecause its reference count is not atomic;Arc<T>is both because its count uses atomic operations.thread::spawnrequires its closure to beSend + 'static, which is why captured data is usually moved in with themovekeyword rather than borrowed.Syncalone does not grant mutation — pair shared access (Arc) with interior mutability (Mutex,RwLock) when threads need to write shared state.- Every Send/Sync violation is caught at compile time, not at runtime, which is a large part of why Rust can promise data-race-free concurrent code.
