7

In Rust Atomics and Locks chapter 5 (available online for free), this example implementation of a one-time channel is presented:

pub struct Channel<T> {
    pub message: UnsafeCell<MaybeUninit<T>>,
    pub ready: AtomicBool,
}
unsafe impl<T> Sync for Channel<T> where T: Send {}

impl<T> Channel<T> {
    pub const fn new() -> Self {
        Self {
            message: UnsafeCell::new(MaybeUninit::uninit()),
            ready: AtomicBool::new(false),
        }
    }

    pub fn send(&self, message: T) {
        unsafe { (*self.message.get()).write(message) };
        self.ready.store(true, Release);
    }
    pub fn is_ready(&self) -> bool { ... }
    pub fn receive(self) -> bool { ... }
}

This is the first example of a channel in the book and is not meant to be perfect, but the book points out that it needs a Drop implementation, in case the channel and its message is dropped without receive ever being called:

impl<T> Drop for Channel<T> {
    fn drop(&mut self) {
        if *self.ready.get_mut() {
            unsafe { self.message.get_mut().assume_init_drop() };
        }
    }
}

The book says:

In the Drop implementation of our Channel, we don’t need to use an atomic operation to check the atomic ready flag, because an object can only be dropped if it is fully owned by whichever thread is dropping it, with no outstanding borrows. This means we can use the AtomicBool::get_mut method, which takes an exclusive reference (&mut self), proving that atomic access is unnecessary. The same holds for UnsafeCell, through UnsafeCell::get_mut.

This was surprising to me, because if we call send() and then drop without calling receive(), it seems like there will be a Release fence on one side paired with plain unordered accesses in the Drop impl.

This is fine if the thread calling Drop is the same thread that called send(), but since the Channel is Sync, it seems that send() could also have been called by another thread, and then we never had an Acquire to pair with the Release on the ready flag.

Question 1: Is the Drop impl guaranteed to see a fully initialized message if it sees ready?

I assume it is, but I'd like to make sure I understand why the following scenario is not an issue:

  • Thread 1 spawns Thread 2
  • Thread 1 creates the channel
  • Thread 1 sends a &Channel reference to Thread 2 (OK because Channel is Sync, so &Channel is Send)
  • Thread 2 sends a large message through the channel, this Release-stores the ready flag
  • Thread 2 lets go of its &Channel reference, Thread 1 is now the only owner
  • Thread 1 has unique ownership again, and drops the channel

Question 2: Are we implicitly relying on other synchronization primitives to establish an Acquire/Release for us before Drop can run?

In my scenario above I omitted the way in which Thread 1 proves that it has unique ownership again. It seems most safe ways to do this like std::sync::Arc or scoped threads do actually establish an acquire-release relationship, so that Drop doesn't need to.

My intuition is that Thread 2 should always be required to use Acquire/Release to signal to Thread 1 that is has relinquished shared access, so that code using the &mut doesn't risk seeing partially updated data, but I'm not sure whether this is the implied ordering that the Drop implementation is actually implicitly relying on.

Am I understanding correctly the reason why the ordering works, and is this actually a guarantee that I can safely rely on?

1 Answer 1

8

Yes, that's exactly the point.

The &mut gives us a compile-time guarantee there was proper ordering.

Suppose, by contradiction, that there isn't a happens-before relationship. Then from the compiler's point of view, thread 2 could use the channel after thread 1 is dropping it. But that means the borrow checker will reject the code, because thread 1 doesn't have exclusive access.

So thread 1 has to prove to the compiler that there is a happens-before relationship, and we exploit that.

This is confusing because, naively thinking, the borrow checker doesn't think about happens-before relationships at all; the CPU does, and the optimizer does, but the borrow checker is before them.

But it goes indirectly: the borrow checker will require the actual execution of the drop in thread 1 to have exclusive access, because that's required by the Rust memory model. That execution depends on the happens-before relationship. And therefore, to pass the borrow checker, the abstraction the thread 1 uses to manage passing the channel to thread 2 will have to take care of this happens-before relationship. Otherwise, it'll be a broken unsafe code. It's a bit like we flow from compile-time to runtime and back to compile-time.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.