Skip to content

6.2 Demand Paging, Page Faults, and mmap

The virtual memory hall gives each process a broad and almost extravagant map of addresses. When you push open a door at a certain address, there's no guarantee a page of DRAM is already behind it, it might have to be fetched from a file, or it might only be allocated when the first write occurs. The addresses on the map are promises, not assurances that all physical resources are already in place.

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

When the program executes a load, store, or instruction fetch, the MMU might detect:

  • 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's the unified entry point for "the current translation cannot directly satisfy the access request."

minor, major, and SIGSEGV are not on the same classification axis

Linux and similar systems often count successfully handled faults as:

  • minor fault: No need to wait for reading the target page from a block device, 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: To satisfy a fault, the system must wait for storage I/O, 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 typically committed 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.

The following Linux/POSIX example explicitly allocates an anonymous region using mmap and observes fault count changes using getrusage:

c
#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, prefetching, pre-filling, kernel accounting, and previous mapping states can all alter the result. 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 delays replication until writing occurs

After fork(), child and parent processes have independent virtual address spaces semantically but can temporarily share the same physical pages. The kernel marks the relevant mappings as read-only and records COW:

text
Parent virtual page --+
                   +--> shared physical page (read-only PTEs)
virtual page --+

A protection fault is triggered when one side attempts to write. If there are still shared readers, 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:

text
1 2 3 4 1 2 5 1 2 3 4 5

Under 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

Ring scan frame: zero the reference bit when it is 1 and skip, otherwise select the victim. It gives recently used pages a second chance, and is a classic model for understanding approximate LRU.

True Linux reclaim has evolved with kernel versions, potentially employing active/inactive lists, generation, working-set detection, and different anonymous/file policies, 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

python
from collections import OrderedDict, deque


def fifo_faults(references, 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):
    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):
    frames = [None] * 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 None and referenced[hand]:
            referenced[hand] = False
            hand = (hand + 1) % capacity

        victim = frames[hand]
        if victim is not None:
            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) == 10

The model requires capacity > 0; production-grade simulators should explicitly reject zero or negative capacity and log the resident set at each step, rather than the total alone.

mmap Connect the archive page to an address map

A door in the virtual memory hall can also directly correspond to a page of documents in the archive. The program accesses it through an address; on the first touch, a fault handler brings the corresponding page into memory, this changes the interface and loading timing, yet doesn't make storage I/O vanish.

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.

The following POSIX example modifies only the first byte of a non-empty file and carefully handles length and errors:

c
#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 (metadata.st_size <= 0) {
        fputs("mapped.bin must be non-empty\n", stderr);
        goto close_file;
    }

    size_t length = (size_t)metadata.st_size;
    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;
}

The mapping length cannot safely exceed the accessible file range; if the file is truncated by another process and then accessed, you'll receive SIGBUS. Concurrent changes to file size require additional protocols.

Request to sync modifications to the file mapping's backing object, but "power loss will inevitably result in stable media" still depends on the file system, device cache, and platform guarantees; when durability is required, the msync call with the MS_SYNC flag (msync(MS_SYNC)), fsync, directory synchronization, or transaction protocols should be used, rather than relying solely on munmap.

mmap isn't a magic "zero-copy" speed button

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/heat 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. Only shared mutexes, process-compatible atomic objects, or well-defined file format protocols are responsible for ensuring update ordering and crash recovery.

overcommit delays the failure point

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.

/proc/sys/vm/overcommit_memory A common pattern is:

valueconcept
0heuristic overcommit
1loose commitment check for always overcommit
2Do 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 processes, 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.

Hands-on Observe Commitment and Presence

  1. 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.
  2. Add capacity <= 0 checks and output frame state at each step for FIFO, LRU, and Clock models.
  3. Create an MAP_PRIVATE file mapping, write to it, then verify the original file remains unchanged.
  4. Explaining why accessing an old mapping after the file is truncate might not be a typical SIGSEGV.
  5. Compare read, mmap, and copy_file_range by copying the same file and conducting cold-cache and warm-cache experiments.
  6. 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.

A fault will eventually cross the boundary between user space and kernel space

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.

Built with VitePress | Software Systems Atlas