Is there a better way to trigger on Arc clone/drop?

I'm working on a versioned data structure and associated manager using tokio. The manager keeps track of the different versions of the structure, provides a reference to the current version on request, and needs to take action when a version is no longer in use. I wanted to use the built-in Arc type for this, but there's no clear way to extend its functionality.

The solution I came up with is a wrapper data structure around Arc ("AwaitableArc", which is kind of a misnomer) with manually implemented Clone and Drop. When clone or drop is called on AwaitableArc, the normal Arc method is called, and a notification is sent to an mpsc channel. Each one has an identifier so that the manager can tell which AwaitableArc fired.

pub(crate) struct AwaitableArc<T, U: Send + Clone + std::fmt::Debug + 'static> {
    id: U,
    arc: Arc<T>,
    tx: mpsc::Sender<(U, usize)>,
}
impl<T, U: Send + Clone + std::fmt::Debug + 'static> Clone for AwaitableArc<T, U> {
    fn clone(&self) -> Self {
        let arc = self.arc.clone();
        let count = Arc::<T>::strong_count(&arc);
        let tx = self.tx.clone();
        let id = self.id.clone();
        tokio::spawn(async move {
            tx.send((id, count)).await.unwrap();
        });
        Self {
            id: self.id.clone(),
            arc,
            tx: self.tx.clone(),
        }
    }
}
impl<T, U: Send + Clone + std::fmt::Debug + 'static> Drop for AwaitableArc<T, U> {
    fn drop(&mut self) {
        let count = Arc::<T>::strong_count(&self.arc) - 1;
        drop(&self.arc);
        let tx = self.tx.clone();
        let id = self.id.clone();
        tokio::spawn(async move {
            tx.send((id, count)).await.unwrap();
        });
        drop(&self.id);
        drop(&self.tx);
        drop(&self)
    }
}
impl<T, U: Send + Clone + std::fmt::Debug + 'static> Deref for AwaitableArc<T, U> {
    type Target = Arc<T>;
    fn deref(&self) -> &Self::Target {
        &self.arc
    }
}

I have a listener awaiting the mpsc channel in a loop. It receives (id, strong_count) and will take action when strong_count drops to 1 (the manager holds a reference, so dropping to 1 means nobody else is holding a reference and we can delete the structure) This seems like it should work, but I'm not sure if it's good practice. Am I abusing Drop here?

Is there a simple solution I've missed to respond to changes in Arc::strong_count ?

This line of code does nothing, since shared references are Copy, so this code is likely to not work in the exact way you've intended.

2 Likes

Sending the count seems race-condition-y if I understand (even if you fix drop). You should check the count on the Manager side.

Consider:

  • Manager hands out version 1 to A (actual count: 2)
  • A starts to drop (count in drop: 1)
  • Manager hands out version 1 to B (actual count: 3)
  • Manager creates version 2
  • A drops Arc (actual count: 2) and sends "1"
  • Manager gets message about non-current version 1 being at count 1
  • Manager cleans up while B is still around

Or:

  • Manager hands out version 1 to A (actual count: 2)
  • A starts to drop (count in drop: 1)
  • A drops the Arc (actual count: 1)
  • Manager hands out version 1 to B (actual count: 2)
  • Manager creates version 2
  • A sends the message by now
  • Manager gets message about non-current version 1 being at count 1
  • Manager cleans up while B is still around

Or on the cloning side:

  • Version x exists a bunch of places
  • M clones into X (actual count: 27)
  • N clones into Y (actual count: 28)
  • N reads strong count and sends 28
  • M reads strong count and also sends 28

Depending on the expected usage patterns, you could just do a GC-like sweep of the non-current versions occasionally and/or when creating new versions.

2 Likes

How eagerly do dead versions need to be swept?

This is all great feedback. I'll sort out my implementation of Drop.

@quinedot - good callouts. The manager is checking the strong count when it receives the notification as well.

Cleaning up on creation of a new version or with an occasional sweep is a good idea and a major simplification. If that becomes a performance issue, I'll reconsider... but for now, that seems like the simplest solution by a lot