The challenge is in making the various forms of async destructor useful, not just easy to write.
First, whatever we do here has to be implemented in the compiler; in turn, that means that we expect it to work in a no_alloc environment, as not all Rust targets have an allocator. This makes AsyncDrop difficult - the drop glue has to allocate somehow, but we've just said it can't expect an allocator. The way round this is to add more magic allocations to every type that might have an async destructor, but that then means that you write pub struct Wrapped<T> { hidden: T }, and Wrapped is no longer the same size as a T at runtime, but some unpredictable amount larger to allow for drop glue.
poll_drop avoids this by saying that if you want an async fn drop_async(self: Pin<&mut Self>); in your type, you have to explicitly allow space for it - e.g. your struct might have to contain a async_drop: future::Fuse<âŚ> for the destructor, and then poll_drop can be implemented as:
fn poll_drop(self: Pin<&mut Self>, cx: &mut Context) {
self.async_drop.poll(cx)
}
So, that means that poll_drop is strictly more flexible than AsyncDrop - it can do everything that AsyncDrop can, just requires you to be explicit about storage, plus it allows you to write stateless poll_drop code (e.g. that just forwards to a subfield, for Box, Vec etc).
Second, poll_drop_ready is more useful to the implementor than poll_drop, because the guarantees are simpler to express. Consider the drop glue for both (in pseudo-Rust):
// Using `poll_drop`
// This function called repeatedly until it returns Ready, as per a normal async function
// cx is Some if in async context, None if not
magic fn drop_glue<T>(drop_me: Pin<&mut T>, cx: Option(&mut Context)) -> Poll<()> {
let async_drop_res = match cx {
None => { drop_me.drop(); Poll::Ready(()) }
Some(cx) => drop_me.poll_drop(cx),
}
if let Some(Ready) = async_drop_res {
recurse_drop_glue_members(drop_me, cx) // Defined as Poll::Ready(()) iff `drop_me` has no members, else runs this function on all members
} else {
async_drop_res
}
}
// Using `poll_drop_ready`
// This function called repeatedly until it returns Ready, as per a normal async function
// cx is Some if in async context, None if not
magic fn drop_glue<T>(drop_me: Pin<&mut T>, cx: Option(&mut Context)) -> Poll<()> {
let async_drop_res = match cx {
None => Poll::Ready(()),
Some(cx) => drop_me.poll_drop_ready(cx),
};
if let Some(Ready) = async_drop_res {
drop_me.drop();
recurse_drop_glue_members(drop_me, cx) // Defined as Poll::Ready(()) iff `drop_me` has no members, else runs this function on all members
}
async_drop_res
}
Yes, the glue code is marginally more complex in the latter case, as it runs two user-provided functions in an async context, not just one - however, the user of poll_drop_ready gets a guarantee that drop will also be called, not just poll_drop_ready. This then means that, as the user, you only need implement the memory-safety relevant code once - in drop - and not twice - in poll_drop as async code, and in drop as sync code - which reduces errors. poll_drop_ready is a pure optimization, as it stops you from having to block waiting for a destructor to finish.
This, in turn, means two things to the user of async destructors:
- When using
poll_drop, the drop method that's called varies according to what context you're in - for code that implements only one of the two drop methods (sync or async), we have to somehow ensure that the other one is called, and we need rules for when to write a poll_drop/AsyncDrop implementation, and when to just write drop. In contrast, the rules are simple for poll_drop_ready - drop is the code you write to ensure that nothing is leaked, poll_drop_ready makes sure that drop never blocks
- Non-trivial types need duplicate code between
drop and AsyncDrop/poll_drop, because both destructors needs to cleanly release resources you own. poll_drop_ready is just an optimization, so all resources can be cleaned up in drop, and poll_drop_ready just handles pushing async resources to a "done" state.
For example, take a process handling phone calls as part of a cluster of IMS servers - when it shuts down, we want to hand off all the active calls to another server, so that we don't drop calls on a normal restart. This implies that drop already has to hand all calls over, synchronously, to another server, and then drop all in-memory structures that represent those calls. In the poll_drop/AsyncDrop case (since the distinction between the two is just in whether the compiler allocates space for the drop future, or the user does), both chunks of work have to be repeated as part of the poll_drop work; in the poll_drop_ready case, poll_drop_ready has to asynchronously hand over all calls, but does not have to handle dropping the internal state (which cannot be handled asynchronously - there's no blocking involved here) once the calls are handed over, because drop will be called anyway.
TL;DR: poll_drop and AsyncDrop are equivalent in power, modulo who allocates storage for the Future state machine. poll_drop wins on that, because it makes the storage for the state machine explicit, rather than a compiler-generated allocation (which can't be done in a no_alloc world). poll_drop_ready is simpler to explain and involves less duplication since the glue code still calls sync drop, and it's clear what belongs to drop (everything), and what belongs to poll_drop_ready (any work that has to be done so that drop is non-blocking in async terms).