What std::mem::forget Actually Does to Heap Allocations

What std::mem::forget Actually Does to Heap Allocations?

std::mem::forget drops the stack-allocated handle without executing Drop glue, permanently orphaning heap metadata within allocator arenas. It guarantees the absence of Undefined Behavior by design, while silently converting bounded operational memory into unrecoverable virtual memory fragmentation under sustained production throughput.

The Stack Frame Disappearance: Execution Flow of an Erased Destructor

The failure mode is silent, cumulative, and lethal to high-throughput systems. When a thread executes std::mem::forget on a heap-backed handle, the Linux kernel logs no faults, memory sanitizers pass the operation as sound, and CPU execution continues uninterrupted. Weeks into production execution, the Linux kernel Out-Of-Memory (OOM) killer abruptly dispatches an uncatchable SIGKILL to the process. Telemetry displays no panic records, no segmentation faults, and no heap corruption dumps only an unrelenting, monotonic growth of the process Resident Set Size (RSS) that progressively starves adjacent control planes.

To understand why this happens, the operation must be dismantled at the Application Binary Interface (ABI) layer. In modern Rust (post-RFC 1214), std::mem::forget<T> is not a compiler intrinsic. It is a plain function defined as:

pub fn forget<T>(t: T) {
    let _ = ManuallyDrop::new(t);
}

When passing a heap allocation such as a Box<T> or a capacity-backed Vec<T> into std::mem::forget, the handle moves by value. On an x86_64 target complying with the System V ABI, a Box<T> is physically represented on the thread stack as a single 64-bit virtual memory address pointing to the payload on the heap. Passing this Box into forget transfers that 64-bit integer into the function’s parameter storage (either the %rdi register or a designated stack spill slot).

Inside forget, the argument is wrapped into ManuallyDrop<T>. The ManuallyDrop type is decorated with #[repr(transparent)], guaranteeing that its memory layout, size, and ABI match T identically. However, ManuallyDrop<T> fundamentally alters compiler control flow: it deliberately lacks an implementation of core::ops::Drop.

When forget reaches its epilogue, the compiler’s drop elaboration pass scans the active scope. Because the value is encased in ManuallyDrop, rustc synthesizes zero drop flags and emits zero drop glue. No call to the allocator’s deallocation hook (alloc::alloc::dealloc or libc free) is compiled into the binary. The stack frame of forget collapses: %rsp increments, and the registers holding the heap address are cleared or repurposed by subsequent stack frames. The stack handle ceases to exist. The address is gone from CPU visibility, but the memory subsystem remains completely unchanged.

Anatomy of the Orphan: What the Allocator and Kernel See

While the thread stack has forgotten the memory location, the heap subsystem retains no telemetry that the reference was lost. Modern high-performance allocators such as jemalloc or ptmalloc operate through arenas partitioned into size-classed bins, slabs, and extents.

Stack (x86_64 Thread Frame)            Virtual Memory / Heap (jemalloc Arena)
┌────────────────────────────┐         ┌──────────────────────────────────────┐
│ %rdi / Stack Slot:         │         │ Arena Slab (e.g., 64-byte size class)│
│ [ 0x00007fff5f12a040 ] ────┼─────────┼─► [ Allocation Metadata: ACTIVE ]    │
│                            │         │   [ Payload: 64 bytes ]              │
└────────────────────────────┘         └──────────────────────────────────────┘
              │                                           │
   std::mem::forget(handle)                               │
              ▼                                           │
┌────────────────────────────┐                            │
│ %rsp increments;           │                            │
│ Slot overwritten by caller │                            │
│ [ 0x???????????????? ]     │                            ▼
└────────────────────────────┘         ┌──────────────────────────────────────┐
                                       │ Slab bit remains 1 (ALLOCATED).      │
   Pointer destroyed.                  │ Free-list bypasses this chunk.       │
   Zero references remain.             │ madvise(MADV_DONTNEED) NEVER runs.   │
                                       │ Virtual pages remain pinned in RSS.  │
                                       └──────────────────────────────────────┘

When the Box<T> was originally initialized, the allocator’s fast path:

  • Mapped the allocation request to a size-class slab (for instance, 64 bytes).
  • Located an active slab region associated with the calling thread’s CPU core arena.
  • Updated the internal slab bitmap, marking that specific chunk index from 0 (free) to 1 (allocated).
  • Returned the pointer to the client code.

Under standard RAII execution, when Box<T> falls out of scope, the destructor executes alloc::alloc::dealloc(ptr, layout). The allocator catches this invocation, flips the bitmap bit back to 0, updates its free-list pointers, and tracks slab vacancy. When every chunk inside a 4 KiB or 2 MiB page run becomes vacant, the allocator coalesces the run and issues an asynchronous madvise(addr, len, MADV_DONTNEED) or madvise(addr, len, MADV_FREE) system call. This informs the Linux kernel page frame reclaimer that the physical pages can be decoupled from the process’s page table entries (PTEs), dropping the process RSS.

Executing std::mem::forget breaks this operational chain entirely. The deallocation routine is bypassed. To jemalloc, the chunk at that virtual address remains marked as active, live heap memory in the slab bitmap. Because that chunk is never returned to the free-list, the surrounding slab can never reach a completely vacant state. A single orphaned allocation within a slab prevents the allocator from ever returning the backing 4 KiB page to the kernel.

The consequences compound under sustained workloads: pages remain populated with sparse, unreferenced allocations. Virtual address space becomes internally fragmented, and the kernel cannot evict these anonymous pages to reduce memory pressure without resorting to swap space or triggering kswapd.

The Triad: Contrasting ManuallyDrop, into_raw, and forget

Engineers frequently conflate ManuallyDrop<T>, Box::into_raw, and std::mem::forget. While all three suppress the invocation of Drop::drop, their effects on stack layout, resource ownership, and heap reachability are starkly differentiated.

use std::alloc::{Layout, alloc, dealloc};
use std::mem::{ManuallyDrop, forget};

#[repr(C)]
struct Node {
    payload: [u8; 64],
}

fn main() {
    unsafe {
        // --- 1. Box::into_raw: Controlled Ownership Transfer ---
        // Stack: Holds a 64-bit raw pointer (*mut Node).
        // Heap:  Allocated, fully reachable, deallocation deferred to caller.
        let boxed = Box::new(Node { payload: [0xAA; 64] });
        let raw_ptr: *mut Node = Box::into_raw(boxed);
        
        assert_eq!((*raw_ptr).payload[0], 0xAA);
        // Ownership retained: We can reclaim the memory deterministically.
        let _ = Box::from_raw(raw_ptr); // Drop runs here; heap chunk freed.

        // --- 2. ManuallyDrop<T>: Zero-Cost Stack Wrapper ---
        // Stack: Holds ManuallyDrop<Box<Node>>, identical ABI to Box<Node>.
        // Heap:  Allocated, reachable, destructor suppressed until manually triggered.
        let boxed_md = Box::new(Node { payload: [0xBB; 64] });
        let mut manual = ManuallyDrop::new(boxed_md);
        
        // Payload remains fully accessible via Deref/DerefMut:
        assert_eq!(manual.payload[0], 0xBB);
        
        // Destructor can still be executed deliberately without move penalties:
        ManuallyDrop::drop(&mut manual); // Deallocates heap memory via Drop glue.
        // Stack slot for 'manual' remains until end of scope, but memory is freed.

        // --- 3. std::mem::forget: Irreversible Reference Erasure ---
        // Stack: Moves Box<Node> into forget(), registers cleared on exit.
        // Heap:  Allocated, UNREACHABLE, allocator bitmap remains set to 1.
        let boxed_leak = Box::new(Node { payload: [0xCC; 64] });
        let leaked_address = &*boxed_leak as *const Node;
        
        forget(boxed_leak); 
        // AT THIS POINT:
        // - 'boxed_leak' stack handle is destroyed.
        // - Allocator receives NO deallocation signal.
        // - Heap chunk at 'leaked_address' is orphaned permanently.
        // - Reading 'leaked_address' via an external raw pointer is valid memory,
        //   but ownership invariants are destroyed.
        assert_eq!((*leaked_address).payload[0], 0xCC);

        // Emergency manual reclamation (Demonstration purposes only):
        // If we did not cache 'leaked_address', this memory is unrecoverable.
        dealloc(leaked_address as *mut u8, Layout::new::<Node>());
    }
}
MechanismStack RepresentationDestructor ExecutionHeap ReachabilityPrimary Architecture Purpose
Box::into_rawExposes naked *mut T pointer registerSuppressedFully retainedTransferring ownership across C-ABI FFI boundaries
ManuallyDrop<T>Transparent wrapper around T (#[repr(transparent)])Suppressed until .drop() or .into_inner()Fully retainedStruct field initialization unions and manual drop staging
std::mem::forgetConsumes T by value, tears down stack framePermanently suppressedSevered and lostInhibiting destructors when handles have already been duplicated

Cascading Resource Traps: Beyond Raw Bytes

The fatal assumption in production is viewing std::mem::forget solely through the lens of heap bytes. In systems programming, memory buffers are rarely inert data blocks; they encapsulate operating system handles and synchronization primitives.

Consider a heap-allocated struct encapsulating a POSIX file descriptor or a network handle:

struct SocketBuffer {
    fd: std::os::fd::RawFd,
    buffer: Box<[u8; 65536]>,
}

impl Drop for SocketBuffer {
    fn drop(&mut self) {
        unsafe { libc::close(self.fd); }
    }
}

Executing std::mem::forget on SocketBuffer suppresses SocketBuffer::drop. This does not merely orphan the 64 KiB buffer inside the allocator’s size-class bin; it halts the execution of libc::close(2). The Linux kernel’s open file table keeps the file descriptor slot open.

Under sustained traffic, the operating system reaches the per-process limit configured in RLIMIT_NOFILE. Subsequent attempts by database pools, logging engines, or RPC clients to open sockets begin returning EMFILE (“Too many open files”). The failure cascades outward, entirely detached from the code site where the allocation was forgotten.

A more severe state failure manifests when forgetting types holding synchronization boundaries. Forgetting a std::sync::MutexGuard leaves the underlying synchronization primitive such as an atomic lock flag or a Linux futex state permanently set to locked. Because the destructor does not run, the mutex is not merely poisoned; it remains permanently acquired without an owner. Every worker thread that subsequently attempts to acquire that lock transitions into uninterruptible kernel sleep (TASK_UNINTERRUPTIBLE), freezing worker pools without raising a panic.

The Architectural Trade-Off: Safety Guarantees vs. Resource Depletion

The existence of std::mem::forget as a safe function highlights an essential boundary in systems architecture: Rust’s type system guarantees memory safety, not resource liveness.

In the formal definition of the Rust abstract machine:

  • Undefined Behavior constitutes operations that invalidate compiler assumptions: data races, dereferencing invalid or dangling pointers, unaligned pointer access, or creating invalid references (such as aliased &mut).
  • Resource Leaks do not invalidate the abstract machine. An orphaned heap allocation occupies a valid, non-overlapping range of the process’s virtual address space. Because the abandoned block is never read after its handle disappears, no memory invariants are broken. It remains valid, mapped, and inert.

Consequently, memory leaking is safe by design. Safe code can construct circular reference graphs via Rc or invoke std::mem::forget without invoking unsafe.

The trade-off is stark: the language runtime sacrifices deterministic resource termination to eliminate undefined behavior at the FFI boundary. When interfacing with external runtimes (such as passing handles to C runtimes or asynchronous kernel completion rings like io_uring), std::mem::forget allows an engineer to disengage the compiler’s automatic destruction passes.

Using std::mem::forget anywhere outside of low-level FFI ownership handoffs or specialized lock-free algorithms is an architectural defect. It circumvents the deterministic teardown model that justifies using a systems language in the first place, converting deterministic allocation lifecycles into uncontrolled virtual memory expansion and allocator arena bloat.

You may also like

See All Posts →