Kernel Heap, Object Lifetimes, and Resource Ownership

LESSON

Operating Systems Implementation

005 30 min intermediate

Kernel Heap, Object Lifetimes, and Resource Ownership

By the end of this lesson, you will be able to...

  • Draw who owns a kernel process, open-file object, and I/O buffer at each step of a small request.

  • Distinguish an owning reference from a temporary borrowed pointer.

  • Review a cleanup path for leaks, double releases, and use-after-free failures.

Idea in one sentence: Kernel allocation is safe only when every object has a named owner, every lasting user holds a valid claim, and the final release follows one deliberate cleanup path.

calc opens a log file and starts a kernel request to write a copied buffer. Before the device reports completion, calc exits. What should disappear now? The process? Its descriptor? The open-file object? The buffer handed to the driver? The physical frames that back each object?

“Free everything when the process exits” sounds sensible until the driver completion handler runs later and follows a pointer to the buffer that was just freed. Keeping everything forever avoids that crash, but turns each request into a slow leak. The hard part is not calling an allocator. It is defining which part of the kernel is responsible for each resource at every moment.

The previous lesson managed whole physical frames and mappings. This lesson builds on that substrate. A kernel heap turns frames into smaller, long-lived objects such as process records, file objects, wait queues, and I/O requests. These objects move between subsystems and can outlive the process that created them. Their lifetime is therefore a design contract, not a local implementation detail.

Core Insight

For every kernel object, answer four questions before writing cleanup code:

  1. Who creates it and owns the initial claim?
  2. Who may retain it after the creating operation returns?
  3. What event releases each claim?
  4. What exact last-release path makes the object unreachable and returns its resources?

An owner is responsible for eventually releasing a resource or transferring that responsibility. A reference is a durable claim that keeps an object alive. A borrow is permission to use an object briefly while some other already-valid reference guarantees its lifetime. The distinction matters because copying a pointer does not automatically create a safe lasting claim.

Plain meaning:

If you hand someone a library book for the afternoon, you need to know whether they now own it, merely have permission to read it while you wait nearby, or are responsible for returning it. Kernel pointers need the same clarity, with more dangerous consequences.

In this scenario:

The process descriptor table and the pending I/O request both need the file object. Each must hold its own durable reference. A completion handler can borrow the I/O request only while the driver queue's reference still keeps it alive.

Technical name:

This design is resource ownership. A common implementation uses a reference count, which runs a destructor when the last valid reference is released. Reference counting is useful, but it is not a substitute for synchronization or an object-lifetime policy.

From Frames to Kernel Objects

The page allocator from lesson 004 returns whole frames. A kernel heap uses frames to satisfy smaller allocations, often by maintaining size classes, free lists, slabs, or a simple allocator for a teaching kernel. The mechanism chosen matters for fragmentation and speed, but every allocator returns the same dangerous-looking thing: a pointer whose future is not encoded in its type.

Consider three objects in a small kernel:

Object Allocated for Initial owner Resources it may own
process one executing program process table / creator page table, kernel stack, descriptor table
file one open-file description descriptor installation path file-system node reference, offset, flags
io_request one in-flight write request creator, then driver queue copied data buffer, file reference, completion state

These are not interchangeable. A file descriptor is usually a small per-process table entry, not the file object itself. Multiple descriptors or processes can refer to one open-file object. An I/O request is not the process; it can remain live after the process leaves. A physical frame backing one heap allocation is not the same as the object stored inside that frame.

The kernel needs an ownership graph, not just a list of allocations:

process P
  owns descriptor slot 3  ----> file F (reference)
  starts write request R  ----> file F (reference)
                              -> copied buffer B (owned)
driver queue ----------------> request R (reference)

Arrows must have labels. “Pointer to” is not enough. A pointer may be an owning reference, a borrowed access under a lock, an entry in a lookup table, or a weak observation that must be revalidated. When a diagram does not label the arrow, cleanup rules are usually already ambiguous.

The Naive Design Breaks at Exit

Imagine this first design:

open() allocates file F
write() allocates request R and buffer B
close() frees F
process_exit() frees every object it can find
driver completion uses R -> F and B

It has two obvious failure paths.

If close() frees F while R still points to it, the completion handler has a use-after-free. The freed memory may still look correct in a test, then later be reused for a different kernel object. The resulting failure can appear far from close().

If process_exit() declines to free anything that might be in use, then F, R, or B can remain allocated after the last user disappears. Repeating that workload eventually exhausts the heap or physical frames underneath it.

The better design does not try to guess which function is “close enough” to free an object. It records claims and releases them when the claim-ending event occurs.

Worked Design: A File Object Outlives a Closing Process

This trace uses a small asynchronous-write design. The exact driver and filesystem will arrive in later lessons; the point here is the ownership contract that makes either implementation safe.

Input: calc owns descriptor slot 3, which references open-file object F. It calls write(3, user_buffer, 4096). The kernel copies the bytes into kernel buffer B and creates request R for the driver.

At the start, the relevant ledger is:

Object State Claims / owner Release event
F open descriptor slot 3: 1 close(3) or process teardown
B allocated request R: owns it request completion or cancellation
R preparing syscall path: 1 handoff or error cleanup

1. Create objects in a failure-safe order

The syscall path first validates the user request. It allocates B, copies input into it, allocates R, and initializes R with an explicit owner for B. Before placing R in a driver queue, it takes an additional reference to F for the request.

Then it transfers the initial R claim to the driver queue. Transfer is not “increment and hope.” The sender stops being responsible precisely when the queue has accepted the request. If queue insertion fails, the syscall path still owns R and unwinds its partially constructed state.

success path:
  descriptor holds F
  request takes F
  request owns B
  driver queue owns R

error before queue handoff:
  syscall path releases R
  R destructor releases B and its F claim
  descriptor still holds F

This order keeps every object reachable by a named owner. It also gives allocation failure a normal path instead of a special pile of ad hoc free calls.

2. Closing the descriptor releases only its claim

Now calc exits while the driver still owns R. Process teardown removes descriptor slot 3 from calc's table and drops the descriptor's reference to F.

The open-file object remains alive because R holds another reference. Its reference count changes from two to one:

F references:
  descriptor slot 3   -> released at process exit
  request R           -> remains until completion

The process record can be destroyed after it releases its own resources, but its exit does not retroactively erase the driver's claim. This is the central design win: exit becomes a local release event, not a command to free every related object.

3. Completion performs the final release in a known order

The device completes the request. The driver obtains R through the queue's existing ownership claim, records success or failure, and wakes or records any interested result state. It then releases its reference to R.

R now has no remaining owners, so its destructor runs once. The destructor:

  1. makes R unreachable from the driver queue and any completion lookup;
  2. releases the owned buffer B back to the heap;
  3. drops R's reference to file F;
  4. releases the storage for R itself.

Dropping F is the final file reference in this trace. Its own destructor closes lower-level state, releases the file-system-node reference, and returns F's heap storage. Each object frees the resources it owns; it does not free its parent process or unrelated global state.

Output: calc may be gone, but the request completes without a stale pointer. After completion, B, R, and F are all released exactly once. The heap can reuse their storage safely because no published or durable claim still reaches them.

Naive failure contrast: Freeing F at close() makes R -> F invalid. Forgetting the request's reference makes the reference count reach zero too early. Forgetting the final drop keeps the object alive forever. The design is correct only when the ledger accounts for each claim and each release event.

Check: The driver wants to queue R for later, but it only has a borrowed pointer that is valid while the syscall path holds a lock. What must happen before the syscall path releases that lock and returns?

Think first, then reveal.

Answer: The driver queue must receive an owning reference or another lifetime guarantee before the borrow ends. A queued asynchronous user cannot rely on a pointer whose validity ended with the syscall path's lock scope.

Reference Counts Need a Safe Acquisition Rule

Reference counting answers “when is the last release?” It does not automatically answer “may I safely take a new reference right now?” That second question is where many use-after-free races begin.

Suppose a thread looks up file F in a global table, obtains its address, and then increments F's count. Another thread may remove F from the table and drop the last count between those two actions. The first thread would increment memory that is already freed or reused.

The safe rule is:

Already hold a valid reference?  -> you may create another claim according to the object's rules.
Looking up a raw pointer?        -> serialize lookup and reference acquisition against last release.

In practice, a lock, an RCU-style lifetime scheme, or an atomic “get unless zero” operation combined with the correct lookup synchronization can provide that serialization. The mechanism varies, but the boundary does not: an object must remain valid from the moment it is found until the new durable reference is established.

Likewise, a reference count does not make the object's contents race-free. Two holders may safely keep F alive while still racing to update its offset or state. Lifetime protection and data synchronization are different invariants. Lesson 009 will deal with synchronization inside the kernel; do not use a count as a substitute for the lock or policy that protects mutable fields.

Cleanup Is an Interface, Not an Afterthought

A robust destructor should have a small, documented job. It should run exactly once on the last valid claim, remove the object from places that could admit new users, free children it owns, release references it holds, and return its storage. It should not assume that callers remembered every child manually.

This makes error paths mirror success paths. If open() allocates a file object, obtains a backing-node reference, and then fails to install the descriptor, the file destructor can drop the backing-node reference and free the object. If write() allocates a buffer but fails before the request is queued, request cleanup can release the buffer. One ownership route reduces the chance that a rare error path leaks a frame.

The trade-off is explicit bookkeeping. Reference counters, destructors, locks around lookup, and ownership diagrams cost code and review effort. A simple single-owner object may be easier to free directly. But when an object crosses process, filesystem, and driver boundaries, that bookkeeping avoids much larger costs: memory leaks, double free, and use-after-free bugs that can corrupt privileged kernel state.

This approach has limits. Cycles of strong references never reach zero, so a reference count alone cannot collect a cycle. Hardware may still access a DMA buffer after software drops a naive reference; that requires a device-completion ownership rule. And a destructor that sleeps or acquires the wrong lock can create deadlocks. The ownership design must name those boundaries instead of treating put() as magic.

Common Confusions

Confusion: Every pointer is an owner

Why it is tempting:

Copying a pointer looks like creating another user.

Better model:

A pointer can be a short borrow. Only a documented durable reference or transferred ownership keeps an object alive after the current protecting context ends.

Confusion: A reference count protects all fields in the object

Why it is tempting:

The count is often atomic and appears next to the object's data.

Better model:

The count protects lifetime. Separate locking or synchronization protects mutable contents such as a file offset, queue link, or completion state.

Confusion: The creator should free every object at process exit

Why it is tempting:

The creator seems like the natural owner of all related work.

Better model:

Ownership can be transferred. Process exit drops the process's claims; in-flight requests, queues, and devices release their own claims when their work ends.

Practice: Repair the Lifecycle Table

A process P holds descriptor 4 for file object F. It starts request R, which owns buffer B. Review this proposed teardown plan:

Event Proposed action
P exits free F, R, and B immediately
driver completes R read R->buffer, update F, free R

Write a four-step correction. A strong answer:

  1. gives R an owning reference to F and ownership of B before it is queued;
  2. lets process exit remove descriptor 4 and drop only that descriptor's claim to F;
  3. keeps R and B alive under the driver queue's ownership until completion or cancellation;
  4. has final request cleanup release B, its reference to F, and then R exactly once.

Check: A file object's reference count becomes zero, but the object is still present in a global lookup table. Is it safe to free it immediately?

Think first, then reveal.

Answer: Not yet. The final release path must first remove or prevent unsafe lookup access under the table's synchronization rules. Otherwise another path can find the stale pointer and attempt to acquire a reference after the object has been freed.

Resources

Key Takeaways

PREVIOUS Physical Memory, Page Tables, and Allocation NEXT Processes, Threads, and Context Switching