Articles / Rust

  • rust
  • async
  • drop
  • tokio
  • raii
  • resource-management

Rust Has No Async Drop: Why You Can't Await in a Destructor

Here's a struct that looks entirely reasonable, and doesn't compile:

struct Session {
    stream: tokio::net::TcpStream,
}

impl Drop for Session {
    fn drop(&mut self) {
        // send a goodbye frame before the socket closes
        self.stream.write_all(b"BYE\r\n").await; // does not compile
    }
}

The compiler stops you immediately: await is only allowed inside async functions and blocks. Drop::drop is defined as fn drop(&mut self), a perfectly ordinary synchronous method. There is no async fn drop variant to opt into, and there never has been.

If you go looking for one, what you'll find instead is a long-running, unresolved design discussion in the async working group about whether such a thing can even exist in a sound form. It isn't a missing convenience method. It's a gap in the language that nobody has found a satisfying way to close.

Drop runs in places no executor can reach

The obvious fix looks like it should work: make drop return a future, and have the runtime poll it to completion before actually deallocating the value.

The problem is that "have the runtime poll it" assumes a runtime is available, willing, and able to do that at the exact point the drop glue runs. Rust drops values in far too many places to guarantee that.

Consider where drop glue actually executes:

  • At the end of a scope, which might be inside a synchronous function that never touches an executor at all.
  • During panic unwinding, where the stack is being torn down specifically to get out of trouble as fast as possible, not to schedule more work.
  • When a Future is dropped mid-poll, for example because tokio::select! chose a different branch, or a JoinHandle was aborted. The value being dropped is often a local variable inside another future's poll implementation. There is no waker context in which to register interest in a nested future's progress; you are already inside somebody else's poll.

That last case is the one that actually kills the idea. An async fn doesn't run to completion when you call it. It produces a Future that has to be polled, and polling can return Poll::Pending arbitrarily many times before it finishes.

If drop returned such a future, something would need to keep polling it, potentially across multiple wake-ups, after the value that owns the resource is already logically gone.

Actually, this bit is interesting: nothing in the ownership model has a slot for "this value is dead but still needs CPU time later". You'd effectively need a second, hidden lifecycle bolted onto every value that implements it. That's a different and much harder feature than the one anyone was asking for.

Spawning or blocking from Drop both bite

None of this stops resources needing async cleanup, so the ecosystem has settled on a few well-worn patterns. All of them have sharp edges.

Spawn a detached task

The first is spawning a detached task from inside drop:

impl Drop for Session {
    fn drop(&mut self) {
        let stream = std::mem::replace(&mut self.stream, /* dummy */);
        tokio::spawn(async move {
            let mut stream = stream;
            let _ = stream.write_all(b"BYE\r\n").await;
        });
    }
}

This compiles and often "works", but it quietly gives up two guarantees you probably wanted:

  • tokio::spawn requires an active runtime context. Call this from a thread with no tokio runtime and it panics on the spot, which is a spectacularly unwelcome thing for a destructor to do.
  • A spawned task is detached: nothing waits for it. If the process exits, or the runtime shuts down, shortly after the drop runs, the goodbye frame may simply never be sent.

You've turned "always runs synchronously" into "runs eventually, maybe, if nothing more urgent happens first".

Block the current thread

The second workaround blocks the current thread to force progress:

impl Drop for Session {
    fn drop(&mut self) {
        if let Ok(handle) = tokio::runtime::Handle::try_current() {
            let _ = tokio::task::block_in_place(|| {
                handle.block_on(self.stream.write_all(b"BYE\r\n"))
            });
        }
    }
}

This avoids the "never actually runs" problem but introduces a worse one. Calling block_on from inside a runtime worker thread that's already driving other futures can deadlock or panic outright ("Cannot start a runtime from within a runtime").

That's precisely why block_in_place exists to work around it on the multi-threaded scheduler. And it's precisely why it doesn't help at all on the current-thread scheduler, which has no other thread to hand the remaining work to.

An explicit async close() with Drop as a safety net

The workaround the ecosystem has converged on is to stop pretending Drop can do the job. Instead, make the async cleanup an explicit, consuming method that the caller is expected to invoke before the value goes out of scope, with Drop demoted to a safety net that only complains if you forgot:

pub struct Session {
    stream: Option<tokio::net::TcpStream>,
}

impl Session {
    pub async fn close(mut self) -> std::io::Result<()> {
        if let Some(mut stream) = self.stream.take() {
            stream.write_all(b"BYE\r\n").await?;
            stream.shutdown().await?;
        }
        Ok(())
    }
}

impl Drop for Session {
    fn drop(&mut self) {
        if self.stream.is_some() {
            tracing::warn!("Session dropped without calling close(); cleanup skipped");
        }
    }
}

This is not a workaround bolted on for this article's example: it's the same shape tokio itself uses. AsyncWriteExt::shutdown exists precisely because dropping an async writer, or a BufWriter wrapping one, does not flush it.

TLS streams need shutdown to send a proper close_notify rather than just severing the connection. If you never call it, the data you thought you wrote may simply not have gone anywhere. Drop cannot rescue you at that point; it can only log that you forgot.

RAII's guarantee quietly stops at the network

The honest way to read this pattern is that Rust never actually got async destructors. It got a convention where the "real" destructor is a method you have to remember to call, and the compiler-invoked Drop::drop is reduced to a diagnostic tool for catching the times you didn't.

That's a reasonable trade-off given the constraints. But it does mean the type system quietly stops enforcing the one thing RAII exists to guarantee: that cleanup happens whether or not the caller remembers to ask for it.

For synchronous resources that guarantee still holds. For anything that needs a network round trip to close cleanly, it's back to being your problem.