Internals · 05 / 11
Internals

Drop, RAII, and destructor order

Rust has no garbage collector, yet you almost never free anything by hand. The trick is RAII: every value has exactly one owner, and when the owner goes out of scope the compiler inserts a destructor call at a statically known point. This page covers the exact order those calls happen in, the drop check that keeps them sound, and the two escape hatches — mem::forget and ManuallyDrop — that turn destructors off.

Long read · drop order rules through dropck and the leakpocalypse · references at the end


1 · RAII, the actual mechanism

RAII (Resource Acquisition Is Initialization) means a resource — heap memory, a file descriptor, a lock, a socket — is tied to the lifetime of a value. Acquire in the constructor, release in the destructor. C++ invented the pattern; Rust makes it the default and then enforces it: because every value has exactly one owner, the compiler knows the single point at which each value dies, and emits the cleanup there.

rust src/main.rs · cleanup you never wrote
use std::fs::File;
use std::io::Write;

fn main() -> std::io::Result<()> {
    let mut f = File::create("out.txt")?;
    f.write_all(b"hello")?;
    Ok(())
} // <- close(fd) happens here, emitted by the compiler

There is no finalizer thread, no reference-counting pause, no "close it in a finally block" discipline. The destructor call is ordinary code at a fixed program point — which is also why Rust destructors are allowed to do real work (flush buffers, unlock mutexes, join threads) that GC finalizers famously cannot be trusted with.

2 · The Drop trait

Custom destructors come from one trait with one method:

rust src/main.rs · implementing Drop
struct Guard(&'static str);

impl Drop for Guard {
    fn drop(&mut self) {
        println!("dropping {}", self.0);
    }
}

fn main() {
    let _a = Guard("a");
    let _b = Guard("b");
    println!("end of main");
}

Three details worth knowing. First, drop takes &mut self, not self — if it took ownership, dropping the value would require dropping the value. Second, you cannot call it explicitly: x.drop() is a hard error (E0040), because the compiler will call it again at scope end and a double-drop is undefined behaviour. To drop early, you call std::mem::drop(x), whose entire implementation is an empty function that takes ownership:

rust core/src/mem/mod.rs · the whole of mem::drop
pub fn drop<T>(_x: T) {}
// moving x into the function makes the function body its scope;
// the destructor runs when the (empty) body ends.

Third, after your Drop::drop body runs, the compiler still recursively drops every field — that generated recursion is called drop glue. You never free your own fields; you only release whatever extra resource the struct represents.

3 · The order rules

Drop order is fully specified — it has been a stability guarantee since RFC 1857 (2017). The rules:

  • Local variables: reverse declaration order. Last declared, first dropped — later values may borrow earlier ones, so they must die first.
  • Struct and enum fields, tuple elements: declaration order, first to last (after your Drop::drop body, if any).
  • Arrays, slices, Vec: element order, front to back.
  • Function parameters: after the body, in reverse declaration order.
  • Temporaries: at the end of the enclosing statement, in reverse creation order.
rust src/main.rs · the rules, observable
struct G(&'static str);
impl Drop for G {
    fn drop(&mut self) { println!("{}", self.0); }
}

struct Pair { first: G, second: G }

fn main() {
    let _x = G("local x");
    let _y = G("local y");
    let _p = Pair { first: G("field first"), second: G("field second") };
}

Note the asymmetry: locals drop in reverse, fields drop in declaration order. Fields cannot borrow each other (safe Rust has no self-referential structs), so forward order is fine there — and it matches what #[repr(C)] interop code tends to expect.

The underscore trap. let _guard = lock.lock(); keeps the guard alive until end of scope. let _ = lock.lock(); does not — _ is not a binding, so the temporary is dropped at the end of that statement, and your critical section is unlocked before it starts. This is a classic production bug with MutexGuard and tracing spans.

4 · Drop flags — when "end of scope" isn't static

What if a value is only conditionally moved?

rust src/main.rs · conditionally moved
fn maybe_send(s: String, wire: bool) {
    if wire {
        send(s);          // s moved out on this path
    }
    // is s alive here? depends on `wire` at runtime
}

The compiler can't know at compile time whether s still owns anything at the end of the function. For these cases rustc inserts a drop flag — a hidden boolean on the stack, set when the value is initialized, cleared when it's moved out — and the scope-end drop becomes if flag { drop_in_place(&mut s) }. (Pre-1.12 these flags lived inside the struct itself; the MIR rewrite moved them to the stack frame, where they're optimised away whenever the path is statically known.) You can see them in the MIR output as drop(_1) terminators guarded by basic-block structure rather than as data.

5 · The drop check (dropck)

Destructors run code, and that code can read what the value borrows. So the borrow checker needs an extra rule: if a type has a custom Drop impl, everything it borrows must strictly outlive it — being equal isn't enough, because among values dying "at the same time" the destructor of one could read the freshly dropped corpse of the other.

rust src/main.rs · the Nomicon's Inspector
struct Inspector<'a>(&'a u8);

impl<'a> Drop for Inspector<'a> {
    fn drop(&mut self) {
        println!("I was only {} days from retirement", self.0);
    }
}

fn main() {
    let (inspector, days);          // same scope, declared together
    days = Box::new(1);
    inspector = Inspector(&days);
    // error[E0597]: `days` does not live long enough
    //
    // Remove the Drop impl and this compiles: with NLL the borrow is
    // dead after the assignment. Add Drop back, and the borrow must be
    // live again at the (implicit) drop point — where `days` may
    // already be gone.
}

The standard library needs to be exempt in one careful spot: Vec<T> has a Drop impl, but its destructor never uses the Ts beyond dropping them. The unstable #[may_dangle] attribute (the "eyepatch") lets Vec, Box and friends promise exactly that, so that a Vec of references with equal lifetimes still compiles. It requires unsafe impl and is not available on stable — if you hit dropck pain in your own collection type, that's the mechanism you're missing.

6 · mem::forget and the leakpocalypse

std::mem::forget(x) takes ownership of x and never runs its destructor. The memory of x itself (the stack part) is reclaimed; whatever it owned on the heap leaks. The surprising part: this function is safe.

That was decided under pressure, weeks before 1.0, in what the community calls the leakpocalypse. The pre-1.0 thread::scoped API returned a JoinGuard whose destructor joined the child thread — soundness depended on that destructor running before borrowed stack data died. Then someone constructed an Rc cycle containing the guard: the cycle leaks, the destructor never runs, the child thread keeps using a dead stack frame. Safe code, use-after-free.

The verdict, still in force: leaking is not undefined behaviour, and safety may never depend on a destructor running. Destructors are for correctness and resource hygiene; unsafe code must stay sound even if they are skipped. thread::scoped was removed; today's std::thread::scope (1.63) is closure-based, joining threads inside the scope function itself — there is no guard to leak.
rust src/main.rs · three ways to leak, all safe
use std::rc::Rc;
use std::cell::RefCell;

std::mem::forget(some_value);          // explicit
let p: &'static str = String::from("hi").leak();  // deliberate, useful
                                       // (Box::leak / Vec::leak / String::leak)

struct Node { next: RefCell<Option<Rc<Node>>> }   // an Rc cycle:
// a -> b -> a keeps both strong counts at 1 forever. No drop, no UB.

7 · ManuallyDrop — the precise tool

mem::forget consumes the value. When you need to keep using a value but take over responsibility for destroying it, the right tool is std::mem::ManuallyDrop<T> — a #[repr(transparent)] wrapper with the same layout as T that simply has no drop glue.

rust src/main.rs · ManuallyDrop in an owned-to-raw handoff
use std::mem::ManuallyDrop;

struct OwnedFd(i32);
impl Drop for OwnedFd {
    fn drop(&mut self) { unsafe { libc::close(self.0) }; }
}

/// Hand the fd to C; C now owns it and will close it.
fn into_raw(fd: OwnedFd) -> i32 {
    let fd = ManuallyDrop::new(fd);   // destructor disarmed
    fd.0                              // still readable through Deref
}

// The other direction, when you must run the destructor yourself:
//   unsafe { ManuallyDrop::drop(&mut slot) }   // exactly once!
//   let v = unsafe { ManuallyDrop::take(&mut slot) };  // move it back out

This is how the standard library itself sequences tricky destructions: Vec::into_iter holds its buffer in a ManuallyDrop so the iterator controls when elements and allocation die, and union fields with non-trivial destructors must be wrapped in ManuallyDrop because the compiler cannot know which union variant is live. ManuallyDrop::drop is unsafe for the obvious reason: calling it twice, or using the value afterwards, is UB.

8 · Drop under panic, and drop in async

Unwinding runs destructors. When a panic unwinds, every live local in every frame between the panic and the catch point is dropped, in the normal order. That's what makes MutexGuard release the lock on panic (after poisoning it). Two edge cases: if a destructor itself panics while unwinding from a previous panic, the process aborts — there is no double unwind. And with panic = "abort" in your profile, no destructors run at all on panic.

In async, drop is cancellation. A future is just a value; dropping it at an .await point drops whatever locals are live in the state machine at that state, and the rest of the body simply never runs. tokio::select! drops the losing branches' futures; a timeout drops the timed-out future. Every guarantee on this page is the fine print behind "cancellation safety": cleanup happens via destructors of the captured state, not via code after the await.

rust src/main.rs · cancellation = drop at the await point
async fn handler(pool: Pool) {
    let conn = pool.get().await;      // conn: PooledConn (Drop returns it to pool)
    let row = conn.query("...").await; // <- future dropped while suspended here?
    audit_log(&row).await;             //    then conn::drop runs, query never finishes,
}                                      //    audit_log never starts. No leak, no UB.

9 · Performance notes

  • needs_drop::<T>(). A compile-time constant: does T have any drop glue at all? Vec<u8> drops by freeing one allocation; Vec<String> must walk every element. Collections branch on this to skip the walk entirely for plain-data types.
  • Drop glue is code. Deeply nested generic types generate deeply nested drop functions; a Box-heavy recursive structure (a long linked list, a deep JSON tree) drops recursively and can overflow the stack. Production fix: an iterative Drop impl that pops children into a worklist.
  • Drop order is part of your API. If a struct holds a buffer and the arena it came from, field order decides whether the buffer is returned before the arena dies. Document it; reorder fields deliberately, or wrap with ManuallyDrop and sequence explicitly in Drop::drop.

References

Found this useful?