Systems Programming · Resource Management

Release Order at Scope Exit and the LIFO Cleanup Rule

Why destructors, defer statements, and scope guards run in reverse order of acquisition, and which ordering guarantees survive partial construction and unwinding.

A mutex is taken, then a heap node that the mutex protects is allocated. Releasing them in the order they were acquired means unlocking first and freeing second, which opens a window in which another thread holds the lock and dereferences a node that is already gone. The correct sequence is the opposite one, and the reason is not stylistic. Acquisition order encodes dependency: the later resource is usable only because the earlier one exists, so the later one must be dismantled while the earlier one is still intact. Every scope-exit mechanism in wide use therefore runs cleanup in reverse order of registration, and the differences between the mechanisms lie in how faithfully they preserve that order when a function returns early, unwinds, or fails halfway through construction.

The dependency that forces reverse order

Consider what a resource actually is. A resource is any object whose lifetime is not managed by the ordinary value semantics of the language: a descriptor, a lock, a mapping, a connection, a directory entry that must be removed. Acquiring one is a transition that can fail, and releasing one is a transition that can be forgotten. The pairs are not symmetric. A forgotten acquisition fails loudly, usually with an error return, while a forgotten release fails silently, consuming a slot in a finite table until the process or the machine runs out.

Nesting is what makes order significant. Any two resources acquired in sequence where the second depends on the first form a stack. Directory handles illustrate the pattern in a form that is easy to verify: a file is created inside a directory, then the directory is fsynced so the entry survives a crash, then both descriptors close. Closing the directory descriptor before the fsync makes the fsync fail with EBADF. Reversing the pair is not a convention, it is the only sequence that works.

Once the release sequence is a stack, a single mechanism handles every resource in a scope: push a cleanup action when the resource is acquired, and pop and run actions when the scope is left. This is why the implementations that look most different are actually the same algorithm. C++ runs destructors of automatic objects in reverse order of construction; Go runs deferred calls in last-in-first-out order; Rust drops locals in reverse declaration order; a scope guard library registers a callable and invokes it on destruction. The uniformity is forced by the data structure, not copied between languages.

Three implementations of one rule

C++ ties cleanup to the object model. A local object of class type with a destructor has that destructor invoked when control leaves the block in which it was declared, including when control leaves through an exception, a return, or a goto that exits the block. Destruction proceeds in the reverse of the order in which construction completed, which matters for the case of a partially constructed object: if a constructor throws after initialising three of its five members, the three completed members are destroyed and the two never-constructed members are not.

Go ties cleanup to a statement rather than a type. The defer statement evaluates its function value and arguments immediately, at the point the statement executes, and schedules only the call for later. That argument-evaluation rule is the part most often misread:

f, err := os.Open(path)
if err != nil {
    return err
}
defer f.Close()          // f is captured now, not at return

for _, name := range names {
    g, err := os.Open(name)
    if err != nil {
        return err
    }
    defer g.Close()      // all descriptors stay open until the function returns
}

The loop body leaks descriptors for the duration of the function even though each iteration appears to close its own. The fix is to move the loop body into a function so the deferred call belongs to a shorter scope. Since Go 1.14 the compiler open-codes the common cases of defer so that no heap record is allocated, but the semantic rule about argument evaluation is unchanged.

Rust ties cleanup to ownership. When a value that owns a resource goes out of scope, its Drop implementation runs, then its fields are dropped. The ordering rules are more specific than most people assume. Local variables drop in reverse declaration order, but struct fields drop in declaration order, and the fields of a tuple drop in order of position. An array’s elements drop from first to last. The reverse-order rule applies to the stack of locals, not to the layout of a composite value.

What partial construction leaves behind

The guarantee that matters most in practice is the one covering a function that fails after acquiring three of its five resources. Because the mechanism is a stack, the same unwinding path that handles the success case handles the failure case: whatever was pushed is popped.

C has no such mechanism, so the discipline is written out by hand, and the idiom that has survived is a single exit block with a chain of labels:

int copy_file(const char *src, const char *dst)
{
    int ret = -1;
    int in = -1, out = -1;
    char *buf = NULL;

    in = open(src, O_RDONLY);
    if (in < 0)
        goto done;
    out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (out < 0)
        goto done;
    buf = malloc(BUFSIZ);
    if (!buf)
        goto done;

    /* ... transfer loop ... */
    ret = 0;

done:
    free(buf);
    if (out >= 0)
        close(out);
    if (in >= 0)
        close(in);
    return ret;
}

Every variable starts in a state that the cleanup block can recognise as “not acquired”, and cleanup runs in reverse acquisition order regardless of which step failed. The cost is that each acquisition needs a sentinel initialisation and a test in the cleanup path. The benefit is that there is exactly one place where releases happen, so a new early return cannot skip one.

The same shape appears in the design of pool-based cleanup lists, where a caller registers callbacks against a pool and the pool runs them in reverse registration order when it is destroyed. That variant trades the scope for an object: cleanup happens when the pool dies, which may be much later than the function that registered the callback.

Ordering that the language does not promise

Reverse order is guaranteed within one scope, on one thread, for objects whose construction completed. Outside that envelope the guarantees thin out considerably.

Objects with static storage duration are destroyed in reverse order of completion of their construction, but the order between translation units is unspecified. A destructor that touches a global defined in another file can therefore observe an already-destroyed object. Function-local statics are destroyed in reverse order of construction, which is well defined only if construction order is; the ordering is established at run time by first passage through the declaration.

Longjmp is the C++ case that breaks the stack discipline outright. longjmp restores a saved register and stack context without running destructors, so if any automatic object with a non-trivial destructor is skipped, the behaviour is undefined. Signal handlers have the same problem for a different reason: only a small set of functions is async-signal-safe, and freeing memory is not among them.

Concurrent scopes have no shared order at all. Two goroutines that each defer a close will run those calls in an order determined by the scheduler, so any resource shared between them needs its own synchronisation rather than reliance on defer. The same applies to destructors of objects owned by different threads.

When the cleanup itself fails

Releasing a resource can fail, and the failure has to go somewhere. close returns an error, and on a network filesystem or with a write-behind cache that error can indicate data loss rather than a bad descriptor. A destructor cannot report it, so C++ destructors are expected not to throw; an exception escaping a destructor during another exception’s unwinding calls std::terminate. Rust makes the same choice more visibly: a panic that unwinds out of a Drop implementation while another panic is in flight aborts the process.

The practical resolution is to separate the two operations. Cleanup that cannot fail belongs in the scope-exit mechanism; cleanup that can fail is performed explicitly before scope exit, with its result checked, and the destructor then only performs the idempotent remainder. A file that must be flushed is flushed and checked, then closed by the guard. The guard’s job is to make the forgettable part impossible to forget, not to make the fallible part infallible.

This split is also what determines whether an error path can be trusted at all. The mechanism described here releases resources in the right order, but it says nothing about whether the object being released is in a coherent state. That question belongs to the error path itself, and it is where cleanup on failure becomes a separate design problem from ordering. Ordering also stops being sufficient once ownership moves between scopes, which is the subject of move-based ownership transfer, and it stops being available altogether when the scope is the whole process, as teardown at exit shows.

References

  1. ISO/IEC 14882:2020 — Programming languages — C++ — ISO/IEC
  2. The Go Programming Language Specification — The Go Project
  3. std::ops::Drop — The Rust Standard Library — The Rust Project
  4. close(2) — Linux man-pages — Linux man-pages project