Updating the informal definition of Send and Sync

I believe the informal definition of Send and Sync suffers a similar (historical) terminology issue as &T and &mut T. The difference being that the problem for &T and &mut T is better known (at least from my experience).

Type/Trait How it looks like What it actually means
&T A shared reference A reference with shared access
&mut T A mutable reference A reference with exclusive access
T: Sync T can be shared between threads No thread restriction for shared access
T: Send T can be sent between threads No thread restriction for exclusive access

The difference between how those types/traits look like and what they actually mean is subtle and often insignificant. But I still believe the community would benefit from clarifying those definitions.

Note that for the purpose of Send, there is no difference between T and &mut T. Both are about exclusive access. The difference between those 2 types is how to dispose of their values when they are not needed anymore. This is also visible in the fact that types have only 2 kinds of safety invariants: one for exclusive access (T and &mut T) and one for shared access (&T).

The "No thread restriction" could probably be improved, but it captures how this is implemented in RustBelt (the satefy invariant does not depend on any particular thread ID). That informal definition could be expanded to something like: shared (resp. exclusive) access is valid regardless of the thread where it is used.

Is this alternative definition already documented somewhere and I missed it? Or worse, is this alternative definition wrong?

6 Likes

I don’t see the proposed descriptions of Send/Sync as an improvement. They make it harder in my opinion to reason about what the right bounds for cases with shared mutability are. Say, Mutex<T> and RwLock<T>; also Arc<T> which offers a small amount of mutable access.

With the premise of “you could use them to send T across a thread boundary” (keeping in mind the existence of mem::replace), it’s easy to argue the need for the T: Send bounds for each of Mutex<T>, RwLock<T>, and Arc<T>: Sync. With the argument of whether or not you’re able to obtain a &T that’s aliased by concurrent access to the same T value on another thread, you can argue why RwLock<T>: Sync also requires T: Sync while Mutex<T>: Sync doesn’t.

Admitted, this criterion was different from literally ”sending &T across thread boundaries“. So maybe there’s room for improvement, but I’d be cautious to remove the specific referral to the type &T. (If with the “exclusive access” you always mean “exactly the kind of access &T has”, then that’s still referring to &T, but in an unnecessarily covert way.)

In any case, the literal descriptions “No thread restriction for shared/exclusive access” don’t work because they don’t mention the type T at all.


Furthermore… it would be possibly confusing to (especially first-time) readers of this new description how a type could ever be Sync + !Send. No restriction for shared access, but restrictions for exclusive access? Isn’t “exclusive access” a special case of “shared access”, when you just decide not to share the value?

On that note, admittedly, one could also argue: isn’t “immutable access” a special case of “mutable access” when you just decide not to mutate the value. This can be confusing. Perhaps the best improvement is the full description, after all, call one thing “shared immutable (reference)” and the other “exclusive mutable (reference)”, then neither is a special case of the other.

I think for both references, as well as Send/Sync, one should be careful that “improvements” to the wording of informal definitions, or naming of things, don’t come with simultaneous regressions, too.


Even though the experienced Rust programmer will know that often “exclusive” vs. “shared” is the prime distinction between &mut T and &T, it is nonetheless still the case that the properties “mutable” vs. “immutable” are entirely irrelevant.

After all, we do have mutability markers for variables, and also syntax that literally says “mut” for “mutable”. The argument that mutation through &T references is still possible can be countered easily; it’s only ever possible with UnsafeCell-based wrappers, in the common case where something like that is not there, any mutation is true undefined behavior; and there can be a good interpretation, that also motivates the official naming of this pattern in the book – “interior mutability” – that &T is truly always immutable access, just that a type like &RefCell<T> still allows mutation to the interior of the RefCell (which is to be differentiated from the RefCell<T> as a whole). It’s the single[1] exception from the otherwise extant rule in Rust that mutability gets inherited by components, i.e. mutability of the whole struct determines mutability of the field, mutability of the Box<T> determines mutability of the referent.[2]


  1. or at least the biggest ↩︎

  2. As far as I’m aware, this kind of rule isn’t super common in other languages, especially for referents, and many languages happily call a variable holding one object or pointer “immutable” even when its fields or referent can still be modified. Just because we have this (admittedly nice) rule doesn’t mean we absolutely must enforce it without exceptions, all the way to the point that we would deny even explicitly marked “interior mutability”, calling &T no longer an “immutable reference” but merely a “shared reference”, and essentially drawing the conclusion that - after all - perhaps immutability as a language feature in Rust doesn’t exist at all ↩︎

1 Like

I do not quite agree with how you present &T in your table -- this is typically called a "shared reference", which in fact does align well with its "real" meaning.

But for Send and Sync I agree with you; those are after all the definitions RustBelt uses. It is never UB to "send a value across thread boundaries" since that's just shuffling bits around; the unsoundness arises because you end up having a "value that satisfies its safety invariant for thread T1" and access it from thread T2.

Isn't there a SendWrapper type that makes everything Send but adds some dynamic checks that the type actually keeps being used only in its original thread? That would be an example of a type that arguably violates "T can be sent across thread boundaries" but it can still be Send.

@RalfJung Good point, I've fixed the &T from "immutable reference" to "shared reference".

@steffahn Good point, the documentation has 2 target audiences: safe users and unsafe users. I'm mostly concerned about unsafe users, because safe users don't really need to understand type semantics because they don't need to prove anything. So it's true that for safe users, the most common usage of those types is probably the best definition. And it's fine if the definition is actually incorrect in some rare cases.

So in the rest of this post let's just focus on how Send and Sync should be documented in the Rustonomicon.

Note that there's a TODO at the end of the Send and Sync section (so this looks like a hole planned to be filled):

TODO: better explain what can or can't be Send or Sync. Sufficient to appeal only to data races?

Note also that similarly to shared references, the current definition of Sync is correct. The imprecision is more about Send (similarly to mutable references).

Let me try to illustrate and compare the definitions for Send using Mutex, PinMutex (some type I made up but maybe it exists), and SendWrapper (the type Ralf mentioned that may exist).

Mutex

We want to prove this line (i.e. write some SAFETY documentation):

unsafe impl<T: ?Sized + Send> Sync for Mutex<T> {}

Old version

Mutex<T> can be shared between threads as long as T can be sent between threads. The requirement is needed because Mutex<T> provides &mut T to different threads which is the same as sending T between threads (think mem::swap). It doesn't require T to be Sync because it doesn't share T between different threads.

New version

There is no thread restriction for shared access of Mutex<T> as long as there is no thread restriction of exclusive access of T. The requirement is needed because Mutex<T> provides exclusive access to T to different threads. It doesn't require T to be Sync because it doesn't provide shared access to T to different threads.

PinMutex

This would be something like Pin<Mutex<T>> but such that you can lock the mutex and get access to Pin<&mut T>. We want to prove this line:

unsafe impl<T: ?Sized + Send> Sync for PinMutex<T> {}

Old version

PinMutex<T> can be shared between threads as long as T can be sent between threads. The requirement is needed because PinMutex<T> provides Pin<&mut T> to different threads which would be the same as sending T between threads (think mem::swap) if it weren't pinned. [Insert some argument about why that makes sense.] It doesn't require T to be Sync because it doesn't share T between different threads.

New version

Same argument as for Mutex<T>. The fact that we can't send T between threads is irrelevant.

SendWrapper

SendWrapper<T> only provides the following API:

impl<T> SendWrapper<T> {
    fn pack(value: T) -> Self;
    // Panics if not called from the same thread as the one that packed it.
    fn unpack(self) -> T;
}

We want to prove this line:

unsafe impl<T> Send for SendWrapper<T> {}

Old version

SendWrapper<T> can be sent between threads. Even though sending SendWrapper<T> between threads results in the bytes of T being sent between threads, T cannot be used in other threads as long as it's packed, so it's not really sent in that sense. Since it can only be unpacked by the same thread that packed it, exclusive access to T is always used from that thread.

New version

There is no thread restriction for exclusive access of SendWrapper<T>, because it doesn't provide any type of access to T (shared or exclusive) as long as it's packed. Since it can only be unpacked by the same thread that packed it, exclusive access to T is always used from that thread.

1 Like

SendWrapper exists: send_wrapper - Rust

@ia0 it would be interesting to also spell out the reasoning for RwLock. After all, that one does give shared access to T to different threads.

Thanks for the SendWrapper link.

It's true RwLock brings some insight because it illustrates both SendWrapper (no bounds) and Mutex (requires Send) as sub-cases of RwLock based on the access provided.

Actually, I would be curious to know if there are buggy examples regarding Send. I want to know if it would be possible to inadvertently prove something wrong using this alternative definition. That would probably be the worse outcome.

RwLock

We want to prove this line:

unsafe impl<T: ?Sized + Send + Sync> Sync for RwLock<T> {}

Both proofs share this important fact about RwLock: At any time, there's either

  • No lock being held and thus there's no access to T (similar to SendWrapper and Mutex).
  • At most one thread holds a write lock and thus has exclusive access to T (similar to Mutex).
  • One or more threads hold a read lock and thus have shared access to T.

Old version

RwLock<T> can be shared between threads as long as T can be sent and shared between threads. The Send requirement is needed because, when locked for write, RwLock<T> provides &mut T to the thread holding the lock, which is the same as sending T between threads (think different threads using mem::swap when holding a write lock). The Sync requirement is needed because, when locked for read, RwLock<T> provides &T to the threads holding a read lock, thus sharing T between threads.

New version

There is no thread restriction for shared access of RwLock<T> as long as there is no thread restriction of exclusive and shared access of T. The Send requirement is needed because, when locked for write, RwLock<T> provides exclusive access to T to the thread holding the lock. The Sync requirement is needed because, when locked for read, RwLock<T> provides shared access to T to the threads holding a read lock, thus sharing T between threads.

1 Like