When the compiler blocks your thread
You write a struct to hold a cache. It works perfectly in main. You decide to speed things up by moving the cache to a background thread. The compiler screams. You didn't change the logic. You just added thread::spawn. The error mentions Send. You've hit the marker traits.
Rust uses marker traits to enforce safety rules at compile time. They don't define methods. They don't add behavior. They are flags that tell the compiler how a type interacts with concurrency and memory management. Send, Sync, and Unpin are the three flags you will encounter most often. Getting them wrong stops your code from compiling. Getting them right keeps your program safe from data races and memory corruption.
Marker traits are flags, not tools
A regular trait like Display gives you a .fmt() method. A marker trait has no methods. It exists only to be implemented or not implemented. The compiler checks for the trait to decide if an operation is allowed.
Think of marker traits like security badges. A badge doesn't teach you how to enter the building. It just proves you have clearance. The guard checks the badge. If you have the right badge, the door opens. If not, you stay out.
Send is the badge for moving a value to another thread. Sync is the badge for sharing a reference across threads. Unpin is the badge for moving a value after it has been pinned in memory.
You rarely implement these traits manually. The compiler derives them automatically based on the fields of your type. If all fields are Send, your struct is Send. If any field is !Send, your struct is !Send. This composition rule makes reasoning about safety straightforward. You only touch the traits directly when you use raw pointers or UnsafeCell.
Marker traits are metadata. Treat them as such. You assert them, you check for them, but you never call a method on them.
Send: moving ownership across threads
Send means a type can be transferred from one thread to another. When you move a value into a thread::spawn closure, the compiler requires Send. The value leaves the current thread and arrives in the new thread. The old thread no longer has access.
use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(42);
// Error: the trait `Send` is not implemented for `Rc<{integer}>`
thread::spawn(move || {
println!("Data: {}", data);
});
}
The compiler rejects this with E0277 (the trait Send is not implemented for Rc<T>). Rc is not Send. The reference count inside Rc is a plain integer. If two threads increment the counter simultaneously, you get a data race. The counter could become corrupted. Memory could leak or double-free. Rc refuses to be Send to prevent this.
Use Arc instead. Arc stands for Atomic Reference Counted. It uses atomic operations to update the counter. Atomics are safe across threads. Arc implements Send.
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(42);
// Arc is Send. The move is allowed.
let handle = thread::spawn(move || {
println!("Data: {}", data);
});
handle.join().unwrap();
}
The move keyword forces the closure to take ownership of data. The compiler checks that Arc<i32> is Send. It is. The code compiles. The value travels safely to the new thread.
Convention aside: Arc::clone(&data) is the preferred way to clone an Arc. Writing data.clone() works, but it looks like a deep clone to readers familiar with other languages. The explicit form signals that you are bumping a reference count, not copying the underlying data.
Send is about ownership transfer. If you can move it, it's Send. If moving it would break safety, it's !Send.
Sync: sharing references across threads
Sync is the trickier trait. It doesn't mean the type itself can be sent. It means a reference to the type can be sent. The definition is precise: T: Sync is equivalent to &T: Send.
If T is Sync, you can create a &T in one thread and pass that reference to another thread. Multiple threads can hold &T at the same time. The type must be safe for concurrent reads.
Most types are Sync by default. Integers, strings, structs of sync fields. They are immutable or have no interior mutability. Reading an integer from multiple threads is safe.
use std::sync::Arc;
use std::thread;
struct Config {
name: String,
max_retries: u32,
}
fn main() {
// Config is Sync because String and u32 are Sync.
let config = Arc::new(Config {
name: "worker".to_string(),
max_retries: 3,
});
let handle = thread::spawn(move || {
// &config is Send because Config is Sync.
println!("Config: {}", config.name);
});
handle.join().unwrap();
}
Arc shares ownership. Inside the thread, you access config through the Arc. The Arc dereferences to &Config. Since Config is Sync, &Config is Send. The reference travels safely.
Types with interior mutability break Sync. Cell and RefCell allow mutation through shared references. If Cell were Sync, two threads could mutate the same Cell simultaneously. That's a data race. Cell is !Sync. Any struct containing a Cell is !Sync.
use std::cell::Cell;
use std::sync::Arc;
struct Counter {
value: Cell<u32>,
}
fn main() {
let counter = Arc::new(Counter { value: Cell::new(0) });
// Error: the trait `Sync` is not implemented for `Counter`
thread::spawn(move || {
counter.value.set(1);
});
}
The compiler rejects this with E0277. Counter contains Cell, so it is !Sync. You cannot share &Counter across threads.
Reach for Mutex when you need shared mutable state. Mutex serializes access. Only one thread can mutate at a time. Mutex is Sync even though it contains UnsafeCell. The standard library uses unsafe impl Sync for Mutex because the lock guarantees safety. This is a rare case where manual implementation is necessary and correct.
Sync is about shared access. If multiple threads can read safely, it's Sync. If mutation is possible through a reference, it's !Sync unless protected by synchronization primitives.
Unpin: keeping self-referential types in place
Unpin deals with memory movement. Most types are Unpin. You can move them anywhere. You can copy them. You can reallocate them. Unpin is the default.
!Unpin types cannot move after they are pinned. Pinning is a promise that the value will stay at a fixed memory address. If a type is !Unpin, breaking that promise is undefined behavior.
Why would a type refuse to move? Self-referential structs. A struct that holds a pointer to its own data. If you move the struct, the pointer becomes stale. It points to the old address. The data is now elsewhere. The pointer is dangling.
Async futures are the main source of !Unpin types. A future might hold a buffer and a pointer into that buffer. The future is self-referential. Moving it breaks the pointer. The Pin type enforces that the future stays put.
use std::pin::Pin;
async fn fetch_data() -> String {
"result".to_string()
}
fn main() {
let future = fetch_data();
// Pin the future. It cannot move out of this Pin.
let mut pinned = Pin::new(&mut future);
// If future were !Unpin, you couldn't do this:
// let _ = pinned; // Error: cannot move out of pinned value
}
Most futures are Unpin in practice. The compiler often optimizes away the self-reference. But the trait system must account for the worst case. Unpin is a marker that says "I have no internal pointers that break on move."
Convention aside: Writing raw Pin logic is error-prone. Use the pin-project crate. It provides a #[pin_project] macro that generates safe projection methods. You define the fields, the macro handles the pinning. This is the community standard for custom async types.
Unpin is about stability. If a type has internal pointers, it's !Unpin. Pin it before you use it. If it's Unpin, pinning is a no-op. You can pin anything. You just can't move !Unpin things after pinning.
The UnsafeCell trap
UnsafeCell is the root of interior mutability. It is the only way to get mutable access through a shared reference. Cell, RefCell, and Mutex all wrap UnsafeCell.
UnsafeCell is !Sync. The compiler marks any type containing UnsafeCell as !Sync. This prevents data races on interior mutable state.
This rule causes friction. Mutex uses UnsafeCell to store its data. The mutex lock protects the data. It is safe to share Mutex across threads. But the compiler sees UnsafeCell and marks Mutex as !Sync.
The standard library solves this with unsafe impl Sync. The implementation asserts that the mutex lock makes the UnsafeCell safe. This is a manual override of the auto-derive rule.
// Simplified view of Mutex internals
struct Mutex<T> {
// UnsafeCell blocks Sync automatically.
data: UnsafeCell<T>,
lock: LockState,
}
// Manual impl required because of UnsafeCell.
// SAFETY: The lock serializes access to data.
// Only one thread can hold a MutexGuard at a time.
// Concurrent reads are prevented by the lock.
unsafe impl<T> Sync for Mutex<T> {}
You should rarely write unsafe impl Send or unsafe impl Sync. If you do, you must write a // SAFETY: comment that proves the implementation is correct. The comment is a proof. If you can't write it, you don't have one.
Raw pointers are also !Send and !Sync. A raw pointer doesn't track ownership. Moving it or sharing it could lead to double-free or use-after-free. Types containing raw pointers must manually implement Send and Sync if they are safe to do so.
The compiler is conservative. It blocks Send and Sync when it sees UnsafeCell or raw pointers. You must convince it otherwise with unsafe impl. Treat that block as a contract with future maintainers.
Pitfalls and compiler errors
The most common error is E0277. This code means "trait bound not satisfied." You will see it for Send, Sync, and Unpin.
The compiler rejects this with E0277 (the trait Send is not implemented for Rc<T>). The error message points to the type that fails the bound. It often suggests wrapping the type in Arc or Mutex.
Another pitfall is confusing Send and Sync. T: Send does not imply T: Sync. T: Sync does not imply T: Send. They are independent flags.
Rc is !Send and !Sync. Arc is Send and Sync. Cell is Send but !Sync. You can move a Cell to another thread. You cannot share a &Cell across threads.
UnsafeCell is Send but !Sync. You can move it. You cannot share it.
Be careful with Box. Box<T> is Send if T is Send. Box<T> is Sync if T is Sync. The box doesn't change the traits. It just allocates on the heap.
Generic bounds can hide issues. A function might require T: Send. If you pass a type that isn't Send, the error appears at the call site. The error message will show the full type path. Trace it back to the field that breaks the bound.
Don't fight the compiler here. If you need Send, use Arc. If you need Sync with mutation, use Mutex. If you need Unpin, use pin-project. The standard library provides the tools. Reach for them.
Decision matrix
Use Send when you transfer ownership to another thread via thread::spawn or a channel. Use Sync when you share a reference &T across threads, typically by wrapping T in Arc. Use Unpin when you are implementing a custom future or a self-referential struct that must stay at a fixed memory address. Reach for Arc<T> when you need thread-safe reference counting; it implements both Send and Sync. Reach for Mutex<T> when you need shared mutable state across threads; it provides Sync by serializing access. Reach for Rc<T> when you are in a single-threaded context and want to avoid the overhead of atomic operations. Reach for Cell<T> when you need fast interior mutability in a single thread; it is Send but not Sync. Reach for pin-project when you need to work with Pin and !Unpin types; manual pinning is too easy to get wrong.
Trust the borrow checker. It usually has a point. Marker traits are the extension of that checking into concurrency. If the compiler blocks you, there is a reason. Fix the type, not the trait.