Finalizers & Destructors — Middle Level¶
Topic: Finalizers & Destructors Focus: How deterministic destructors and GC finalizers actually work under the hood — drop order, drop flags,
with/defer/using, the finalizer thread, resurrection, and the one-cycle delay.
Table of Contents¶
- Introduction
- Prerequisites
- Glossary
- Core Concepts
- Mental Models
- Code Examples
- Coding Patterns
- Pros & Cons
- Use Cases
- Best Practices
- Edge Cases & Pitfalls
- Summary
Introduction¶
At the junior level we drew the line between deterministic destructors and non-deterministic finalizers. Now we look at the mechanisms: what actually invokes cleanup, in what order, on which thread, and what can go wrong. The mechanism explains the rules. Once you understand how a finalizer gets scheduled — onto a queue, processed later by a dedicated thread, after at least one GC cycle — the "don't release scarce resources in finalizers" rule stops being dogma and becomes obvious.
We will cover the deterministic side (C++ destructors, Rust Drop with drop order and drop flags, Go defer, Python with, C# using) and the GC-driven side (Java finalize/Cleaner, Go SetFinalizer, Python __del__), including resurrection, ordering, and the finalizer thread model.
Prerequisites¶
- Junior tier: the deterministic vs non-deterministic distinction.
- Stack unwinding: when a scope exits (normally or via an exception/error), local variables are torn down in a defined order.
- Reference counting vs tracing GC (rough idea): Python uses reference counting plus a cycle collector; Java/Go use tracing collectors.
- Move semantics (rough idea, for Rust/C++): ownership of a value can transfer.
Glossary¶
| Term | Meaning |
|---|---|
| Stack unwinding | The process of destroying local variables as a scope exits (including during exception propagation). |
| Drop order | The order in which destructors run for variables in a scope — typically reverse of declaration. |
| Drop flag | A hidden runtime boolean Rust uses to track whether a value still needs dropping (e.g., after a conditional move). |
| Finalizer queue | A list the GC adds finalizable objects to; a separate thread drains it and runs their finalizers. |
| Finalizer thread | A dedicated background thread (or goroutine) that executes finalizers one by one. |
| Resurrection | A finalizer making the about-to-die object reachable again, canceling its collection. |
| Reachability | Whether the GC can still find an object through references from roots. |
| Idempotent close | A close operation safe to call more than once. |
ManuallyDrop | A Rust wrapper that suppresses automatic dropping so you control it yourself. |
Core Concepts¶
The deterministic side: cleanup driven by control flow¶
Deterministic cleanup hooks into control flow, not the GC.
- C++ destructors run during stack unwinding. When a block exits, automatic objects are destroyed in reverse construction order. This happens on normal exit and while an exception unwinds — the foundation of exception safety.
- Rust
Dropruns when a value goes out of scope. The compiler inserts thedropcalls. Drop order is reverse declaration order within a scope; struct fields drop in declaration order. Because moves can change whether a variable still owns a value, the compiler sometimes needs drop flags — runtime booleans tracking whether a drop is still required. - Go
deferpushes a deferred call onto a per-function stack; deferred calls run in LIFO order when the function returns (including onpanic). - Python
with/ C#usingare syntactic contracts:__enter__/__exit__andIDisposable.Dispose()are called at known points, even on exceptions.
The unifying property: you can read the source and identify the exact instant cleanup happens.
The non-deterministic side: cleanup driven by the GC¶
A finalizer is metadata attached to an object. When the GC discovers the object is otherwise unreachable, instead of freeing it immediately, it:
- Notices the object has a finalizer that hasn't run.
- Adds it to a finalizer queue (the object is now temporarily kept alive).
- A finalizer thread later dequeues it and runs the finalizer.
- On a subsequent GC cycle, if the object is still unreachable, its memory is finally reclaimed.
Two consequences fall out of this mechanism immediately:
- At least one extra GC cycle of delay (often more). The object lives longer than a non-finalizable one.
- A single shared thread (Java's finalizer thread; Go runs finalizers in a goroutine). If one finalizer blocks, the whole queue backs up — the finalizer thread stall.
Resurrection¶
During step 3, the finalizer runs arbitrary code — including storing this/the object into a global. That makes the object reachable again, so the GC must not collect it. The object is "resurrected." In most runtimes the finalizer will not run a second time even if the object dies again later, so a resurrected-then-re-abandoned object may leak its finalization entirely. Resurrection is almost always a bug.
Ordering among finalizers¶
The GC does not order finalizers by reference relationships. If object A's finalizer touches object B, and both are being collected, B might be finalized (or even freed) first. Never have one finalizer depend on another finalizable object still being valid. With reference cycles, the order is fully undefined.
Mental Models¶
- Destructor = "on the way out the door." It is wired to the exit of a scope/function, so it fires exactly when control leaves.
- Finalizer = "in the lost-and-found queue." The object is set aside, and a single clerk processes the queue eventually. You don't know when the clerk reaches your item, and the building might close (process exit) before they do.
- Drop flags = a checklist. "Did I already give this away (move) earlier? If so, don't try to drop it again." The compiler keeps the checklist so a moved-out value isn't double-dropped.
- Resurrection = a zombie. The object was pronounced dead, the mortician (finalizer) ran, and then it walked out alive. Spooky and almost never what you want.
Code Examples¶
Rust — Drop, drop order, and ManuallyDrop¶
struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) {
println!("dropping {}", self.0);
}
}
fn main() {
let _a = Noisy("a"); // declared first
let _b = Noisy("b"); // declared second
// At scope end, drop runs in REVERSE: "dropping b", then "dropping a".
// You cannot call _a.drop() explicitly; the compiler forbids it.
// To drop early, use std::mem::drop:
let c = Noisy("c");
drop(c); // prints "dropping c" right here, deterministically
// ManuallyDrop suppresses automatic Drop:
let mut m = std::mem::ManuallyDrop::new(Noisy("m"));
// m will NOT be dropped automatically; you must:
unsafe { std::mem::ManuallyDrop::drop(&mut m); }
}
Key facts: drop order is reverse declaration; you can't call drop(&mut self) directly (you use std::mem::drop, which just moves the value and lets it fall out of scope); ManuallyDrop opts out of the automatic call.
Go — defer (deterministic) vs SetFinalizer (non-deterministic)¶
func process(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // LIFO; runs on every return path, including panic
// ... use f ...
return nil
}
// A finalizer as a leak-detector backstop only:
func newBuffer() *Buffer {
b := &Buffer{handle: cAllocate()}
runtime.SetFinalizer(b, func(b *Buffer) {
// Runs in a goroutine, after >= 1 GC cycle, MAYBE before exit.
cFree(b.handle) // native memory the GC can't see
})
return b
}
defer is the real cleanup. SetFinalizer is the backstop for native memory; it is not guaranteed to run before the program exits.
Python — with vs __del__¶
# Deterministic: __exit__ runs at the end of the block, even on exception.
class Conn:
def __enter__(self):
self.sock = connect()
return self
def __exit__(self, exc_type, exc, tb):
self.sock.close() # known point
with Conn() as c:
use(c)
# Non-deterministic: __del__ runs when refcount hits zero — usually prompt
# under CPython, but NOT guaranteed (cycles, gc disabled, shutdown).
class Leaky:
def __del__(self):
self.sock.close() # may be late, may be skipped at shutdown
CPython's reference counting often calls __del__ promptly, which lulls you into trusting it — but cycles, a different interpreter, or shutdown break that.
Coding Patterns¶
Pattern: explicit close, finalizer as backstop¶
The canonical "two-tier" shape (shown here in Go-style pseudocode):
type Resource struct {
handle uintptr
closed bool
}
func (r *Resource) Close() error {
if r.closed {
return nil // idempotent
}
r.closed = true
return release(r.handle)
}
// Backstop: if the caller forgot Close(), the GC eventually releases native memory.
// (In Go 1.24+, prefer runtime.AddCleanup over SetFinalizer — see senior tier.)
Callers use defer r.Close(). The finalizer only fires when someone forgot, and it should log that mistake.
Pattern: idempotent close¶
Both an explicit close and a finalizer can run. Guard with a closed flag (or atomic) so the second call is a no-op. Without this, double-free crashes and "already closed" errors appear under load.
Pros & Cons¶
| Mechanism | Timing | Thread | Main use |
|---|---|---|---|
C++ destructor / Rust Drop | Scope exit (deterministic) | Caller's | Primary cleanup, exception/panic-safe |
Go defer | Function return (deterministic) | Caller's | Primary cleanup |
Python with / C# using | Block exit (deterministic) | Caller's | Primary cleanup |
Java finalize / Cleaner, Go finalizer, Python __del__ | GC-driven (non-deterministic) | Finalizer thread/goroutine | Backstop only |
Deterministic wins on timing, thread-locality, and exception-safety. Finalizers win only as a forgot-to-close safety net and native-memory reclaim.
Use Cases¶
defer/with/using/Drop/destructor: every scarce or side-effecting resource — files, sockets, DB connections, locks, transactions, temp files.- Finalizer/Cleaner: free native memory the GC can't see; detect and log leaks; never the only release path for OS handles.
Best Practices¶
- Wire cleanup to control flow, not to the GC, for anything that matters.
- Know your drop order (reverse declaration in Rust/C++) when one resource depends on another — declare the dependency first so it drops last.
- Make close idempotent so explicit-close + backstop-finalizer coexist safely.
- Never have one finalizer depend on another finalizable object.
- Never resurrect — don't store
self/thissomewhere live from inside a finalizer. - Don't block in a finalizer — you'll stall the shared finalizer thread.
Edge Cases & Pitfalls¶
- Conditional moves in Rust create drop flags; a value moved in one branch must not be dropped in the merge — the compiler handles it, but it explains why drops sometimes seem to "disappear."
deferin a loop stacks up until the function returns, not each iteration — can hold resources far too long. Wrap the body in a function or close explicitly.- CPython prompt
__del__is a trap: works in tests, fails on cycles or alternative runtimes. - Finalizer thread starvation: a slow or blocking finalizer halts every other object's finalization behind it in the queue.
- Shutdown skips finalizers in most runtimes; don't "flush on exit" from a finalizer.
Summary¶
- Deterministic cleanup (C++ destructors, Rust
Drop, Godefer, Pythonwith, C#using) is wired into control flow and fires at a known instant, in a known order (reverse declaration), on the caller's thread, safely through exceptions and panics. - Finalizers are queued when the GC finds an object unreachable, drained later by a single finalizer thread/goroutine, after at least one extra GC cycle, and possibly never (shutdown).
- That mechanism directly causes the hazards: delay, thread stalls, undefined ordering, and resurrection.
- Use the two-tier pattern: explicit idempotent close as the primary path, finalizer as a backstop that logs forgotten cleanup and reclaims native memory.
In this topic
- junior
- middle
- senior
- professional