Why these particular rules for `Send`, `Sync`, and borrowing references?

I composed a finite state automaton for the subject to better understand it. My interpretation of the rules is: if T implements Sync and doesn't implement Send and F is a non-empty composition of type constructors & and &mut, then F<T> implements Send iff the finite state automaton below accepts F (from right to left, that is, in application order).


If I analyzed it correctly, “the finite state automaton accepts F” iff “F contains &”. Does anybody know why these particular rules?

(Update 2025-01-12: “if T implements Sync” → “if T implements Sync and doesn't implement Send”.)

I think trying to see these rules as a finite state machine just makes them harder to understand. Instead I would suggest you to look at the individual rules for when Send and Sync are implemented respectively for &T and &mut T:

  • &T implements Sync when T implements Sync because sharing a &T only results in sharing the T, and it doesn't involve sending the T;
  • &T implements Send when T implements Sync because sending a &T results in sharing the T, and it doesn't involve sending the T;
  • &mut T implements Sync when T implements Sync because sharing a &mut T results in only sharing the T, and it doesn't involve sending the T;
  • &mut T implements Send when T implements Send because sending a &mut T can result in sending the T (e.g. if you std::mem::replace it, or if you assign to it), and it doesn't involve sharing the T (because the &mut T is guaranteed to be the only way to access it while it exists and is active).

Your automata is just a result of composing these rules under the premise that T is Sync and not Send, but is just a consequence of how the rules were thought. If you still want an explanation for that result, you can observe that type constructor with purely nested &mut _ will always require the inner type to implement Send, and so on until it requires T: Send. However the moment you have a & _ in the sequence the requirement changes to implementing Sync, and this is propagated with both &_ and &mut _ type constructors.

3 Likes

Hi, Send and Sync is a complex topic if you want to understand it thoroughly.

Your FSA seems to be about the Send & Sync implementations of &T and &mut T. If you start out with T: Sync and build up some nested type like &mut &mut T or &mut & &mut & &mut T or whatever, then your FSA seems accurate.

As for "why" – really the exact contract of Send & Sync isn't super easy to write down thoroughly in the first place; but these traits are necessary for “soundness” of Rust’s multi-threading-related APIs.

So let me try an explanation by-example; what kind of issues do Send & Sync prevent and roughly how?


In most cases, thread-safety in Rust comes in 3 flavors:

  • Send + Sync – this is the default. Most types in Rust are automatically thread-safe. This is because Rust’s rules of data always being “either shared or mutable” prevents thread-safety issues (data races) just as many other problems with “shared and mutable” (problems with re-entreancy; and programs becoming harder to reason about in general)
  • Send but not Sync – this is for owned data that isn’t thread-safe; this usually comes for data-structures which are offering some sort of “shared + mutable” access without also having built-in enforcement of synchronization (e.g. with mutual exclusion, or with more complex, less blocking strategies). A typical example is Cell<T> and RefCell<T>. These types do still offer full ownership of the contained data. Why does this matter? You could take Cell<T> (or RefCell<T>), with .into_inner() turn it into T, then send T to another thread, wrap it in a new Cell<T> (or RefCell<T>) there. The effect is the same as having sent the whole thing untouched. Rust thus splits up “thread-safety” into two separate traits, not one.
  • neither Send nor Sync – this is data that is even less thread-safe… generally you get this for a non-thread-safe data structure when you also don't (exclusively) own that data structure.

For your concrete case, you start with T: Sync; but in most cases would actually be T: Send + Sync. If you apply your (non-deterministic) finite automaton, when you start with the whole set of positions {Send, Sync}, then wrapping with &mut keeps you in {Send, Sync}… and wrapping with &, too, brings you to… {Send, Sync}. Ah, no effect at all: “thread-safe data stays thread-safe”.

If we instead start with {Send} then &mut-wrapping keeps you in {Send} but &-wrapping takes you down to {}. Finally, {} stays in `{}.

In the works of only {}, {Send}, {Send + Sync}, the rules for & and &mut are thus:

  • thread-safety doesn’t change trough &mut-indirection. (In this sense, exclusive mutable access and ownership is actually the same. This might be surprising… until you learn of APIs like mem::replace. Mutable borrowing is almost ownership; the only constraint you need to oblige is that the original owner of the value will keep some value of the same type when your borrow ends, but it might be a completely different value.

There’s one remaining case though. The set { Sync }, data that’s T: Sync but not (necessarily) also T: Send. This is relatively rare. It would even be reasonable to re-design Rust in a way that rules out this completely. Though it’s also not completely unused, actually.

But it’s a niche use-case. The only main use-case I’m really aware of is just MutexGuard which has a technical background that some OS implementations of mutex have a requirement that the thread that locked the thing be the same thread that unlocked it.

For MutexGuard, with Sync-but-not-Send, you hence can mark a case of “something that is fully thread-safe to ‘use’; but the ownership (in this case the Drop-ing of the value) can’t happen elsewhere”. Since &mut-access does enable ownership-transfer, all &mut-access is prohibited by T not being Send. This in turn also enables other more “trivial” cases of data that can safetly support Sync, e.g. the Exclusive wrapper. (The reasoning here is: this wrapper prevents &Exclusive<T>-references from being useful for anything at all.)

These are both advanced examples though, and with limited relevance. I don't think much would be lost if MutexGuard offered no thread-safety at all (if you want to share the locked data in a MutexGuard<'_, T> you can always instead share a &T reference to the contents), and we had a strict non-thread-safe < only-Send < Send+Sync hierarchy, the resulting automaton (would be deterministic anyway, and) looks a lot simpler, only really & going from only-Send to non-thread-safe would be the only edge not leading back into the same state itself.

And this makes sense in the context of my original explanation: & introduces sharing, so fully-thread-safe stays fully-thread-safe, but special “not thread safe, but sending with ownership” case (for Cell<T> or RefCell<T>) that I explained above, of course is taken to “not thread safe at all”.


For the full picture, it could also be interesting to look at thread bounds on relevant API, such as thread::spawn (and also the API around thread::scope for a more realistic case where &T or &mut T borrows could be sent between threads directly); and also on Arc.

Another (introductory) learning resource I enjoyed is this video which has a nice section with some thread-safety example.[1]


  1. Perhaps especially valuable if you have some experience with thread-safety in other languages; IMO it showcases the intended experience then of “these concrete examples would be a programming error in some other languages, but Rust prevents them from compiling in the first place”. ↩︎

3 Likes

Here's another overview of Send and Sync.

2 Likes