Confused about Send, Arc, and Mutex when passing dyn trait function parameters

It does not. That would mean String never gets deallocated, for example, because String: 'static.

It does mean it would be sound to hold an actual value in memory for the entire duration of the program.

Rust lifetimes -- 'a things -- generally denote the duration of borrows. They don't directly represent liveness of a value. And a value can contain a borrow that lasts longer than the value itself. Otherwise it would be impossible to store a &'static str (or Cow<'static, str>, etc) in a local variable.

An approximation of what T: 'x means is "any borrows in T are at least as long as 'x". So for 'static specifically, T: 'static means "any borrows in T are valid for the rest of the of the program." And an approximation of that is "T holds no borrows", as in some sense, borrows that last forever might as well not be borrows at all.

These are approximations as the lifetime bounds are checked syntactically. So a dyn Trait<&'a str> + 'static does not meet a 'static bound for example, due to the presence of 'a.[1] It has to work this way for various parts of the type system to be sound.[2]

Borrow checking is a pass-or-fail test that doesn't change the semantics of your program. It's impossible to use Rust lifetime annotations to force a value to live longer. And again, Rust lifetimes don't even denote how long values live at all -- they are a type level property.

So this isn't the case. The 'static bound is required because the call to spawn returns immediately, and the caller is free to keep executing -- including dropping or mutating local values, say. But the futures still exist in the runtime and will generally get executed at some point. If those futures held borrows of the local values that got dropped or mutated, that would be unsound (executing the futures could result in use-after-free, data races, all that junk).

Thus spawn forbids the futures from holding any borrows -- or at least, any borrows that aren't valid "forever".

Spawning threads and immediately letting the caller continue executing has the same type of concerns (independently of async considerations), so the closure you pass it must meet a 'static bound too. In contrast, scope allows spawning threads that do borrow locals (and thus the closures do not have to be 'static), because scope doesn't return until all the threads have stopped executing.

Send is a type-system level assertion that says "it is always sound to send this values of this type to another thread". It has no methods or runtime effect; it exists so we can have compile-time guarantees that we're not going to end up with a data races and the like in multi-threaded programs. The compiler doesn't intrinsically know what it means; APIs that are going to send things to another thread (like spawn) have Send bounds on their generics in order to participate in this compile-time soundness guarantee system.

T: Sync means &T is Send.

Here's my favorite exploration of the topic. It has some concrete examples of types which are and are not Send/Sync and why.

The shorter answer is that it doesn't do anything like silently adding a Mutex somewhere. It changes some types and some type level guarantees which are checked at compile time. I think the only places you can add + Send to types are dyn Trait and -> impl Trait, so let's look at each of those more closely.


These are four distinct types:

dyn Trait               // Erased type implements `Trait`
dyn Trait + Send        // Erased type implements `Trait` and `Send`
dyn Trait + Sync        // Erased type implements `Trait` and `Sync`
dyn Trait + Send + Sync // Erased type implements `Trait`, `Send`, and `Sync`

And generally speaking, dyn Trait + Send will implement Send, but dyn Trait will not. So in this case, when you add + Send, you're choosing a different type, like choosing Arc<str> instead of Rc<str> or such.

There's no extra Mutex or such introduced by adding + Send. There's a difference in what types can coerce to dyn Trait + ... (you have to meet all the bounds), and there's a difference in what traits are implemented automatically. dyn Trait + Send can implement Send automatically because you cannot coerce a value to dyn Trait + Send unless the value's type is Send. It's all part of the "guaranteeing soundness at compile time using the type system" approach.


When you add + Send to a -> impl Trait type...

fn f0<T>() -> impl Trait { ... }
fn f1<T>() -> impl Trait + Send { ... }
fn f2<T>() -> impl Trait + Sync { ... }
fn f3<T>() -> impl Trait + Send + Sync { ... }

you're guaranteeing to the caller that the hidden return type always implements Send (for f1 and f3). (There's no change in the type; no implicit Mutex etc.)

If you don't make that guarantee, it may still be the case that the hidden return type always implements Send. Or it may be the case that it never implements Send. Or it may be the case that it conditionally implements Send based on what the generic T type ends up being.

Whether you make the guarantee or not, if the hidden type implements Send (or any other auto-trait), the caller is allowed to utilize that (e.g. by passing returned values to spawn). This is sort of like how if you don't explicitly implement Send for your struct, users of the struct can still make use of its automatic Send implementation (assuming it has one).[3]

A big reason it works this way -- auto traits of the hidden type leaking when there is no guarantee -- is that the conditional case is very common, but we have no syntax to convey a conditional guarantee. Like from earlier in this topic I mentioned a Shared<T> which is Send if (and only if) T is Send.[4] So you might have something like...

// Can't use `-> impl Trait + Send` here because whether or not
// the return type is `Send` is conditional on `T`.
fn g0<T>(t: T) -> impl Trait { Shared(t) }

// Works, but now we've limited which `T` you can pass in
// (which might be fine sometimes, but not always).
fn g1<T: Send>(t: T) -> impl Trait + Send { Shared(t) }

// More ideal, but doesn't exist in the language.
fn we_wish<T>(t: T) -> impl Trait + if<T: Send then Send> { Shared(t) }

Because the actual type of each -> impl Trait is hidden from the caller, the return type of g0 and g1 act like distinct types (e.g. you couldn't put them both in the same Vec<_>[5]), so providing both functions isn't a complete workaround for the "conditionally Send" scenario.

And also I suppose, the conditions aren't always so straight-forward, it would be a lot of boilerplate to cover all the auto-trait combinations, etc. Downsides which do sometimes come up with dyn Trait + ..., where there is no leakage.

(A downside of the leakage is that it's easier to accidentally make a breaking change by returning a different type that doesn't implement Send in the same cases -- similar to structs without explicit auto trait implementations.)


  1. assuming 'a does not equal 'static ↩︎

  2. at least as things are now, i.e., without significant changes to the type system ↩︎

  3. Part of the reason for -> impl Trait is so that you can change the actual type returned, so it's not exactly the same, but anyway. ↩︎

  4. This is almost always how things end up working for some GenericStruct<T>, hence why the conditional case is so common. ↩︎

  5. without type erasure ↩︎