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).