The `Sync` bound nobody asked for

&self on an async trait method whose returned future must be Send implicitly forces Sync on the impl type — even if neither the trait nor its callers ever ask for Sync.

Most async runtimes spawn futures onto a thread pool, which means a spawned future has to be safe to move between threads. tokio::spawn makes the requirement explicit:

pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
    F: Future + Send + 'static,
    F::Output: Send + 'static,

F: Send cascades through everything the future captures.

Whenever a future captures a reference and itself has to be Send, two facts about Rust’s reference types matter:

So a Send future that captures &mut T only needs T: Send, but a Send future that captures &T needs T: Sync.

In the example below, everything compiles because MyWorker is trivially Send + Sync.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
pub trait Worker {
    fn work(&self) -> impl Future<Output = ()> + Send;
}

#[allow(dead_code)]
struct MyWorker;

static_assertions::assert_impl_all!(MyWorker: Send);
static_assertions::assert_impl_all!(MyWorker: Sync);

impl Worker for MyWorker {
    async fn work(&self) {}
}

pub fn spawn<W: Worker + Send + 'static>(w: W) {
    tokio::spawn(async move {
        loop {
            w.work().await;
        }
    });
}

The Worker trait only visibly asks for Send, so giving the impl type interior mutability seems reasonable. But Cell is Send and !Sync, so it makes MyWorker !Sync too, which breaks the Sync requirement coming from &self.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::cell::Cell;

pub trait Worker {
    fn work(&self) -> impl Future<Output = ()> + Send;
}

#[allow(dead_code)]
struct MyWorker(Cell<()>);

static_assertions::assert_impl_all!(MyWorker: Send);
static_assertions::assert_not_impl_any!(MyWorker: Sync);

impl Worker for MyWorker {
    async fn work(&self) {}
}

pub fn spawn<W: Worker + Send + 'static>(w: W) {
    tokio::spawn(async move {
        loop {
            w.work().await;
        }
    });
}
error: future cannot be sent between threads safely
  --> examples/step-2.rs:14:25
   |
14 |     async fn work(&self) {}
   |                         ^ future returned by `work` is not `Send`
   |
   = help: within `MyWorker`, the trait `Sync` is not implemented for `Cell<()>`
   = note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock`
note: captured value is not `Send` because `&` references cannot be sent unless their referent is `Sync`
  --> examples/step-2.rs:14:19
   |
14 |     async fn work(&self) {}
   |                   ^^^^^ has type `&MyWorker` which is not `Send`, because `MyWorker` is not `Sync`
note: required by a bound in `Worker::work::{anon_assoc#0}`
  --> examples/step-2.rs:4:50
   |
 4 |     fn work(&self) -> impl Future<Output = ()> + Send;
   |                                                  ^^^^ required by this bound in `Worker::work::{anon_assoc#0}`

The error walks the chain: fn work captures &self as &MyWorker; for the returned future to satisfy + Send, &MyWorker has to be Send; and &T: Send only holds when T: Sync. The &self parameter has been demanding Sync on Self all along — Cell just made the demand visible.

The cheap fix is to make Self: Sync: swap the non-Sync interior-mutability primitive for a Sync one (a Mutex, an RwLock, an atomic). The impl type becomes Sync and the trait compiles unchanged. But we’ve added synchronisation overhead on every state access for a worker whose state is only ever touched from inside a single spawned task. Suboptimal™.

The better move is to switch &self to &mut self. &mut T: Send requires only T: Send, no Sync involved:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::cell::Cell;

pub trait Worker {
    fn work(&mut self) -> impl Future<Output = ()> + Send;
}

#[allow(dead_code)]
struct MyWorker(Cell<()>);

static_assertions::assert_impl_all!(MyWorker: Send);
static_assertions::assert_not_impl_any!(MyWorker: Sync);

impl Worker for MyWorker {
    async fn work(&mut self) {}
}

pub fn spawn<W: Worker + Send + 'static>(mut w: W) {
    tokio::spawn(async move {
        loop {
            w.work().await;
        }
    });
}

This compiles. Now the trait carries no Sync requirement anywhere.

Underneath all of this is &mut T being the unique reference, not the mutable one. The instinct in Rust is to reach for & over &mut to tighten the contract: no mutation allowed. Here it goes the other way. &mut self guarantees unique access to Self for the duration of the call, which rules out cross-thread sharing and drops the Sync bound with it.