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.