Kernel Heap, Object Lifetimes, and Resource Ownership
LESSON
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:
- Who creates it and owns the initial claim?
- Who may retain it after the creating operation returns?
- What event releases each claim?
- 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:
- makes
Runreachable from the driver queue and any completion lookup; - releases the owned buffer
Bback to the heap; - drops
R's reference to fileF; - releases the storage for
Ritself.
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:
- gives
Ran owning reference toFand ownership ofBbefore it is queued; - lets process exit remove descriptor
4and drop only that descriptor's claim toF; - keeps
RandBalive under the driver queue's ownership until completion or cancellation; - has final request cleanup release
B, its reference toF, and thenRexactly 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
- [REFERENCE] Linux kernel kref documentation — Focus: durable references, the last-release callback, and safe lookup-plus-acquisition rules.
- [BOOK] xv6 RISC-V textbook — Focus: file tables, reference counts, and process cleanup in a small kernel.
- [COURSE] MIT 6.1810 mmap lab — Focus: why mapping a file requires retaining a file reference after the descriptor may close.
Key Takeaways
- Heap allocation provides storage; ownership rules make that storage safe across subsystem boundaries.
- A lasting user needs an owning reference or an explicit ownership transfer. A borrowed pointer is valid only within its protecting context.
- Final release must unpublish the object, release its children and references, and return storage exactly once.
- Reference counting protects lifetime, not concurrent mutation or every acquisition race.
- Exit and error paths are ordinary release events. Designing them from the same ownership ledger prevents leaks and stale-pointer failures.