The Resource Problem — Cleanup in Async
RAII in synchronous code:
{
let file = File::open("data.txt")?;
process(&file)?;
} // file.drop() runs here, always, unconditionally
Reliable. Simple. The drop happens when the scope ends — no exceptions (unless you have exceptions).
The Async Complication
async fn process_data() -> Result<(), Error> {
let conn = open_connection().await?;
let data = fetch_data(&conn).await?; // What if this is cancelled?
transform_and_save(data).await?; // Or this?
conn.close().await?; // May never reach here
Ok(())
}
Three problems:
- Cancellation: If this async function is cancelled mid-execution,
conn.close()never runs. - Panic: If
transform_and_savepanics, the async task is dropped.conn.close()is skipped. - Async Drop:
impl Drop for Connectioncan only do synchronous cleanup. If closing a connection requires.await, you can’t do it inDrop.
conn.close() must be an async call, but Drop can’t be async. This is a fundamental mismatch.
The Root Cause
RAII relies on Drop running synchronously when a value goes out of scope. In async code, “going out of scope” and “running cleanup” can be decoupled — by cancellation, by executor scheduling, or by the fact that async closures are state machines that might never reach certain states.
The Solution Preview
effectful solves this with:
Scope— a region where finalizers are registered and guaranteed to run (even on cancellation or panic)acquire_release— a combinator that pairs acquisition with its cleanupPool— for long-lived resources that need controlled reuse
All three run cleanup effects (not just synchronous Drop), and all three run them unconditionally — success, failure, or interruption.