Skip to content
← All articles

Send and Sync as unsafe auto traits

`unsafe impl Send for X {}` is a proof obligation, not an incantation for quietening the compiler. What the proof has to establish, where the bounds come from, and why MutexGuard is Sync but not Send.

You have now met Send and Sync three times: as pure type-system facts, as the errors that stop an Rc or a RefCell crossing a thread boundary, and as something you wrote by hand with unsafe impl to make a raw pointer shareable. This article closes the loop, now that you have atomics and can see what the promise actually rests on.

The two declarations

pub unsafe auto trait Send {}
pub unsafe auto trait Sync {}

Three words, each load-bearing.

trait — but with no methods. Nothing to call. The entire content is a fact the compiler uses to decide what your code is allowed to do.

auto — the compiler implements it for you, structurally. A type is Send if all of its fields are Send. You do not write these impls and should not want to; the derivation is automatic and correct for every type built out of ordinary parts.

unsafe — implementing one by hand is a promise. A wrong Send impl is not a type error, it is a data race: undefined behaviour, which on a good day is a wrong number and on a bad day is memory corruption discovered weeks later in a different subsystem.

The definitions, once more, in the form worth memorising:

  • T: Send — ownership of a T may be transferred to another thread.
  • T: Sync — a &T may be shared with another thread. Equivalently and exactly: T: Sync if and only if &T: Send.

Two derived rules fall out and are worth having: &mut T: Send iff T: Send, and both &T and &mut T are Sync iff T is Sync.

The moment a type loses both

Store a *const T, a *mut T or a NonNull<T> in a struct and the automatic derivation stops: raw pointers are neither Send nor Sync, so your struct is neither either.

This is worth stating clearly because the std documentation for Send has historically been read as implying otherwise. It is not so — the Nomicon is explicit, and the compiler agrees. A struct containing a bare *mut u8 fails E0277 at a spawn boundary exactly as Rc does. Trust the error message.

The rule is deliberately conservative and correct: a raw pointer carries no information about who else can reach the thing it points at, so the compiler assumes the worst. Which is where you come in.

Writing the impl, and getting the bound right

The canonical form is not the bare one:

unsafe impl<T: Send> Send for MyBox<T> {}
unsafe impl<T: Sync> Sync for MyBox<T> {}

Getting that bound wrong is how an Rc escapes to another thread. Write the unbounded version —

unsafe impl<T> Send for MyBox<T> {}   // wrong, and it compiles

— and now MyBox<Rc<Data>> is Send. Move it to another thread, clone the Rc on both sides, and two threads race on a non-atomic refcount. Use-after- free, or a leak, depending on which increment was lost. Your unsafe impl did that, in a program with no other unsafe code anywhere.

The rule of thumb: your wrapper should require of T whatever the operation it enables requires. If your type gives other threads ownership of the T, require T: Send. If it gives them shared access, require T: Sync. If you cannot state which, you do not yet have the proof.

The mirror image — opting out — has no stable syntax. impl !Send for X {} is nightly-only (negative impls). The stable workaround is to include a PhantomData<*const ()> field, which drags !Send and !Sync in structurally. It is a hack and it is what everyone does.

The instructive case: MutexGuard is Sync but not Send

impl<T: ?Sized + Sync> Sync for MutexGuard<'_, T> {}
// and no Send impl at all

Read that twice. The guard can be shared between threads by reference, and cannot be moved to another thread.

Why: on POSIX platforms, pthread_mutex_unlock must be called by the thread that locked it. The guard’s destructor does the unlocking. So the guard must die on the thread that created it, and “may be moved to another thread” is precisely what Send would license.

Notice what kind of constraint that is. It is a portability constraint imposed by the C library underneath, not a soundness necessity on every platform — some implementations would be perfectly happy. std takes the strictest common denominator, because a type’s Sendness is part of its public API and cannot be platform-dependent without splitting the ecosystem.

The consequences ripple further than you would expect:

  • you cannot move a MutexGuard into a spawned thread;
  • you cannot hold one across an .await, because a future holding it would not be Send, and most executors need Send futures. clippy::await_holding_lock warns about exactly this, and it is the bridge into the async track;
  • MappedMutexGuard — the guard that lets you project into a field of the protected data — is still unstable, which is one of the most-cited reasons people reach for parking_lot.
💡Why can this course not set a problem asserting "Rc<u32> is not Send"? click to reveal

Because the assertion is a compile failure, and the grader runs code.

The idiomatic way to assert a marker-trait fact is a static assertion helper:

fn assert_send<T: Send>() {}

assert_send::<u32>();        // compiles
assert_send::<Rc<u32>>();    // E0277 — the program no longer builds

The positive form is gradeable. The negative form has no passing shape: a submission containing it does not compile, so it cannot pass a test, and a submission omitting it proves nothing. Grading this properly needs a must_not_compile test kind — compile the file plus an appended snippet and assert a specific error code — which this harness does not have.

There is no runtime escape either. Auto-trait membership is erased before codegen; there is no is_send::<T>() -> bool, and writing one would require specialisation, which is not stable.

One practical note, since older advice claims otherwise: on clippy 0.1.95 the assert_send helper above is accepted. clippy::extra_unused_type_parameters deliberately skips functions with an empty body precisely so the static- assertion idiom stays legal. Add a single statement to the body and the lint fires. So the helper is available to you — it is only the negative assertion that has no home here.

What the tools will and will not do

  • clippy::non_send_fields_in_send_ty flags an unsafe impl Send on a type with non-Send fields — exactly the case you want reviewed. It is nursery, so off by default.
  • clippy::undocumented_unsafe_blocks would force a // SAFETY: comment on every unsafe block. It is restriction, also off by default.
  • clippy::arc_with_non_send_sync catches Arc::new(x) where x is neither Send nor Sync — a real bug, since such an Arc can never cross a thread boundary and you have paid for atomic refcounting for nothing. This one is on by default.
  • clippy::await_holding_lock catches guards held across .await.

So on the safe side of the line the compiler is doing enormous work for you, and on the unsafe side the defaults have almost nothing to say. That asymmetry is the honest summary of Rust’s guarantee: it is airtight where you have not taken the wheel, and entirely yours where you have.

The checklist for unsafe impl Send

Before you write one, be able to answer all four:

  1. What exactly is being shared or transferred, and through what field?
  2. What synchronises access to it — a lock, an atomic, or a structural argument like disjoint index ranges?
  3. What bound does T need, and why that one rather than the other?
  4. Can you write the // SAFETY: comment? If not, you do not have the proof — you have a hope.