The `Sync` bound nobody asked for

&self as a receiver in an async method of a trait whose returned future must be Send implicitly forces Sync on the trait implementor type — even if neither the trait nor its callers ever explicitly ask for Sync.

Here’s a quick demonstration.

1.1. Future without Send bound, Send + Sync impl type — compiles:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct SendSync;

static_assertions::assert_impl_all!(SendSync: Send, Sync);

pub trait T {
    fn f(&self) -> impl Future<Output = ()>;
}

impl T for SendSync {
    async fn f(&self) {}
}

1.2. Future without Send bound, Send-only impl type — also compiles:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct SendOnly(std::cell::Cell<()>);

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

pub trait T {
    fn f(&self) -> impl Future<Output = ()>;
}

impl T for SendOnly {
    async fn f(&self) {}
}

2.1. Future with Send bound, Send + Sync impl type — compiles:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct SendSync;

static_assertions::assert_impl_all!(SendSync: Send, Sync);

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

impl T for SendSync {
    async fn f(&self) {}
}

2.2. Future with Send bound, Send-only impl type — does not compile:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct SendOnly(std::cell::Cell<()>);

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

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

impl T for SendOnly {
    async fn f(&self) {}
}
Full compile error (we'll see it again later)
error: future cannot be sent between threads safely
  --> examples/demo-2.2.rs:11:22
   |
11 |     async fn f(&self) {}
   |                      ^ future returned by `f` is not `Send`
   |
   = help: within `SendOnly`, 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/demo-2.2.rs:11:16
   |
11 |     async fn f(&self) {}
   |                ^^^^^ has type `&SendOnly` which is not `Send`, because `SendOnly` is not `Sync`
note: required by a bound in `T::f::{anon_assoc#0}`
  --> examples/demo-2.2.rs:7:47
   |
 7 |     fn f(&self) -> impl Future<Output = ()> + Send;
   |                                               ^^^^ required by this bound in `T::f::{anon_assoc#0}`

So clearly: if the returned future doesn’t need to be Send, the impl type doesn’t need to be Sync. If the returned future needs to be Send, the impl type needs to be Sync.

impl type: Send + Syncimpl type: Send + !Sync
returned future: without Send bound1.1 — compiles1.2 — compiles
returned future: with Send bound2.1 — compiles2.2 — fails

So why do we even need an async method of a trait to return a Send future?

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 MyTask is trivially Send + Sync.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct MyTask;

static_assertions::assert_impl_all!(MyTask: Send, Sync);

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

impl Task for MyTask {
    async fn run(&self) {}
}

pub fn spawn<T: Task + Send + 'static>(t: T) {
    tokio::spawn(async move { t.run().await });
}

Now imagine we really need to use an external type as part of Self — a type we don’t control. The type is Send but !Sync (due to interior mutability). Since the task is only ever executed by a single thread at any given moment, this should be fine in theory:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
pub struct Foreign(std::cell::Cell<()>);

struct MyTask {
    foreign: Foreign,
}

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

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

impl Task for MyTask {
    async fn run(&self) {}
}

pub fn spawn<T: Task + Send + 'static>(t: T) {
    tokio::spawn(async move { t.run().await });
}
error: future cannot be sent between threads safely
  --> examples/step-2.rs:15:24
   |
15 |     async fn run(&self) {}
   |                        ^ future returned by `run` is not `Send`
   |
   = help: within `MyTask`, 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:15:18
   |
15 |     async fn run(&self) {}
   |                  ^^^^^ has type `&MyTask` which is not `Send`, because `MyTask` is not `Sync`
note: required by a bound in `Task::run::{anon_assoc#0}`
  --> examples/step-2.rs:11:49
   |
11 |     fn run(&self) -> impl Future<Output = ()> + Send;
   |                                                 ^^^^ required by this bound in `Task::run::{anon_assoc#0}`

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

There are (at least) two ways to address this:

a) Wrap Foreign in a Mutex (or RwLock). Mutex<T>: Sync only requires T: Send, not T: Sync, so wrapping the !Sync Foreign is enough to make Self: Sync. This is in line with what the compiler suggests1:

= note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock`

It compiles. The cost is synchronization overhead on every state access even though the access pattern is single-threaded — Self is never actually shared across threads. Suboptimal™.

b) Switch &self to &mut self. &mut T: Send only requires T: Send — no Sync involved. The trait stops demanding Sync on the impl type, and Self stays untouched:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
pub struct Foreign(std::cell::Cell<()>);

struct MyTask {
    foreign: Foreign,
}

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

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

impl Task for MyTask {
    async fn run(&mut self) {}
}

pub fn spawn<T: Task + Send + 'static>(mut t: T) {
    tokio::spawn(async move { t.run().await });
}

This compiles. The trait carries no Sync requirement, and Foreign is still in Self unchanged.

Underneath all of this is &mut T being the unique reference, not the mutable one. Often 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 exclusive access to Self for the duration of the call, which rules out cross-thread sharing and drops the Sync bound with it.


Full accompanying source code can be found here. Built with rustc 1.95.0. Library versions used: tokio 1.52.2.


  1. Strictly speaking, the compiler is suggesting swapping the Cell inside Foreign for an RwLock — i.e., changing Foreign’s internals. By stipulation we can’t do that, since Foreign is foreign. Wrapping Foreign in a Mutex from the outside is the same underlying move though: reach for a Sync synchronization primitive somewhere in the chain. ↩︎