When the compiler blocks your thread
You're building a chat server. You spawn a thread to handle an incoming message. You grab the shared user list and try to pass it into the thread closure. The compiler stops you with a wall of red text. the trait Send is not implemented for Rc<UserList>. You didn't ask for Send. You just wanted to move data to a thread. Why is Rust yelling at you?
The error isn't a bug. It's a safety check. Rust refuses to let you move data across threads unless it can prove the data won't corrupt memory if two threads touch it at the same time. Send is the label that marks types as safe for transport. If your type lacks that label, the compiler blocks the shipment.
What Send actually means
Send is a marker trait. It has no methods. It's a property the compiler attaches to types that can be safely transferred to another thread. Most types get this property automatically. Integers, strings, vectors, and structs made of safe types all implement Send by default. You can move them to threads without thinking about it.
The problem appears when your type contains something that cannot be moved safely. Rc<T> is the usual suspect. Rc stands for reference counted. It tracks how many owners hold a value. When the last owner drops, the value is freed. Rc uses a simple integer for the count. It updates that integer with regular memory operations.
Regular memory operations are not atomic. If two threads update the same integer at the same time, the updates can interleave. The counter might skip a value or drop to zero early. If the counter drops to zero while a thread still holds a reference, the memory gets freed. The other thread dereferences freed memory. Undefined behavior strikes.
Rust prevents this by refusing to mark Rc as Send. You cannot move an Rc to another thread. The compiler catches this at compile time. You never get to the point where the race condition can happen.
Send is a promise. If you can't keep the promise, the compiler won't let you make the call.
Minimal example
This code tries to move an Rc into a new thread. It fails immediately.
use std::rc::Rc;
use std::thread;
fn main() {
// Rc is not Send. It uses non-atomic reference counting.
let data = Rc::new(vec![1, 2, 3]);
// thread::spawn requires the closure to be Send.
// The closure captures data by move due to the move keyword.
let _handle = thread::spawn(move || {
// Compiler error: the trait Send is not implemented for Rc<Vec<i32>>.
// Error code E0277 indicates a trait bound is not satisfied.
println!("{:?}", data);
});
}
The compiler rejects this with E0277 (trait bound not satisfied). It points to the thread::spawn call and tells you the closure isn't Send because it captures a non-Send value. The error message traces the chain: the closure needs Send, the closure captures data, data is Rc, Rc is not Send.
How the check works
The compiler analyzes the closure passed to thread::spawn. It sees the move keyword, so it knows the closure will take ownership of data. It checks the type of data. data is Rc<Vec<i32>>. The compiler looks up the Send implementation for Rc. It finds none. The closure inherits this limitation. thread::spawn has a bound requiring the closure to be Send. The bound fails. The error propagates up.
At runtime, if this were allowed, two threads could decrement the reference count simultaneously. The count might drop to zero while a thread still holds a reference. The memory gets freed. The other thread dereferences freed memory. The crash is unpredictable. It might work on your machine and fail in production. Rust eliminates this class of bugs by making the code refuse to compile.
Realistic fix: swap Rc for Arc
Real code rarely has a bare Rc. Usually, it's a struct field. You have a Config struct. It holds a name and a shared cache. The cache uses Rc because you shared it between functions in the main thread. Now you want to move the whole config to a worker thread. The Rc field poisons the whole struct.
The fix is to swap Rc for Arc. Arc stands for Atomic Reference Counting. It uses atomic instructions to update the counter. Atomics are safe across threads. Arc is Send. The struct becomes Send. The error vanishes.
use std::sync::Arc;
use std::thread;
/// Configuration for a worker thread.
/// Uses Arc to allow sharing across threads.
struct Config {
name: String,
// Arc is Send because it uses atomic operations for the reference count.
cache: Arc<Vec<String>>,
}
/// Spawns a worker thread with the config.
fn run_worker(config: Config) {
// The closure captures config by move.
// Config is Send because all its fields are Send.
let _handle = thread::spawn(move || {
println!("Worker started with cache size {}", config.cache.len());
});
}
fn main() {
let config = Config {
name: "worker-1".to_string(),
cache: Arc::new(vec!["item".to_string()]),
};
run_worker(config);
}
Convention aside: when cloning an Arc, write Arc::clone(&data) instead of data.clone(). Both work. The explicit form tells the reader you are cloning the pointer, not the data. It prevents confusion with deep clones. This is a community standard. Follow it to keep your code readable.
Swap the Rc for an Arc. The rest of your code usually stays the same.
Trait objects and Send
Trait objects add another layer. Box<dyn Trait> is not Send by default. If you try to send a Box<dyn Error> to a thread, you get the error. You need Box<dyn Error + Send>. This applies to Arc<dyn Trait> too. The compiler doesn't assume trait objects are thread-safe. You have to opt-in.
use std::sync::Arc;
use std::thread;
/// A trait for tasks that can run on threads.
trait Task {
fn run(&self);
}
struct PrintTask {
message: String,
}
impl Task for PrintTask {
fn run(&self) {
println!("{}", self.message);
}
}
fn main() {
// Box<dyn Task> is not Send.
// We need Box<dyn Task + Send> to move it to a thread.
let task: Box<dyn Task + Send> = Box::new(PrintTask {
message: "Hello from thread".to_string(),
});
let task = Arc::new(task);
let _handle = thread::spawn(move || {
// task is Arc<Box<dyn Task + Send>>.
// The + Send bound ensures the trait object is safe to share.
task.run();
});
}
If you forget the + Send bound on the trait object, the compiler complains that the trait object doesn't implement Send. The fix is to add the bound where you declare the trait object type. This tells the compiler that only types implementing Send can be boxed behind this trait object.
UnsafeCell and the root of Send
Under the hood, Send is blocked by UnsafeCell. UnsafeCell represents memory that can be mutated through a shared reference. If you can mutate memory through a shared reference, you can create data races. So UnsafeCell is not Send.
Rc contains an UnsafeCell for the reference count. That's why Rc isn't Send. Arc also contains an UnsafeCell, but Arc explicitly implements Send. It proves to the compiler that the atomic operations inside make it safe. This shows that Send isn't just about structure. It's about proof. Types can opt-out of auto-impl and provide their own implementation if they can guarantee safety.
RefCell is another common offender. RefCell uses UnsafeCell for interior mutability. It checks borrows at runtime. It is not Send. You cannot move a RefCell to another thread. If you need interior mutability across threads, use Mutex or RwLock. These types wrap the data in a lock. The lock ensures only one thread can mutate the data at a time. They are Send.
Generics and Send bounds
Generics can hide the problem. You write a function fn process<T>(data: T). It compiles. You call it with Rc. It compiles. You try to spawn a thread inside process. Now you need T: Send. The error moves to the call site or the function definition.
use std::thread;
/// Processes data in a new thread.
/// Requires T to be Send so it can be moved.
fn process_in_thread<T: Send + 'static>(data: T) {
let _handle = thread::spawn(move || {
// data is moved here.
// T: Send ensures this is safe.
// 'static ensures data doesn't borrow anything temporary.
println!("Processing data in thread");
});
}
fn main() {
// This works. i32 is Send.
process_in_thread(42);
// This fails. Rc is not Send.
// process_in_thread(std::rc::Rc::new(42));
}
Adding T: Send pushes the error to where the data is created. This is good. It forces the caller to provide thread-safe data. The 'static bound is also required for thread::spawn. It ensures the data doesn't contain references to stack variables that will be dropped when the function returns. Threads outlive the function call. They need data that lives forever or is owned.
Check the fields. The error points to the struct, but the culprit is often a single non-Send member hiding inside.
Decision matrix
Use Arc<T> when you need to share data across threads and multiple owners exist. Arc provides atomic reference counting, making it Send and Sync.
Use Rc<T> when you have multiple owners but stay on a single thread. Rc is faster because it avoids atomic overhead, but it is not Send.
Use Mutex<T> or RwLock<T> when you need to mutate shared data across threads. Arc only gives shared read access. Wrap the inner data in a lock to allow safe mutation.
Reach for ThreadLocal storage when each thread needs its own copy of data. If threads don't actually share the value, avoid the synchronization cost entirely.
Pick raw pointers (*const T or *mut T) only when you are building a safe abstraction or calling FFI. Raw pointers are Send and Sync by default, which is dangerous. You must manually enforce safety invariants.
Thread safety isn't a switch you flip. It's a property you build by choosing the right types from the start.