This Send/Sync Secret Separates Professional From Amateur Rust Developers
2 Months of Banging My Head Against Send/Sync, Explained in One Coffee Break
When I started learning Rust, everyone warned me about how hard ownership and borrowing would be. But honestly? Once I got my hands dirty, those concepts clicked much faster than expected.
That was, until I hit the traits Send and Sync—still ownership and borrowing but in the multithreaded context. I'd spend hours reading documentation, only to feel more lost than when I started. At one point, I genuinely thought about switching back to Go just to avoid the headache.
The official Rustonomicon definition only made things worse:
A type is Send if it's safe to send it to another thread.
A type is Sync if it's safe to share between threads.
This definition failed me badly because it told me what but never why. I could follow examples mechanically, but I couldn't reason about new types. Sure, I could memorize that Rc isn't Send and Arc usually is—but I had no idea why. I felt like I was just collecting random facts instead of actually understanding anything.
This motivates me to dig deeper and find the underlying principles that I will present to you today in this post.
Here's what finally clicked for me: these traits aren't arbitrary—they're Rust's way of encoding specific thread safety concerns into the type system. All those hours of confusion suddenly made sense when I stopped trying to memorize and started understanding the underlying danger. To grasp Send and Sync, we need to understand what makes threading dangerous in the first place.
Send and Sync Are About Thread Safety
The core danger in multithreaded programming is data races. A data race happens when multiple threads access the same memory at the same time, with at least one thread changing the data, and nothing to coordinate that access.
Picture a website hit counter at 1000 visits when two users arrive simultaneously. Both requests read "1000", both compute "1001", both write "1001". Expected result after two visitors: 1002. Actual result: 1001.
Rust's Send and Sync traits are designed to prevent data races by encoding thread safety rules directly into the type system:
Send: You can move this value to another thread without causing data races. (Think: unique ownership + thread safety)
Sync: Multiple threads can hold references to this value without causing data races. (Think: shared access + thread safety)
The key insight is that Send is about unique ownership across threads, while Sync is about shared access across threads. When you move a value (Send), you're transferring complete control to another thread. When you share references (Sync), multiple threads have access but need coordination to avoid conflicts.
Let's see how this plays out with concrete examples.
A Type That's Both Send and Sync
Consider String—it's both Send and Sync. Here's why this makes sense:
Send example:
// Send: Safe to move to another thread
let s = String::from("hello");
thread::spawn(move || {
println!("{}", s); // s is now owned by this thread
});
// s is no longer accessible in the original thread
Why this is safe: Moving gives the new thread unique ownership of the string's data. Since only one thread can access it at a time, there's no possibility of a data race. The original thread can't touch the string anymore, it's been completely transferred.
Sync example:
// Sync: Safe to share references across threads
let s = String::from("hello");
let s_ref = &s;
thread::scope(|scope| {
scope.spawn(|| {
println!("{}", s_ref); // Reading via shared reference
});
scope.spawn(|| {
println!("{}", s_ref); // Multiple threads can read simultaneously
});
});
Why this is safe: Multiple threads have shared access to the string, but they can only read it. Since nobody can modify the data (you'd need &mut s for that), the shared access is thread-safe. Multiple readers don't conflict with each other.
From this String example, we can extract a general pattern. Most types are both Send and Sync because:
Send: Moving creates unique ownership in the new thread. Since only one thread can access the data at any time, there's no possibility of conflicts.
Sync: Shared references only allow reading. Multiple threads reading the same data simultaneously doesn't cause problems.
This simple model works perfectly for basic types like i32, String, Vec<T>, and most structs you'll create. The thread safety comes from Rust's ownership rules: either one thread owns the data (Send), or multiple threads can read but none can write (Sync).
I really wish Rust's type system were this simple. But of course, Rust wouldn't let us off that easy. Interior mutability breaks all our nice assumptions about "move = unique ownership" and "shared = read-only." Let's dive in.
What Makes a Type !Sync
Here's where our simple "shared references = read-only" rule breaks down. Some types in Rust allow interior mutability—they let you change data even when you only have a shared reference. If you'd like a deeper explanation of interior mutability and why Rust needs it, let me know in the comments—I'll write a dedicated post about it. For now, just understand that it breaks the usual "shared reference = read-only" rule.
To understand why this is problematic, let's look at Cell<u64>:
use std::cell::Cell;
let cell = Cell::new(42);
let cell_ref = &cell; // Just a shared reference, no &mut
cell_ref.set(100); // But we can still modify the value inside!
println!("{}", cell_ref.get());
Notice what's happening here: we have a shared reference (&cell), not a mutable one (&mut cell), but we can still change the data inside through set(). This breaks Rust's usual rule that shared references are read-only.
Cell<T> exists because sometimes you need to modify simple values (like counters or flags) even when you only have shared access to the containing structure. It's particularly useful when you need to track simple state like hit counters, flags, or retry attempts within otherwise immutable structures. Cell allows you to replace the entire value atomically without complex borrowing.
Now here's the threading problem: Cell<u64> lets you change data through a shared reference, but it has no protection against multiple threads doing this at the same time. If two threads both tried to call set(), you'd get a data race:
// This WILL NOT compile
let cell = Cell::new(0);
thread::scope(|scope| {
// error[E0277]: `Cell<i32>` cannot be shared between threads safely
scope.spawn(|| cell.set(1)); // Thread 1 writing
scope.spawn(|| cell.set(2)); // Thread 2 writing simultaneously
});
That's why Cell<T> is !Sync, it can't be safely shared between threads. The interior mutability breaks our "shared references are safe" assumption.
But interior mutability doesn't automatically make a type !Sync. If the type can prevent data races, it can still be Sync. Some types solve this problem by adding their own synchronization. That's the case with AtomicU64 and Mutex<T>:
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
// AtomicU64: Uses hardware-level atomic operations
let atomic = AtomicU64::new(0);
thread::scope(|scope| {
scope.spawn(|| atomic.store(1, Ordering::Relaxed)); // Safe
scope.spawn(|| atomic.store(2, Ordering::Relaxed)); // Safe
});
// Mutex: Uses locking to serialize access
let mutex = Mutex::new(0);
thread::scope(|scope| {
scope.spawn(|| *mutex.lock().unwrap() = 1); // Safe
scope.spawn(|| *mutex.lock().unwrap() = 2); // Safe
});
Both types allow interior mutability with protection against data races, so they stay Sync. The key difference is that AtomicU64 uses hardware-level atomic operations that happen in a single step, while Mutex<T> makes threads take turns—only one thread can hold the lock at a time, so changes happen one after another instead of simultaneously.
The refined model for Sync:
If shared references can't change the data → Sync
If shared references can change the data without synchronization → !Sync
If shared references can change the data with synchronization (atomics, locks) → Sync
Now, time for !Send—let's go.
What Makes a Type !Send
You might think that Send would be simpler than Sync. After all, when you move a value to another thread, you're giving that thread complete ownership—what could go wrong?
The problem is that some types maintain hidden shared state even after being moved. The move operation transfers the obvious data, but there might be invisible connections to the original thread that create thread safety issues.
The classic example is Rc<T> (reference-counted pointer):
use std::rc::Rc;
let rc1 = Rc::new(42);
let rc2 = rc1.clone(); // Both point to the same data
// If we could send rc1 to another thread (we can't!)...
thread::spawn(move || {
drop(rc1); // Decrements reference count
});
// ...while rc2 exists on the original thread
drop(rc2); // Also decrements the same reference count
Here's what's happening: Rc<T> works by keeping a reference counter—when you clone an Rc, it increases the counter, and when you drop an Rc, it decreases the counter. When the counter reaches zero, the data gets cleaned up.
The critical insight: even after moving rc1 to another thread, you don't have unique ownership of the reference counter—it's still shared with rc2 on the original thread. Since Rc<T> uses a non-atomic reference counter, if two threads change that counter at the same time, you get a data race.
That's why Rc<T> is !Send, moving it doesn't actually create unique ownership of all the relevant data. There's hidden shared state (the reference counter) that remains unsafe.
So how do we fix this? How can we have reference counting that works across threads?
The solution is Arc<T> (atomic reference-counted pointer), which makes the shared reference counter thread-safe:
use std::sync::Arc;
let arc1 = Arc::new(42);
let arc2 = arc1.clone();
// This works because Arc makes the shared state thread-safe
thread::spawn(move || {
drop(arc1); // Atomically decrements counter
});
drop(arc2); // Also atomically decrements counter
Arc solves the problem by making the reference counter atomic, multiple threads can safely increment and decrement it simultaneously without data races.
Note: Arc<T> is only Send + Sync if T is also Send + Sync. For example, Arc<Cell<T>> is not Sync because Cell<T> isn't Sync. Arc makes the counter thread-safe, but the data it contains still depends on the inner type's thread safety properties. I won't dive too deep into this in this post—let me know in the comments if you'd like a dedicated post about it.
Note: There's another category of !Send types with thread-local constraints (like MutexGuard<T>), but Rc<T>-style shared state is the most common case you'll encounter.
The refined model for Send:
- If moving creates true unique ownership (no hidden shared state) → Send
- If moving still leaves shared state that isn't thread-safe → !Send
- If moving leaves shared state that is thread-safe → Send
Now that we understand the underlying principles, let's turn these insights into a practical decision-making tool.
Practical Decision Tree: Is Type X Send/Sync?
Instead of memorizing every type's Send/Sync status, you can reason through it systematically. Here's the mental framework I use, built from the principles we just explored:
Pro tip: You can always double-check by trying to use the type across threads. The compiler will tell you if it's not Send/Sync with a clear error message!
Key Takeaway
Here's the mental model that makes Send and Sync predictable:
They're Rust's way of encoding thread safety into the type system.
Send asks: "Can I move this to another thread without creating hidden shared state that isn't thread-safe?"
Sync asks: "Can multiple threads hold references to this without racing on mutations?"
When you think about it this way, the traits become logical rather than arbitrary. Rc<T> isn't Send because moving it still leaves shared (non-atomic) state with the original thread. Cell<T> isn't Sync because it allows unsynchronized mutation through shared references.
Once you internalize this mental model, you'll find yourself correctly predicting a type's Send/Sync status just by understanding what the type does. And when you design your own types, you'll naturally think about thread safety from the ground up.
What I didn't cover
This post focused on the most common Send/Sync patterns. There are edge cases like MutexGuard<T> (which is !Send due to thread-local constraints, not shared state) and raw pointers, but the mental model here handles 95% of real-world cases.
If you made it this far, you're probably as obsessed with understanding how things really work as I am. I'm Cuong, and I write about Rust and programming. If you share the same passion, I'd love to connect with you—feel free to reach out on X, LinkedIn , or subscribe to my blog (substack, medium) to keep pushing the boundaries together!
This is a great discussion around the internals and Send and Sync. Like you to this point I have read statements like Send can be sent across threads and Sync can be shared across threads but I never really understood why. Much clearer now thank you