Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Why UnsafeCell in Mutex? #52

Open
skibon02 opened this issue Sep 16, 2024 · 4 comments · May be fixed by #53 or #54
Open

Why UnsafeCell in Mutex? #52

skibon02 opened this issue Sep 16, 2024 · 4 comments · May be fixed by #53 or #54

Comments

@skibon02
Copy link

skibon02 commented Sep 16, 2024

The core idea of a mutex is to make !Sync types Sync. This is exactly what this line does

unsafe impl<T> Sync for Mutex<T> where T: Send {}

!Sync type == cannot function properly when used concurrently behind shared references.

To fulfill the safety contract we must guarantee that all shared access to the inner type is synchronized. From the perspective of critical sections, fulfillment of the safety contract depends only on proper handling of lifetimes, because while owning a valid CriticalSection<'cs>, any shared access to inner type is safe.

There is no actual benifit to using UnsafeCell here, and the only requirement is to ensure that shared access to the inner type bound by 'cs lifetime.

As I ran some tests, it seems like additional UnsafeCell layer does not cause any performance regressions. But if we change it to just T, curious people like me will have fewer questions regarding necessity of such wrappers :)

@BartMassey
Copy link
Member

We discussed this one at today's Embedded WG meeting. We're still thinking about it… Should see more comments here shortly.

@jannic
Copy link
Member

jannic commented Sep 17, 2024

I don't know if the UnsafeCell can be removed or if it is needed for some subtle reason. (I guess it can be removed - but these things are tricky, better ask someone more knowledgeable.)

But I wondered if it does make a difference. After all, UnsafeCell doesn't change the layout of the type at all. I see three main cases:

  • The wrapped type is already Sync. That doesn't make much sense, why wrap such a type in a Mutex?
  • The wrapped type contains some kind of Cell. In that case, it already has interior mutability, the additional UnsafeCell doesn't change that. So this shouldn't change optimization results either. (Didn't verify that, though).
  • The wrapped type contains a pointer. This one is interesting. With the UnsafeCell, the optimizer can't assume that the pointer stays constant, even if the Mutex doesn't provide any way for the pointer to be mutated.

I looked at the third case in compiler explorer, and indeed, mutex1 (without the UnsafeCell) generates slightly shorter code than mutex2 (with the UnsafeCell): https://godbolt.org/z/bo5KfsnWG

So, assuming that it is sound to remove the UnsafeCell, it might be worthwhile to do so.

@jannic
Copy link
Member

jannic commented Sep 17, 2024

As suggested by @thalesfragoso, I also tried wrapping T by MaybeUninit instead of UnsafeCell.
It provides the same optimized code as just removing the UnsafeCell: https://godbolt.org/z/x8j4zn4Mb

@skibon02
Copy link
Author

I am not an expert in compiler internals, not even close. However, I want to summarize all possible usages of Mutex to narrow down where exectly there may be a potential problem.

Let's explore all the details of a simpler implementation of Mutex<T> where inner: T. Any usage of Mutex can be categorized into one of the following cases:

1. If T: Sync + Send

In this case, Mutex<T> will also become Sync + Send. Therefore, the unsafe impl Sync has no effect on the Mutex, making all API calls safe.

2. If T: !Send

Here, Mutex will become !Send + !Sync. We either make the contract of T stronger by making it !Sync or leave it with the exact same markers.
Furthermore, the unsafe impl Sync line does not apply to such types.
No additional possibilities are provided for the inner type.

3. If T: !Sync + Send

This is the only case where unsafe impl Sync is really applied. Here where we need to pay attention to it.

From doc.rust-lang.org:
The precise definition is: a type T is Sync if and only if &T is Send. In other words, if there is no possibility of undefined behavior (including data races) when passing &T references between threads.

Such types are acceptable when accessed from different threads, as long as these accesses never overlap. In critical_section contract, it depends fully on the acquire/release implementation. To fulfill safety contract, we must ensure that any access to values under Mutex happens-after the end of the acquire call and happens-before the beginning of therelease call.

From my understanding, there are two concerns regarding such memory accesses:

  1. Run-time or compile-time "caching" of the value.
    The compiler may use value from a previous critical section without reading it again in the case of modification. However, this is fine for types without interior mutability, as all types with interior mutability contain UnsafeCell, preventing the compiler from making such optimizations.

  2. Memory access reordering.
    Compiler is free to reorder memory accesses as long as such reorderings are not observable and do not affect program execution. Implementors usually add an additional compiler_fence to prevent compiler from making such optimizations.

Again, correct me if I'm wrong, but it seems like using inner: T should be fine according to Rust safety contracts.
So, what exactly kind of unexpected or undefined behavior concern is preventing us from removing UnsafeCell completely?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants