Skip to main content
added 5 characters in body
Source Link
tux3
  • 7.4k
  • 6
  • 41
  • 51
  • 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 dropslets go of its &Channel reference, Thread 1 is now the only owner
  • Thread 1 has unique ownership again, and drops the channel
  • 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 drops its &Channel reference, Thread 1 is now the only owner
  • Thread 1 has unique ownership again, and drops the channel
  • 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
Source Link
tux3
  • 7.4k
  • 6
  • 41
  • 51

Can a channel's Drop omit Acquire ordering, as in the Rust Atomics and Locks book?

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 drops 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?