Send, Sync and Unpin are
unlike every other trait in Rust. You almost never write impls for them. A
type has them when all of its fields have them, and the compiler
works that out on its own —
for every type in the program, including the ones it generates
itself for closures and async functions.
Rust calls these auto traits. The rule is one sentence long,
and most of what is surprising about thread safety in Rust follows
from it: why an Rc cannot cross a thread boundary, why
adding a private field can break a crate that depends on yours, and
why an async fn that compiled yesterday cannot be
spawned today. This article covers how the derivation works, where it
stops, how to override it in both directions, how it leaks out of
types nobody wrote down — closures and futures — why a
trait object is where that leak ends, and what it costs you at an
API boundary.
All examples were compiled with Rust 1.96.1 (edition 2021). The two that require unstable features were compiled with nightly 1.96.0 (2026-04-06). Error messages vary slightly across compiler versions; the derivation rules themselves are stable.
What Auto Traits Are
Stable Rust has five auto traits: Send,
Sync, Unpin, UnwindSafe and
RefUnwindSafe. Two more, Freeze and
UnsafeUnpin, sit behind feature gates for the compiler's
own use: the first decides whether a static may be placed
in read-only memory, the second whether a &mut T may
be marked noalias. Both follow the same rules, and
neither can be named on stable. The rule is easiest to see on a
struct containing nothing but ordinary fields:
struct Plain {
a: i32,
b: String,
c: Vec<u8>,
}
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
fn main() {
assert_send::<Plain>();
assert_sync::<Plain>();
}
It compiles. The two helper functions are the instrument used
throughout this article: a generic function whose only job is to carry
a bound. If assert_send::<T>() compiles then
T: Send holds, and if it does not, the compiler explains
precisely why. This turns an invisible property of a type into a
yes-or-no question with a diagnostic attached.
Plain is Send and Sync because
i32, String and Vec<u8>
all are. That is the entire rule. A type is Send if every
one of its fields is Send, applied recursively until it
bottoms out at primitives. Nothing was declared for
Plain, and nothing is declared for the overwhelming
majority of types you will ever write.
One Field Decides the Whole Struct
The rule gets interesting when the compiler reaches a field that does not qualify. Swap one:
use std::rc::Rc;
struct Holder {
id: u32,
shared: Rc<String>,
}
fn main() {
assert_send::<Holder>();
}
The compiler rejects it, and the wording repays a close look:
Two phrases carry the mechanism. "within Holder"
and "required because it appears within the type" are the
compiler narrating a field walk. It did not consult a table of
!Send types and find Holder on it. It
descended into the struct, found an Rc, and propagated
the failure back up. The error is reported against Holder
but blames a type that Holder merely contains.
Which raises the question of why Rc itself fails, and the
answer is not derivation. The standard library declares it:
impl<T: ?Sized, A: Allocator> !Send for Rc<T, A> {}
impl<T: ?Sized, A: Allocator> !Sync for Rc<T, A> {}
An Rc keeps its reference count in a plain non-atomic
integer. Two threads each holding a clone of the same Rc
would increment that count concurrently, an update would be lost, and
the allocation would eventually be freed while a live handle still
pointed at it. No
field walk could work that out, so the type opts out by hand. Note the
syntax, because it is doing something this article will later say you
cannot do.
Back to the walk itself. This is its everyday face:
let counter = Rc::new(5);
thread::spawn(move || {
println!("{counter}");
});
thread::spawn requires its closure to be
Send, the closure captures the Rc, and a
closure is a struct whose fields are its captures. So this is exactly
the Holder case with a type the compiler generated
instead of one you declared. The rejection is the same walk over the
same kind of structure.
The practical consequence is that you cannot determine whether your
own type is Send by reading its definition. You have to
know what its fields are made of, all the way down. A type three
crates away can decide it for you.
Send and Sync: Two Different Questions
Send and Sync are routinely spoken about as
a pair, which obscures that they answer different questions.
Send asks whether ownership of a value may move to
another thread. Sync asks whether a
reference may be shared with another thread, and it is
defined in terms of the first: T is Sync
exactly when &T is Send.
That definition has a consequence worth seeing directly. A shared
reference is Send when its target is Sync,
not when its target is Send, and those are different sets
of types:
assert_send::<Cell<i32>>(); // Cell itself is Send
assert_send::<&i32>(); // i32 is Sync, so &i32 is Send
assert_send::<&Cell<i32>>(); // Cell is not Sync, so this is rejected
A Cell is perfectly Send. You may move one to
another thread and use it there all you like. What you may not do is
let two threads hold references to the same one, and the final note is
that rule being applied literally.
The mirror case is stranger. &mut T is
Send whenever T is Send, with no
Sync requirement at all:
assert_send::<&mut Cell<i32>>(); // compiles
An exclusive reference to a Cell may cross a thread
boundary while a shared one may not. That inverts the usual instinct
that &mut is the more dangerous of the two, and the
reason is that the hazard being guarded against is simultaneous
access. An exclusive reference is itself proof that no second observer
exists, so there is nothing left to synchronize. A shared reference is
proof of exactly the opposite.
References pull the two traits apart in one direction:
Cell is Send and not Sync. The
standard library also contains a type that goes the other way,
Sync and not Send. This compiles:
fn main() {
assert_sync::<MutexGuard<'static, i32>>();
}
and this does not:
fn main() {
assert_send::<MutexGuard<'static, i32>>();
}
A lock guard is Sync but not Send, and the
standard library states both directly:
impl<T: ?Sized> !Send for MutexGuard<'_, T> {}
unsafe impl<T: ?Sized + Sync> Sync for MutexGuard<'_, T> {}
The Sync half is conditional. Sharing a reference to a
guard is safe only when the guarded data is itself Sync,
because that data is all the other thread can reach through it. The
!Send half is unconditional, and the reason is an
operating system constraint rather than anything about Rust: several
platforms require that a mutex be released by the thread that
acquired it, and moving the guard would move the destructor that
performs the unlock.
Notice also that the diagnostic here says only "the trait
Send is not implemented for
MutexGuard" — there is no "within" clause. The
failure originates at this type rather than being inherited from a
field. That distinction is the difference between a root and a
propagation, and the roots are a short enough list to learn.
Every Concrete Root Is a Declared Negative
If almost every type is Send and Sync by
structural derivation, the question is what stops the walk. The
natural assumption is that the compiler refuses when it cannot work
something out for itself. It does not. The walk only ever propagates a
failure it was handed, and every concrete type it stops at is one the
standard library declared out by hand. The one other thing that stops
it is having no fields to look at, which is what a trait object or an
unbounded type parameter presents, and that case gets its own section
later.
For Sync, the deepest origin is UnsafeCell:
This is the same UnsafeCell that the
aliasing model turns on. There
it is the one type permitted to be mutated through a shared reference,
the single legal exception to the rule that &T means
nobody is writing. Here it is the root of !Sync. Those
are not two coincidental facts about one type. They are the same fact
stated twice: a type that permits mutation through a shared reference
is precisely a type that cannot be shared across threads without
further synchronization.
That status is written down rather than worked out:
impl<T: ?Sized> !Sync for UnsafeCell<T> {}
Cell and RefCell would fail the walk anyway,
since each contains an UnsafeCell, but the standard
library does not lean on that. It declares both !Sync
outright as well. Try to share one:
let c = Cell::new(0);
thread::scope(|s| {
s.spawn(|| c.set(1));
s.spawn(|| c.set(2));
});
The closures capture &Cell<i32>, which is
Send only if Cell<i32> is
Sync, which it is not. The data race that would result is
rejected at compile time by a rule with no knowledge of data races in
it — only of fields and of a short list of declarations.
Raw pointers are another root, declared both !Send
and !Sync in core, and they propagate the
same way:
struct Wrapper {
p: *const u8,
}
fn main() {
assert_send::<Wrapper>(); // E0277
}
A raw pointer carries no information about what it addresses, who
else holds it, or what synchronizes access to it, so nothing useful
could ever be derived from one. But that is the reasoning behind the
declaration, not the compiler's reasoning at the point of use. At the
point of use it is reading the !Send that
core declares for *const T and propagating
the result to anything holding one, exactly as it propagated
Rc through Holder.
That list is short, it lives in the standard library, and you cannot add to it. Which is precisely why the opt-out shown later works the way it does: you cannot declare a negative, but you can embed a type that already carries one.
A static Must Be Sync
One consequence of that short list turns up in a place with no threads
in it anywhere, and it is where most people meet
Sync for the first time. A static lives for the whole
program and every thread can reach it through a shared reference, so
the language requires its type to be Sync:
use std::cell::Cell;
static COUNTER: Cell<i32> = Cell::new(0);
A strictly single-threaded program is rejected exactly as firmly as a
threaded one, because the requirement attaches to the declaration
rather than to anything the program does. It is also why interior
mutability at global scope means reaching for a Mutex, an
RwLock or an atomic.
The demand is specific to static. Neither of these is
rejected, with the same Cell in both:
const START: Cell<i32> = Cell::new(0);
thread_local! {
static LOCAL: Cell<i32> = Cell::new(7);
}
A const is substituted at each use site rather than stored
once, so no two threads ever reach the same value. A
thread_local runs its initializer once per thread, so
each thread has a value of its own. Only
the genuinely shared declaration needs the genuinely shared guarantee,
which means the form of the declaration decides whether
Sync is demanded — not the type you put inside it.
unsafe impl: Asserting What the Compiler Cannot Check
Inheriting a negative is not a life sentence. The raw-pointer wrapper
from earlier is not Send because of what it contains, but
a wrapper that genuinely is safe to send — because it owns its allocation
exclusively, say — can say so:
struct Wrapper {
p: *const u8,
}
unsafe impl Send for Wrapper {}
That compiles, and Wrapper is now Send by
assertion rather than by derivation. The unsafe keyword
is not decoration. Remove it and the compiler rejects the impl
outright, with an unusually direct explanation:
"Enforces invariants that the compiler can't check" is the whole
justification for the design. Everywhere else in the language,
unsafe marks a place where you take on a proof obligation
the compiler cannot discharge. An unsafe impl Send is
exactly that: you are asserting that moving this value across a thread
boundary cannot introduce a data race, and nothing verifies it.
The standard library uses this constantly, and conditionally. Its
declaration for Arc reads:
unsafe impl<T: ?Sized + Sync + Send, A: Allocator + Send> Send for Arc<T, A> {}
The assertion is made only when the contents justify it, which is why
Arc<Cell<i32>> is not Send even
though Arc<i32> is:
The last note is the conditional assertion being checked rather than
assumed. Arc<T> is Send only when
T is both Send and Sync, and
Cell fails the Sync half.
PhantomData: Opting Out on Stable
The reverse operation is stranger, because there is no stable syntax
for "this type is not Send". What there is instead is a
trick that follows from the derivation being structural: if you want a
type to fail the field walk, give it a field that fails.
use std::marker::PhantomData;
struct NotSend {
v: u32,
_marker: PhantomData<*const ()>,
}
PhantomData occupies no space, but it counts as a field
for the purposes of the walk, and a raw pointer is one of the roots
listed earlier. So NotSend fails
assert_send with the same E0277 that
Wrapper did. You have not declared anything negative. You
have arranged for the ordinary rule to produce the answer you wanted.
This is the same PhantomData that appears in the
variance article, doing a structurally
identical job on a different axis. There the choice of type parameter
controls how the compiler's variance computation treats a type; here
it controls how the auto trait computation treats it. One marker, two
derivations, both of them driven purely by the shape of the fields.
Rust does have real negative impls, and they say what they mean:
#![feature(negative_impls)]
struct NoSend {
v: u32,
}
impl !Send for NoSend {}
fn main() {
assert_send::<NoSend>();
}
They are also unavailable. On nightly that example produces the expected E0277. On stable the feature gate fires as well, and it is reported first:
This is the feature the standard library used to declare
Rc !Send earlier on. The
standard library is compiled with unstable features enabled and your
crate is not, which is the whole asymmetry: std says what it means,
and you arrange for a field to fail. So on stable a failing field is
not merely the idiomatic way to opt out of an auto trait. It is the
only way, and PhantomData is how you do it without paying
for the field. Worth noting that the gate does not suppress the trait
check: stable reports E0554 and the E0277 together, because
the negative impl was still parsed and taken into account.
Unpin Is an Auto Trait Too
Send and Sync are about threads, which makes
it easy to assume auto traits are a concurrency feature. They are not.
Unpin is an auto trait too, and it has nothing to do with
concurrency.
fn assert_unpin<T: Unpin>() {}
fn main() {
assert_unpin::<Plain>();
assert_unpin::<String>();
assert_unpin::<Vec<u8>>();
}
All three pass. Ordinary types are Unpin for free,
derived structurally, exactly like Send. And the opt-out
is the same marker-field trick, with a purpose-built marker:
use std::marker::PhantomPinned;
struct SelfRef {
v: u32,
_pin: PhantomPinned,
}
fn main() {
assert_unpin::<SelfRef>();
}
The "within" clause is back, and it is the same walk. Pin
is a large enough subject to deserve its own article, but the reason it
works at all is visible from here: the default is permissive, so
Pin<&mut T> imposes nothing on the overwhelming
majority of types, and the small number that genuinely cannot be moved
opt out structurally by containing a PhantomPinned.
One asymmetry separates
Unpin from the other two. Send and
Sync demand unsafe impl.
Unpin does not. Add one line to SelfRef:
impl Unpin for SelfRef {} // no unsafe keyword
It is accepted, and SelfRef is Unpin again
despite the marker field. Implementing Unpin wrongly
cannot by itself break memory safety, so the impl carries no proof
obligation. The guarantee that Pin rests on is upheld by
the unsafe code that constructs a pinned value, and that
is where the obligation lives.
UnwindSafe: The Same Walk, for Panics
Unpin shows the machinery is not about threads.
UnwindSafe shows it is not about moving values either.
catch_unwind requires its closure to be
UnwindSafe, and a closure holding a &mut
is not:
let mut v = vec![1, 2, 3];
let _ = catch_unwind(|| {
v.push(4);
});
Three separate things are happening in that message.
"within this closure" is the identical field walk over the
identical captures. The property being derived has nothing to do with
concurrency at all — it asks whether a panic could leave this
value observably half-modified and then let you carry on using it. And
the final note draws the same line the Send rules draw for
shared references: &Vec<i32> qualifies where
&mut Vec<i32> does not, because only one of the
two lets the closure modify the value at all. A panic partway through
a mutation leaves the owner holding something logically broken, and a
shared reference gives the closure no way to bring that about.
Like Unpin, and unlike Send and
Sync, UnwindSafe and its companion
RefUnwindSafe are safe traits. An
impl UnwindSafe needs no unsafe keyword,
because the property is a promise about logical state after a panic,
not about memory, and AssertUnwindSafe exists precisely
to let you make that promise on someone else's type.
Closures Capture Fields, Not Structs
The walk applies to types the compiler generates as readily as to types you declare, and a closure is one of those. These two functions differ only in what the closure prints:
struct Holder {
id: u32,
shared: Rc<String>,
}
fn spawn_id(h: Holder) {
thread::spawn(move || {
println!("{}", h.id);
});
}
fn spawn_both(h: Holder) {
thread::spawn(move || {
println!("{} {}", h.id, h.shared);
});
}
The first compiles and the second does not. Holder is
the same type in both, and the Rc is one of its fields
either way. What differs is what the closure captured: since edition
2021 a closure captures individual fields rather than the whole
struct, so spawn_id produced an anonymous type holding a
u32, and spawn_both produced one holding a
u32 and an Rc. The walk then runs over those
captures, and the failure is reported against a type with no name:
Moving one field's use into or out of a closure body therefore changes whether that closure can be spawned, without changing any type you wrote.
The capture mode counts for as much as the field. A closure
implements an auto trait when the types of its captures do, and a
capture taken by shared reference contributes &T
rather than T. A closure that reads a Cell
by reference is therefore asking whether Cell is
Sync, while a move closure that takes the
same Cell by value is asking whether it is
Send. Those two questions have different answers, as the
rules for &T and T established
earlier.
Auto Traits Leak Through impl Trait
A closure at least has captures you can point at in the source. An
async fn does not give you even that. Consider one with
no trait bounds written anywhere in it. The helper changes shape
here, because a future's type cannot be written down: this version
takes the value and lets inference name the type.
fn assert_send<T: Send>(_: T) {}
async fn other() {}
async fn task() {
let local = Rc::new(1);
other().await;
println!("{local}");
}
fn main() {
assert_send(task());
}
This is rejected, and unlike the failures so far the diagnostic has no error code at all — it is a bespoke message the compiler reserves for this situation:
The help line gives the game away: "within
impl Future<Output = ()>". It is the identical
phrasing from the Holder error, applied to a type that has
no name and that you did not write. An async fn compiles
to a state machine whose fields are the locals that must survive
across an await, and auto traits are then derived over
those fields by the ordinary rule. The relationship to the
memory layout of futures
is direct: the same field set that determines a future's size
determines its thread safety.
This is what makes auto traits leak. The signature
async fn task() says nothing about Send, yet
the answer is fixed by the body, and callers depend on it. Spawning
onto a work-stealing runtime requires a Send future, so
introducing an Rc in the middle of a private function can
break a spawn several layers away.
A Borrowed Local Stays Until Its Scope Ends
The obvious repair is to get rid of the value before the await, and it works:
async fn task() {
let local = Rc::new(1);
drop(local);
other().await;
}
The future is Send. The move into drop ends
the local as far as the state machine is concerned, and nothing of it
survives the await. Now use the value before dropping
it:
async fn task() {
let local = Rc::new(1);
println!("{local}");
drop(local); // moved away before the await
other().await;
}
local has still been moved into drop and
cannot possibly be touched afterwards. The future is no longer
Send, and the compiler's phrasing is the tell:
"maybe used later" is the operative phrase. The only change
is the println!, and println! takes its
arguments by reference. Once a local has been borrowed, the analysis
that decides which locals survive an await stops
following moves out of it and keeps it until its scope ends.
local is still in scope at the await, so
the slot is captured, the field exists, and the walk finds an
Rc. Anything that takes a reference counts, including a
*local or a method call, since both go through
&local. Confining the borrow to an inner block that
has already closed does not help either:
async fn task() {
let local = Rc::new(1);
{
let r = &local;
println!("{r}");
}
drop(local);
other().await;
}
That is rejected with the same "maybe used later". What does work is
ending the scope of local itself:
async fn task() {
{
let local = Rc::new(1);
println!("{local}");
}
other().await;
}
The future is Send again. None of this is visible in a
type signature, and none of it follows from the rules stated so far.
It comes from how the compiler decides which locals a state machine
must keep: a move ends a local that was never borrowed, but a borrow
pins the local to its scope. If you take one operational habit away
from this article, it is that a non-Send value you have
borrowed must go out of scope before the await, and the
way to arrange that is a block, not a drop().
async fn in Traits Cannot Be Bounded
Leakage through a free function is a convenience, and a deliberate
one. The compiler peeks at the hidden type, works out its auto traits,
and lets the caller depend on them, so that an
impl Trait return behaves like a struct wrapping a
private field: whatever the field is, the auto traits show through. In
code generic over a trait, that design runs out of road.
An async fn in a trait desugars to an associated type
with no name. There is nothing for a caller to write a bound on, so
generic code cannot require that the returned future is
Send:
trait Fetcher {
async fn fetch(&self) -> String;
}
fn use_it<F: Fetcher>(f: &F) {
assert_send(f.fetch());
}
This is the send bound problem, and it explains a great deal of async
Rust that looks as though it ought to be generic over a trait and is
not. A work-stealing runtime needs a Send future, and the
async fn sugar gives the trait no way to promise one.
The last block of that diagnostic is the compiler telling you to stop
using async fn and write the desugaring out, because a
hand-written return type is somewhere a bound can live:
trait Fetcher {
fn fetch(&self) -> impl Future<Output = String> + Send;
}
Now the future is Send for every implementor by contract
rather than by inference. The price is that the
trait has now committed: anyone whose future is not Send
can no longer implement it at all. The trait-variant crate
exists to generate both flavors of a trait from one definition, and
return type notation, which would let the caller write the bound at the
use site instead of the trait author guessing, was still unstable when
this was written.
Trait Objects Must Name Their Auto Traits
A trait method's future has no name to attach a bound to. A trait
object has no fields to walk, which gets you to the same place by a
different route. dyn Job has erased the concrete type, so there is nothing
left to inspect and nothing to derive from. The auto traits are
therefore not implied, and have to be written into the type:
trait Job {
fn run(&self);
}
fn main() {
assert_send::<Box<dyn Job + Send>>(); // named, fine
assert_send::<Box<dyn Job>>(); // not named, rejected
}
The notes are the walk running as far as it can. It descends into the
Unique pointer that Box holds, meets the
conditional impl that makes Unique<T>
Send when T is, and stops at
dyn Job, where there is nothing further to look at. This
is why signatures across the ecosystem read
Box<dyn Error + Send + Sync> rather than
Box<dyn Error>. The bound cannot be inferred, so it
has to be spelled out, and a trait object that forgot to name it
cannot be retrofitted by a caller.
The syntax is itself a special case. A trait object may name only one
non-auto trait, which is why Box<dyn Debug + Display>
is rejected. Auto traits are exempt from that restriction and may be
stacked on top freely, which is the only reason
Box<dyn Debug + Send + UnwindSafe> is a legal type.
Defining Your Own Auto Trait
A reasonable reading of everything above is that the compiler carries
a handful of hard-coded special cases. It does not. auto trait
is a general construct, and on nightly you can declare one:
#![feature(auto_traits, negative_impls)]
auto trait Fizzy {}
struct Ordinary;
struct NotFizzy;
impl !Fizzy for NotFizzy {}
struct Container {
inner: NotFizzy,
}
fn assert_fizzy<T: Fizzy>() {}
fn main() {
assert_fizzy::<Ordinary>(); // auto-derived, fine
assert_fizzy::<Container>(); // infected by NotFizzy
}
Fizzy means nothing. It has no methods, no semantics and
no support anywhere in the standard library. It behaves identically
anyway:
Same "within" clause, same "required because it appears within the
type", same propagation from a field to its container. Nothing in the
compiler knows that Send is about threads. It knows how to
walk fields and how to honor an opt-out. Send,
Sync, Unpin, UnwindSafe and
RefUnwindSafe are all applications of that one machine.
The declaration is constrained in ways an ordinary trait is not. An
auto trait may not have methods or associated types, because the
compiler must be able to generate an impl for every type in the
program without being told anything about it. It may not have
supertraits either. And resolution is coinductive, meaning a cycle
counts as a success rather than a failure — which is what lets a
recursive type like a linked list be Send at all instead
of sending the solver into infinite regress.
The feature has been unstable for years, and the blocking problem is sharper than a general complaint about coherence. Unrestricted negative impls turn an auto trait into a test for the absence of a type:
auto trait NoString {}
impl !NoString for String {}
A function bounded on NoString now succeeds or fails
according to whether a String appears anywhere inside its
argument, at any depth. That makes almost any change to almost any type
a potential breaking change for every crate downstream. The language
team's position has softened over time, and stabilizing some restricted
form is no longer considered out of the question. What such a form
might look like is already visible in pyo3: on nightly it defines its
Ungil as an auto trait with negative impls confined to
types it defines itself, and on stable it falls back to a blanket impl
over Send. The general case remains open.
Private Fields Are Public API
That hazard needs nobody to define a new auto trait. It is already live for the ones you have, and it is easy to miss precisely because the impls are invisible.
A private field is part of your public API. This follows directly from
the Holder case: adding a non-Send field to a
struct, even a private one used purely as an implementation detail,
removes Send from the public type and breaks every
downstream caller who was spawning it onto a thread. Nothing in the
type's signature changed, and nothing in your crate's documentation
records the guarantee you just withdrew.
The same hazard applies to async fn, with a smaller blast
radius but a much larger surface, since any local that crosses an
await participates. A refactor entirely inside a function
body can change whether callers can spawn the result.
Three habits follow. Prefer making a type genuinely satisfy the
derivation over asserting it with unsafe impl. When you do
write an unsafe impl, write down the invariant you are
claiming, because the compiler has recorded nothing. And keep
non-Send values out of scopes that span an
await: once such a value has been borrowed, only a block
that ends before the await removes it, and a
drop() does not.
What Is Settled and What Is Not
Most of what is above is language semantics that has not moved in
years and is not going to: the field walk itself, the definitions of
Send and Sync, the rules for shared and
exclusive references, the requirement on static,
unsafe impl, the marker-field opt-out, and the fact that
a private field is part of your public API. Those can be relied on.
Four things here are snapshots rather than fixtures, all taken at Rust 1.96 in 2026.
- Return type notation. The send bound problem is real now and the hand-written desugaring is the answer now. RTN is the intended fix and was already out for testing. If it has stabilized by the time you are reading this, that workaround is history and the bound belongs at the use site instead.
- Negative impls and
auto trait. Both are unstable as of this writing, and that is the only reasonPhantomDatais the opt-out you are obliged to use. The language team has grown less opposed to stabilizing some restricted form. If that lands, the opt-out section gains a second way and nothing else in this article changes. - What a future ends up holding. That a borrowed local is kept until its scope ends, where a never-borrowed one leaves at the move, is a conservatism in how the compiler decides which locals a state machine must keep, not a promise the language makes. It has held for years and may stop holding. The rule it illustrates — that auto traits are derived over whatever the state machine ended up holding — will not change.
- Diagnostic wording. Every error message quoted here is real output from the pinned toolchain, trimmed of trailing notes and source snippets but otherwise untouched. rustc rewords its diagnostics regularly. The error codes and the rules behind them are considerably more stable than the prose.
Nothing in the first paragraph depends on anything in the list.
Key Takeaways
- Auto traits are opt-out, apply to every type by default, and are derived structurally from fields rather than written out for each type.
- The word within in a diagnostic is the compiler narrating that field walk, and it tells you the failure was inherited rather than originating at the type you named.
SendandSynccan come apart in both directions:Cellhas the first and not the second,MutexGuardthe second and not the first.- The walk never refuses on its own. Every concrete stopping point —
UnsafeCell,Cell, raw pointers,Rc,MutexGuard— is an explicitimpl !Traitin the standard library, which is a thing your crate is not allowed to write.UnsafeCellis also the type that anchors the aliasing model, for the same underlying reason. - References split the two apart.
&TisSendwhenTisSync, while&mut TisSendwhenTmerely is. ACellcan be moved to another thread, and lent to one exclusively, but never shared with one. - A
staticmust beSync, in a single-threaded program as firmly as in a threaded one, while aconstand athread_localneed not be. The form of the declaration decides it, not the type. unsafe implis a proof obligation forSendandSync, and a safeimplof either is rejected outright with E0200.Unpin,UnwindSafeandRefUnwindSafecan be implemented safely, because getting them wrong cannot break memory safety on its own.- The standard library's assertions are conditional:
Arc<T>isSendonly whenTisSend + Sync, which is whyArc<Cell<i32>>is not. - Opting out on stable means adding a field that already fails the
walk, which is what
PhantomData<*const ()>andPhantomPinnedare for. UnwindSaferuns the same walk for panic safety, which is the clearest evidence that this is a general mechanism and not a concurrency feature.- Auto traits leak through
impl Trait: a future'sSend-ness is decided by which locals the state machine keeps across anawait. A local that was never borrowed leaves at the move, but once borrowed it stays until its scope ends, and only a block removes it. - An
async fnin a trait desugars to an associated type with no name, so no stable syntax can require its future to beSend. Write the desugaring by hand, or reach fortrait-variant, until return type notation lands. - Auto traits are not implied on trait objects.
dyn Traiterases the fields the walk needs, soSendmust be named in the type, which is whyBox<dyn Error + Send + Sync>is written out in full across the ecosystem. - Because the impls are inferred, a private field is part of your public API.
Further Reading
- The Rust Reference: Special types and traits, which states the derivation rules exactly
- The Rustonomicon: Send and Sync
- The lang team's design note on auto traits, on why the feature is still unstable
- Auto traits and impl Trait from the impl-trait initiative, on why the leakage is deliberate
- Announcing async fn and return-position impl Trait in traits, which introduced the send bound problem
std::marker::Sendandstd::marker::Syncstd::marker::Unpinandstd::marker::PhantomPinnedstd::cell::UnsafeCell- RFC 19: Opt-in Builtin Traits, which introduced the design