345: Async Drop
Tutorial
The Problem
Resource cleanup (closing files, flushing buffers, notifying peers) often requires async operations — but Rust's Drop trait is synchronous. If an async task holding a database connection is cancelled, its Drop runs synchronously on the async runtime, potentially blocking the executor thread. This mismatch is a known pain point: Rust doesn't yet have AsyncDrop in stable (RFC 3541 is in progress). The workaround is RAII guards with synchronous Drop that signal cleanup flags, deferring actual async cleanup to explicit close() methods or defer!-like patterns that run before the future is abandoned.
🎯 Learning Outcomes
Drop to run cleanup logic when a value goes out of scopeArc<AtomicBool> as a cleanup witness to verify Drop randisarm() to skip cleanup on success pathsDrop cannot be async and the implications for async codeclose() methods are necessary for async cleanupCode Example
#![allow(clippy::all)]
//! # Async Drop
//! Cleanup resources when async tasks are cancelled or complete.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
pub struct Resource {
id: usize,
cleaned_up: Arc<AtomicBool>,
}
impl Resource {
pub fn new(id: usize) -> (Self, Arc<AtomicBool>) {
let flag = Arc::new(AtomicBool::new(false));
(
Self {
id,
cleaned_up: Arc::clone(&flag),
},
flag,
)
}
pub fn id(&self) -> usize {
self.id
}
}
impl Drop for Resource {
fn drop(&mut self) {
self.cleaned_up.store(true, Ordering::SeqCst);
}
}
pub struct Guard<F: FnOnce()> {
cleanup: Option<F>,
}
impl<F: FnOnce()> Guard<F> {
pub fn new(cleanup: F) -> Self {
Self {
cleanup: Some(cleanup),
}
}
pub fn disarm(mut self) {
self.cleanup = None;
}
}
impl<F: FnOnce()> Drop for Guard<F> {
fn drop(&mut self) {
if let Some(f) = self.cleanup.take() {
f();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resource_cleanup_on_drop() {
let flag;
{
let (r, f) = Resource::new(1);
flag = f;
assert_eq!(r.id(), 1);
}
assert!(flag.load(Ordering::SeqCst));
}
#[test]
fn guard_runs_cleanup() {
let called = Arc::new(AtomicBool::new(false));
let c = Arc::clone(&called);
{
let _g = Guard::new(move || c.store(true, Ordering::SeqCst));
}
assert!(called.load(Ordering::SeqCst));
}
}Key Differences
| Aspect | Rust Drop | OCaml Fun.protect |
|---|---|---|
| Trigger | Automatic when value leaves scope | Must explicitly wrap with protect |
| Async cleanup | Not supported in Drop | Lwt.finalize handles async |
| Panic safety | Drop runs even on panic (usually) | finally runs even on exception |
| Zero-cost | Yes — no runtime overhead | Minor overhead for exception handling |
| Guard pattern | Option<F> + disarm() | Return value or flag from finally |
OCaml Approach
OCaml lacks RAII (no destructors). Cleanup is managed explicitly through Fun.protect:
let with_resource id f =
let cleaned_up = ref false in
Fun.protect
~finally:(fun () -> cleaned_up := true)
(fun () -> f id)
Fun.protect ~finally guarantees finally runs even if f raises an exception — the functional equivalent of RAII. For Lwt async cleanup: Lwt.finalize runs a cleanup promise whether the main promise succeeds or fails.
Full Source
#![allow(clippy::all)]
//! # Async Drop
//! Cleanup resources when async tasks are cancelled or complete.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
pub struct Resource {
id: usize,
cleaned_up: Arc<AtomicBool>,
}
impl Resource {
pub fn new(id: usize) -> (Self, Arc<AtomicBool>) {
let flag = Arc::new(AtomicBool::new(false));
(
Self {
id,
cleaned_up: Arc::clone(&flag),
},
flag,
)
}
pub fn id(&self) -> usize {
self.id
}
}
impl Drop for Resource {
fn drop(&mut self) {
self.cleaned_up.store(true, Ordering::SeqCst);
}
}
pub struct Guard<F: FnOnce()> {
cleanup: Option<F>,
}
impl<F: FnOnce()> Guard<F> {
pub fn new(cleanup: F) -> Self {
Self {
cleanup: Some(cleanup),
}
}
pub fn disarm(mut self) {
self.cleanup = None;
}
}
impl<F: FnOnce()> Drop for Guard<F> {
fn drop(&mut self) {
if let Some(f) = self.cleanup.take() {
f();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resource_cleanup_on_drop() {
let flag;
{
let (r, f) = Resource::new(1);
flag = f;
assert_eq!(r.id(), 1);
}
assert!(flag.load(Ordering::SeqCst));
}
#[test]
fn guard_runs_cleanup() {
let called = Arc::new(AtomicBool::new(false));
let c = Arc::clone(&called);
{
let _g = Guard::new(move || c.store(true, Ordering::SeqCst));
}
assert!(called.load(Ordering::SeqCst));
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resource_cleanup_on_drop() {
let flag;
{
let (r, f) = Resource::new(1);
flag = f;
assert_eq!(r.id(), 1);
}
assert!(flag.load(Ordering::SeqCst));
}
#[test]
fn guard_runs_cleanup() {
let called = Arc::new(AtomicBool::new(false));
let c = Arc::clone(&called);
{
let _g = Guard::new(move || c.store(true, Ordering::SeqCst));
}
assert!(called.load(Ordering::SeqCst));
}
}
Deep Comparison
OCaml vs Rust: Async Drop
Overview
See the example.rs and example.ml files for detailed implementations.
Key Differences
| Aspect | OCaml | Rust |
|---|---|---|
| Type system | Hindley-Milner | Ownership + traits |
| Memory | GC | Zero-cost abstractions |
| Mutability | Explicit ref | mut keyword |
| Error handling | Option/Result | Result<T, E> |
See README.md for detailed comparison.
Exercises
FlushGuard that wraps a BufWriter<File> and calls flush() in Drop; verify that partial writes are flushed even if the function panics midway.Guard with a counter, calls disarm(), lets it drop, and verifies the counter wasn't incremented; then write a complementary test without disarm().Resource that has a close(self) -> impl Future method for async cleanup; wrap it in a sync Drop that logs a warning if close() was never called.