6.2 Demand Paging, Page Faults, and mmap
A process may own a large virtual address range without having a DRAM page ready behind every address. A page might need to be loaded from a file, or it might be allocated only on the first write. A virtual mapping defines address semantics first; the kernel supplies physical resources according to policy.
6.1 Virtual Addresses, Page Tables, and TLB explains how addresses are translated. This lesson investigates what happens when the PTE is not yet ready, and how anonymous memory, file mapping, copy-on-write, reclaim, and overcommit collectively determine whether this access can proceed.
A Page Fault Is a Controlled Synchronous Exception
During a load, store, or instruction fetch, page-table checks may find missing presence or permission. The OS then interprets the cause, for example:
- page not yet present;
- Current access violation: read/write/execute or user/supervisor permissions;
- The page is deliberately set to read-only to enable copy-on-write;
- The address does not belong to any valid mapping.
The processor pauses the current instruction, saves sufficient architectural state, and switches into the kernel page-fault handler. The kernel locates the corresponding VMA and access type, then decides whether to fix the mapping, wait for I/O, send a signal to the process, or take another platform-defined action. If the fix succeeds, the original instruction can be resumed, and the user code typically does not see an explicit function return.
Thus, a page fault is not synonymous with a page not being in DRAM, nor is it always a sign of a program error; it reports paging presence or protection conditions. The MMU does not interpret VMAs, files, or COW intent; the kernel does.
Minor/Major Classify Work; SIGSEGV Is an Outcome
Linux and similar systems often count successfully handled faults as:
- minor fault: The fault is handled without loading the target page from disk, such as allocating a zero-filled anonymous page, setting up a PTE for an existing page-cache page, or completing certain COW operations;
- major fault: Resolving the fault requires loading the target page, usually from backing storage, such as when the target page is not in the page cache or the anonymous page has been paged out.
When accessing an unmapped address or violating an unfixable permission, the kernel may send SIGSEGV to the process; when a mapped file is truncated and access falls into an invalid range, it's common on Unix systems to see SIGBUS. These are fault handling outcomes and should not be listed alongside minor/major faults as "three types of page faults."
minor doesn't guarantee a fixed "tens of microseconds," and major doesn't guarantee even millisecond-level performance on mechanical disks. Page cache, SSDs, memory pressure, scheduling, and the file system all affect the actual cost.
Anonymous Pages Are Usually Backed on First Touch
An anonymous mapping can preemptively occupy a virtual range and only allocate and zero out a physical page upon the first write to a page. Read-only first access might share the kernel's zero page until a write triggers COW.
This Linux example uses POSIX APIs and the MAP_ANONYMOUS extension to allocate 64 MiB and observe process fault counts. Save as touch_pages.c, compile with cc -std=c17 -Wall -Wextra -Werror touch_pages.c -o touch_pages, and run ./touch_pages:
#define _DEFAULT_SOURCE
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <unistd.h>
int main(void) {
const size_t length = 64U * 1024U * 1024U;
long page_size = sysconf(_SC_PAGESIZE);
if (page_size <= 0) {
fputs("cannot determine page size\n", stderr);
return 1;
}
volatile unsigned char *region = mmap(
NULL, length, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (region == MAP_FAILED) {
perror("mmap");
return 1;
}
struct rusage before;
struct rusage after;
if (getrusage(RUSAGE_SELF, &before) != 0) {
perror("getrusage");
munmap((void *)region, length);
return 1;
}
for (size_t offset = 0; offset < length; offset += (size_t)page_size) {
region[offset] = 1;
}
if (getrusage(RUSAGE_SELF, &after) != 0) {
perror("getrusage");
munmap((void *)region, length);
return 1;
}
printf("minor delta=%ld major delta=%ld\n",
after.ru_minflt - before.ru_minflt,
after.ru_majflt - before.ru_majflt);
if (munmap((void *)region, length) != 0) {
perror("munmap");
return 1;
}
return 0;
}Don't expect the fault count to be strictly equal to length/page_size. Transparent Huge Pages, kernel population policies, other faults in the process, and kernel accounting can alter the result. Ordinary CPU cache prefetching is not anonymous-page allocation on behalf of a user access. Experiments should simultaneously record THP configuration, page size, and system load.
The initial touch behavior of ordinary malloc can also be influenced by allocator reuse. It might return an arena region that is already backed, so when studying paging, using a clearly defined mmap makes it easier to control variables.
Copy-on-Write Defers Copying Until a Write
After fork(), child and parent processes have independent virtual address spaces semantically but can temporarily share the same physical pages. For writable private mappings, the kernel marks relevant PTEs read-only for COW. MAP_SHARED retains shared semantics; inherited mappings are not all COW:
Parent virtual page --+
+--> shared physical page (read-only PTEs)
Child virtual page ----+A protection fault is triggered when one side attempts to write. If there are still sharers, the kernel allocates a new page, copies the old content, updates the write-side PTE to point to the new page, and restores writable permissions; the other side continues to see the original content.
COW saves the "unwritten copies". If a child process quickly overwrites the entire large heap, copy costs will still occur and could lead to sudden memory pressure. Multithreaded processes fork() must still adhere to async-signal-safe and other runtime rules, and cannot rely solely on page count to determine safety.
Reclaim Goes Beyond a Simple FIFO Queue
When physical memory is tight, the kernel can reclaim clean file-backed pages because they can be restored from the file when needed; dirty file pages must first enter the writeback queue; anonymous pages that need to retain their content typically require swap or another form of backing storage.
Textbooks often use three algorithms to build intuition:
FIFO
FIFO discards based on entry time, ignoring recent access; it may exhibit Belady anomaly: increasing frames can actually cause more page faults. Classic reference string:
1 2 3 4 1 2 5 1 2 3 4 5Under FIFO, 3 frames have 9 faults, while 4 frames have 10.
LRU
Evict the page that has been longest unused. LRU has the stack property, so adding frames does not cause Belady anomaly; yet it is not theoretically optimal. When the future reference sequence is known, evicting the page that will be used the latest in the future (OPT/MIN) results in the fewest page faults, though online systems cannot know the future.
Maintaining an exact global LRU order for every memory access is too expensive; the OS uses accessed/reference bits, sampling, and generational approximations of working-set hotness.
Clock / second chance
Scan frames in a ring: clear a set reference bit and skip that frame; select a frame whose bit is clear. It gives recently used pages a second chance, and is a classic model for understanding approximate LRU.
Linux reclaim evolves with kernel versions, potentially employing active/inactive lists, generation, working-set detection, and different anonymous/file policies. It cannot be summarized as "Linux is just Clock." Container cgroup, NUMA node, and memory pressure also constrain the candidate set.
A Runnable FIFO/LRU/Clock Comparison
from collections import OrderedDict, deque
def check_capacity(capacity):
if not isinstance(capacity, int) or isinstance(capacity, bool):
raise TypeError("capacity must be an integer")
if capacity <= 0:
raise ValueError("capacity must be positive")
def fifo_faults(references, capacity):
check_capacity(capacity)
resident = set()
order = deque()
faults = 0
for page in references:
if page in resident:
continue
faults += 1
if len(resident) == capacity:
resident.remove(order.popleft())
resident.add(page)
order.append(page)
return faults
def lru_faults(references, capacity):
check_capacity(capacity)
resident = OrderedDict()
faults = 0
for page in references:
if page in resident:
resident.move_to_end(page)
continue
faults += 1
if len(resident) == capacity:
resident.popitem(last=False)
resident[page] = None
return faults
def clock_faults(references, capacity):
check_capacity(capacity)
empty = object()
frames = [empty] * capacity
referenced = [False] * capacity
positions = {}
hand = 0
faults = 0
for page in references:
if page in positions:
referenced[positions[page]] = True
continue
faults += 1
while frames[hand] is not empty and referenced[hand]:
referenced[hand] = False
hand = (hand + 1) % capacity
victim = frames[hand]
if victim is not empty:
del positions[victim]
frames[hand] = page
referenced[hand] = True
positions[page] = hand
hand = (hand + 1) % capacity
return faults
trace = [1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5]
assert fifo_faults(trace, 3) == 9
assert fifo_faults(trace, 4) == 10
assert lru_faults(trace, 3) == 10
assert lru_faults(trace, 4) == 8
assert clock_faults(trace, 3) == 9
assert clock_faults(trace, 4) == 10The model rejects nonpositive or noninteger capacity. Page identifiers must be hashable; None is valid because Clock uses a separate empty-slot sentinel. It counts faults only; log each resident set when diagnosing policy differences.
mmap Maps File Pages into Virtual Address Space
A virtual page can correspond to a file offset. The program accesses it through an address, and on first touch the fault handler establishes the required mapping. This changes the access interface and loading time; it does not make storage I/O disappear.
File-backed mapping makes virtual pages correspond to file offsets. When a page not in the page cache is first accessed, the fault handler reads the file data and establishes a PTE; if a page is already in the page cache, a minor fault might be all that's needed.
Writes in MAP_PRIVATE use COW and do not update the underlying file; modifications in MAP_SHARED enter a shared file page and can be written back to the file according to API rules. Neither automatically resolves synchronization of data structures across multiple processes.
Prepare a nonempty regular file named mapped.bin in a new practice directory. This POSIX program maps and changes only its first byte, avoiding a narrowing conversion of the full file length to size_t. Save as map_file.c, compile with cc -std=c17 -Wall -Wextra -Werror map_file.c -o map_file, and run it:
#include <fcntl.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
int main(void) {
int result = 1;
int fd = open("mapped.bin", O_RDWR);
if (fd < 0) {
perror("open");
return 1;
}
struct stat metadata;
if (fstat(fd, &metadata) != 0) {
perror("fstat");
goto close_file;
}
if (!S_ISREG(metadata.st_mode) || metadata.st_size <= 0) {
fputs("mapped.bin must be a non-empty regular file\n", stderr);
goto close_file;
}
const size_t length = 1;
unsigned char *mapping = mmap(NULL, length,
PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (mapping == MAP_FAILED) {
perror("mmap");
goto close_file;
}
printf("first byte before: %u\n", (unsigned int)mapping[0]);
mapping[0] = (unsigned char)'A';
if (msync(mapping, length, MS_SYNC) != 0) {
perror("msync");
goto unmap;
}
result = 0;
unmap:
if (munmap(mapping, length) != 0) {
perror("munmap");
result = 1;
}
close_file:
if (close(fd) != 0) {
perror("close");
result = 1;
}
return result;
}A successful mmap does not mean every mapped byte has file data behind it. On Linux, the unused tail of a file’s last partial page is normally zero-filled; writes there do not extend the file. Accessing whole pages beyond EOF normally raises SIGBUS. Concurrent truncation may also invalidate previously usable pages, so file-size changes require a protocol.
msync(MS_SYNC) synchronizes changes to the backing file and waits for completion. Crash durability also follows filesystem and device guarantees: use msync/fsync as the target protocol requires, with directory synchronization where file creation or renaming requires it. Atomic recovery across multiple updates needs a transaction protocol. munmap alone does not provide these guarantees.
mmap Is Not an Automatic Zero-Copy Speedup
For file reads, mmap can directly map a page-cache page into the process, skipping read()'s step of copying the page cache to user buffer. However, if the CPU later copies the data to another location, the bytes are still copied; page faults, TLB, and VMA management also incur costs.
In sequential streaming I/O, read using a fixed buffer might make it easier to control readahead and errors, and performance could also be excellent. For random access to large files, mmap's address-based interface is convenient. Sending files over a socket can be evaluated using sendfile, and file-to-file operations can be evaluated using copy_file_range; each API has filesystem and platform limitations.
Don't preset a winner. When comparing, record cold/warm cache, fault count, system calls, CPU time, total throughput, and error handling semantics.
The fact that "changes to a shared mapping are visible to another process" does not guarantee lock-free concurrent safety. Process-shared mutexes or suitable cross-process atomic protocols provide concurrency ordering. Persistence order and crash recovery require their own protocol; a lock or atomic field does not turn a file into a recoverable transaction.
Overcommit Can Delay Allocation Failure
Linux memory overcommit controls virtual memory commitment accounting. Large malloc/mmap may succeed initially, with actual page access later consuming more physical memory and swap; this makes sparse spaces, COW fork, and similar modes feasible, but can also delay resource shortages until runtime.
Common /proc/sys/vm/overcommit_memory modes are:
| value | concept |
|---|---|
| 0 | heuristic overcommit |
| 1 | loose commitment check for always overcommit |
| 2 | Do stricter accounting based on commit limit |
Mode 2's limit is related to swap, overcommit_ratio, or overcommit_kbytes, among other configurations, but the exact rules should be checked in the running kernel documentation. There is no universal answer that "all critical systems should be set to 2": database systems, swap-less container nodes, desktop, and batch processing have different requirements regarding early failure, throughput, and OOM behavior.
Under memory pressure, the kernel may reclaim pages, write back data, swap out anonymous pages, fail allocations or page faults, or trigger the OOM killer. Linux's victim selection considers memory cgroup, oom_score_adj, and usage factors, and is not equivalent to simply killing the process with the largest memory leak. Protecting one process shifts risk to other workloads and must be configured at the system level.
Observe Commitment and Residency
- Run the anonymous mapping example, record page size, THP configuration, and minor/major delta; explain why the result doesn't have to equal the number of pages.
- Test invalid capacity, empty traces, and
Nonepage identifiers for all three models, and output frame state at each step. - Create an
MAP_PRIVATEfile mapping, write to it, then verify the original file remains unchanged. - Explain why accessing an old mapping after the file is truncated might not be a typical
SIGSEGV. - Compare
read,mmap, andcopy_file_rangeby copying the same file and conducting cold-cache and warm-cache experiments. - Explain how host OOM, memory cgroup limits, and a process's own virtual address space limit can lead to different failure scenarios within a container.
Fault Handling Crosses into the Kernel
A page fault is a type of processor exception. The next chapter enters Exceptions and System Calls, distinguishing between trap, fault, interrupt, and syscall, and traces how hardware switches privilege levels and how the kernel safely returns to user mode.