Introduction
Knowing what Send and Sync mean is only the first step. In practice, you need patterns for controlling these traits in your own types, choosing the right concurrency primitive, and auditing thread safety. This lesson covers the essential patterns that experienced Rust developers use daily: PhantomData for trait control, newtype wrappers, negative trait implementations, and the decision tree for choosing between Arc<Mutex<T>> and Arc<RwLock<T>>.
Key Concepts
- PhantomData: A zero-sized type that tells the compiler your struct logically "contains" a type
Twithout actually storing it. By embeddingPhantomData<*const T>(a raw pointer, which is!Send + !Sync), you can opt a type out ofSendandSyncauto-implementation. - Newtype pattern: Wrapping a type in a single-field struct to change its
Send/Syncbehavior. Useful when you need to addSendorSyncto a foreign type or restrict it. - Negative impls:
impl !Send for Type {}andimpl !Sync for Type {}explicitly opt a type out ofSendorSync. These require nightly Rust (thenegative_implsfeature), so stable code usesPhantomDataas the alternative. - Interior mutability and Sync: Any type that allows mutation through a shared reference (
&self) must provide its own synchronization to beSync.UnsafeCell<T>is the fundamental building block — it is!Sync, and types likeCell,RefCell,Mutex, andRwLockall build on top of it. - Scoped threads and 'static:
thread::spawnrequires'staticdata, butthread::scoperelaxes this requirement by guaranteeing all threads join before the scope exits. This eliminates the need forArcin many fork-join patterns.
Real World Context
Library authors frequently need to control Send/Sync for wrapper types around FFI handles or hardware resources. A GPU buffer handle, for example, must not be sent to another thread if the graphics API is single-threaded. Using PhantomData<*const ()> ensures the compiler enforces this. Application developers face the Arc<Mutex<T>> vs Arc<RwLock<T>> decision daily — choosing wrong can halve throughput or introduce unnecessary complexity.
Deep Dive
PhantomData for Send/Sync Control
PhantomData is a zero-sized marker that affects trait auto-implementation without adding any runtime cost.
rustuse std::marker::PhantomData; // This struct wraps a raw file descriptor. // It should NOT be Send or Sync because the FD is tied to the current thread's I/O context. struct ThreadLocalHandle { fd: i32, _not_send_sync: PhantomData<*const ()>, // *const () is !Send and !Sync, so ThreadLocalHandle becomes !Send and !Sync. } impl ThreadLocalHandle { fn new(fd: i32) -> Self { Self { fd, _not_send_sync: PhantomData, } } } // Verify at compile time: fn assert_not_send<T>() where T: Send {} // This would fail for ThreadLocalHandle // assert_not_send::<ThreadLocalHandle>(); // compile error: not Send
By including PhantomData<*const ()>, which is !Send and !Sync, the compiler treats the entire struct as if it contains a raw pointer, preventing it from being moved between threads or shared via references.
You can also use PhantomData to make a type !Send but still Sync, or vice versa, by choosing the right phantom type.
rustuse std::marker::PhantomData; // !Send but Sync: use PhantomData<std::sync::MutexGuard<'static, ()>> // MutexGuard is !Send but Sync. struct PinnedToThread { data: i32, _no_send: PhantomData<std::sync::MutexGuard<'static, ()>>, } // PinnedToThread is !Send (can't move between threads) // but IS Sync (can share &PinnedToThread between threads)
This technique gives you precise control over which traits are opted out, since different phantom types carry different trait implementations.
Newtype Pattern for Thread Safety
The newtype pattern wraps an existing type to change its thread safety characteristics.
rustuse std::sync::Arc; // Suppose you have a C library handle that you've audited and know is thread-safe, // but Rust marks it as !Send because it contains a raw pointer. struct FfiHandle { ptr: *mut std::ffi::c_void, } // Newtype wrapper that asserts thread safety struct ThreadSafeHandle(FfiHandle); // SAFETY: The underlying C library is thread-safe. // All functions that operate on this handle use internal locking. unsafe impl Send for ThreadSafeHandle {} unsafe impl Sync for ThreadSafeHandle {} // Now ThreadSafeHandle can be used with Arc and sent between threads. let handle = Arc::new(ThreadSafeHandle(FfiHandle { ptr: std::ptr::null_mut() }));
The newtype wrapper isolates the unsafe Send/Sync implementations to a single type with a clear safety comment, keeping the rest of your codebase safe.
Auditing a Type for Thread Safety
When deciding whether to implement Send or Sync for a type, ask these questions in order.
rust// Thread safety audit checklist: // // 1. Does the type contain raw pointers or references to non-Send/Sync types? // -> If yes, can you PROVE the pointed-to data is safe to access from other threads? // // 2. Does the type have interior mutability (mutation through &self)? // -> If yes, is that mutation synchronized (atomics, locks)? // -> If not synchronized, the type must be !Sync. // // 3. Does the type interact with thread-local state, signal handlers, or OS handles? // -> If yes, it should probably be !Send. // // 4. Does the type have a destructor that must run on a specific thread? // -> If yes, it must be !Send. // Example audit: a connection pool struct ConnectionPool { connections: std::sync::Mutex<Vec<Connection>>, max_size: usize, } // Audit: // - Contains Mutex<Vec<Connection>>: Mutex is Send+Sync if Connection is Send. ✓ // - max_size is usize: Send+Sync. ✓ // - No raw pointers, no thread-local state, no thread-specific destructor. // Result: ConnectionPool is automatically Send+Sync (if Connection: Send). ✓
Walking through this checklist for every type that wraps raw pointers or FFI handles ensures you make an informed decision about thread safety rather than blindly adding unsafe impl Send.
Negative Impls (Nightly)
On nightly Rust, you can explicitly opt out of Send or Sync using negative trait implementations. This is clearer than the PhantomData workaround but is not yet stabilized.
rust// Requires: #![feature(negative_impls)] struct GpuBuffer { handle: u64, } // Explicitly mark as !Send — the GPU context is thread-local. impl !Send for GpuBuffer {} // Explicitly mark as !Sync — no concurrent access allowed. impl !Sync for GpuBuffer {}
Negative impls are more self-documenting than PhantomData because they state the intent explicitly. However, since they require nightly, most libraries use the PhantomData approach instead.
On stable Rust, use PhantomData<*const ()> to achieve the same effect, as shown earlier.
Interior Mutability and Sync
UnsafeCell<T> is the only way to obtain a &mut T from a &T in Rust. Every interior mutability type builds on it. Because UnsafeCell is !Sync, any type that wraps it must provide its own synchronization to be Sync.
rustuse std::cell::UnsafeCell; // UnsafeCell is !Sync — the compiler forces you to add synchronization. struct MyMutex<T> { locked: std::sync::atomic::AtomicBool, data: UnsafeCell<T>, } // We must manually implement Sync, providing our own synchronization. // SAFETY: Access to `data` is guarded by the `locked` atomic flag. unsafe impl<T: Send> Sync for MyMutex<T> {}
This shows the fundamental pattern: UnsafeCell opts out of Sync, and then MyMutex opts back in by providing its own atomic synchronization via the locked flag. The unsafe impl is the programmer's promise that the synchronization is correct.
Scoped Threads: Relaxing 'static
thread::spawn requires 'static data, which typically means using Arc for shared ownership. thread::scope relaxes this requirement by guaranteeing all threads join before the scope exits.
rustuse std::thread; let mut data = vec![0; 1000]; let chunk_size = data.len() / 4; // No Arc needed — scoped threads can borrow local data. thread::scope(|s| { for (i, chunk) in data.chunks_mut(chunk_size).enumerate() { s.spawn(move || { for x in chunk.iter_mut() { *x = i as i32; } }); } }); // All threads have joined. `data` is fully populated. println!("{:?}", &data[..8]);
This eliminates the Arc boilerplate entirely. Each thread gets a &mut slice of the data, and the scope guarantees the borrows are valid for the duration of the parallel work.
Scoped threads still require that borrowed types are Sync (for shared borrows) or Send (for mutable borrows). The difference is that 'static is no longer required.
Arc<Mutex<T>> vs Arc<RwLock<T>> Decision Tree
Choosing between Mutex and RwLock depends on your access pattern.
rust// Decision tree: // // 1. Are reads much more frequent than writes (>80% reads)? // YES -> Use Arc<RwLock<T>> // NO -> Use Arc<Mutex<T>> (simpler, lower overhead) // // 2. Is the critical section very short (< 1 microsecond)? // YES -> Consider parking_lot::Mutex (no poisoning, smaller, faster) // NO -> std::sync::Mutex is fine // // 3. Do you need to downgrade write locks to read locks? // YES -> Use RwLock (RwLockWriteGuard::downgrade, Rust 1.92+) // NO -> Either works // // 4. Do you need try_lock with a timeout? // YES -> parking_lot provides try_lock_for() and try_lock_until() // NO -> std is fine // // 5. Do you want to avoid poisoning? // YES -> Use parking_lot (no poisoning by design) // NO -> std is fine use std::sync::{Arc, Mutex, RwLock}; // Write-heavy: use Mutex let counter = Arc::new(Mutex::new(0u64)); // Read-heavy: use RwLock let config = Arc::new(RwLock::new(AppConfig::default()));
This decision tree provides a practical framework: start with the simplest option (Mutex), and only add complexity (RwLock, parking_lot) when profiling shows a concrete bottleneck.
When to Use parking_lot Alternatives
The parking_lot crate provides drop-in replacements for Mutex, RwLock, Condvar, and Once with several advantages.
rustuse parking_lot::{Mutex, RwLock}; // parking_lot advantages over std: // 1. No poisoning — lock() returns the guard directly, not a Result. // 2. Smaller — parking_lot::Mutex is 1 byte, std::sync::Mutex is 40+ bytes. // 3. Faster — optimized lock algorithms, especially under contention. // 4. Fair locking option — RwLock can be configured for writer-priority or fair. // 5. Timed locking — try_lock_for(Duration) and try_lock_until(Instant). let m = Mutex::new(42); let guard = m.lock(); // returns MutexGuard directly, no unwrap() needed println!("{}", *guard); // Timed lock attempt use std::time::Duration; if let Some(guard) = m.try_lock_for(Duration::from_millis(100)) { println!("got lock: {}", *guard); } else { println!("lock timeout"); }
The parking_lot API is notably simpler: lock() returns the guard directly instead of a Result, since parking_lot does not use poisoning. This eliminates the .unwrap() calls that litter std::sync::Mutex usage.
Use parking_lot when you want simpler APIs (no poisoning), better performance under contention, or timed lock acquisition. Use std::sync when you want zero external dependencies or need poisoning for panic safety.
Common Pitfalls
- *Using PhantomData<T> instead of PhantomData<const T> —
PhantomData<T>makes your type act as if it owns aT, which affects drop checking but does not removeSend/Sync. UsePhantomData<*const T>orPhantomData<*const ()>to opt out ofSendandSync. - Choosing RwLock for write-heavy workloads —
RwLockhas higher per-operation overhead thanMutexbecause it tracks reader counts. If writes are frequent,Mutexis simpler and often faster.
Best Practices
- Default to Arc<Mutex<T>> and optimize later —
Mutexis simpler, has lower overhead, and works for any access pattern. Switch toRwLockonly when profiling shows that read contention is a bottleneck. - Use PhantomData on stable, negative impls on nightly — For library code that targets stable Rust,
PhantomData<*const ()>is the standard way to opt out ofSend/Sync. Document the reason in a comment.
Summary
PhantomData<*const ()>removesSendandSyncfrom a type at zero runtime cost.- The newtype pattern lets you add or remove thread safety traits for foreign types.
- Negative impls (
impl !Send) exist on nightly; usePhantomDataon stable. UnsafeCellis the root of all interior mutability and is!Syncby definition.- Scoped threads relax
'staticrequirements, often eliminating the need forArc. - Default to
Arc<Mutex<T>>; switch toArc<RwLock<T>>only for read-heavy workloads. parking_lotoffers smaller, faster, non-poisoning lock primitives with timed locking.
Code Examples
use std::marker::PhantomData;
use std::sync::{Arc, Mutex, RwLock};
use std::thread;
// Pattern 1: PhantomData to remove Send/Sync
struct GpuHandle {
id: u64,
_not_send: PhantomData<*const ()>, // makes GpuHandle !Send + !Sync
}
// Pattern 2: Arc<Mutex<T>> for write-heavy shared state
fn mutex_example() {
let counter = Arc::new(Mutex::new(0u64));
thread::scope(|s| {
for _ in 0..8 {
let counter = Arc::clone(&counter);
s.spawn(move || {
for _ in 0..1000 {
*counter.lock().unwrap() += 1;
}
});
}
});
println!("counter: {}", *counter.lock().unwrap()); // 8000
}
// Pattern 3: Arc<RwLock<T>> for read-heavy shared state
fn rwlock_example() {
let config = Arc::new(RwLock::new(vec!["default".to_string()]));
thread::scope(|s| {
// Many readers
for i in 0..8 {
let config = Arc::clone(&config);
s.spawn(move || {
let data = config.read().unwrap();
println!("reader {i}: {:?}", *data);
});
}
// One writer
let config = Arc::clone(&config);
s.spawn(move || {
config.write().unwrap().push("updated".to_string());
});
});
}
fn main() {
mutex_example();
rwlock_example();
}