The Page Cache and Writeback

Here is a fact that quietly runs your whole machine: free RAM is wasted RAM, so Linux spends nearly all of it caching file data. Every byte you read from or write to a file passes through the page cache — a pool of RAM pages holding recently-touched file contents. The first read of a file pays the full block-layer price (~10 ms on a disk); every read after that is a cache hit at DRAM speed (~100 ns). That is the hundred-thousand-fold gap the entire storage module has been circling, harnessed as the OS's single biggest performance lever.

The page cache is where the OS decides two hard questions. On reads: should I fetch more than you asked for, betting you'll want it? (read-ahead). On writes: can I lie that your data is saved, keep it in RAM, and flush it to disk later? (writeback). Both bets usually win big — and both have a sharp edge (data loss on crash, cache pollution) that this lesson makes precise.

Clean and dirty: the two states of a cached page

A cached page is in one of two states. A clean page's contents are identical to what is on disk — it can be dropped and reclaimed instantly, because the disk still has a good copy. A dirty page has been written by a program but not yet persisted — RAM holds the only up-to-date copy, so it must be written back to disk before its memory can be reused.

This clean/dirty split is the join between two subsystems you already know. Reclaiming clean pages is free, so the page cache interlocks with page replacement: under memory pressure the kernel prefers to evict clean cache pages first (no I/O), and only writes back and drops dirty ones when it must. Cache management and virtual-memory eviction are, in Linux, literally the same LRU lists.

Read-ahead: guessing the future

When you read the first few blocks of a file in order, the kernel notices the pattern and reads ahead — prefetching the next chunk before you ask, so by the time your next \texttt{read()} arrives the data is already in RAM. For sequential access this is transformative: instead of stalling on every block, you stall once and then stream from cache. If the access pattern turns out to be random, the kernel notices its predictions missing and shrinks the read-ahead window, so it doesn't waste bandwidth fetching pages nobody wants.

The model below streams through a file with a read-ahead window. Sequential access turns almost every request into a cache hit; the one-time misses are the prefetches themselves.

// Read-ahead hit rate on a sequential scan. // The cache prefetches `window` pages ahead whenever it services a miss. const FILE_PAGES = 40; const window = 8; // read-ahead window (pages fetched per prefetch) const cached = new Set<number>(); let hits = 0, misses = 0, prefetched = 0; for (let p = 0; p < FILE_PAGES; p++) { if (cached.has(p)) { hits++; } else { misses++; // Miss triggers a prefetch of the next `window` pages (including p). for (let k = p; k < Math.min(p + window, FILE_PAGES); k++) { if (!cached.has(k)) { cached.add(k); prefetched++; } } } } const rate = (100 * hits / (hits + misses)).toFixed(1); console.log(`pages read: ${FILE_PAGES}`); console.log(`cache hits: ${hits}`); console.log(`cache misses: ${misses} (each triggered a read-ahead)`); console.log(`hit rate: ${rate}%`); console.log(`\nWith window ${window}, only ~1 in ${window} accesses actually waits on I/O.`);

Writeback: the delicious, dangerous lie

When a program writes, the kernel copies the data into a page, marks it dirty, and returns success immediately — the data is still only in RAM. This write-back caching (as opposed to write-through, which would wait for the disk) is what makes writes feel instant and lets the kernel batch, merge, and reorder many small writes into efficient large ones. A family of kernel flusher threads later walks the dirty pages and writes them to disk, governed by tunables:

It sounds perverse — turn off the free speed-up? But a database like PostgreSQL or Oracle already keeps its own finely-tuned buffer pool in user space, with knowledge the kernel lacks: which pages are hot, which must hit disk before a commit, exactly when to flush for durability. If the kernel also caches every block, you get double buffering — the same data cached twice, wasting RAM — and the kernel's generic read-ahead and writeback heuristics fight the database's careful ones. O_DIRECT lets the application say "move these bytes straight between my buffer and the device by DMA; do not touch the page cache." The app trades away the kernel's help for total control and predictable latency. It's a recurring theme: the smartest cache is sometimes no cache, when someone above knows better.

The most dangerous illusion the page cache sells: a \texttt{write()} that returns 0 (success) has, by default, only copied your bytes into a dirty RAM page. If the machine loses power in the next few seconds — before the flusher threads run — that data is simply gone, even though your program was told it succeeded. To actually guarantee persistence you must call \texttt{fsync()} (or open with O_SYNC), which blocks until the dirty pages and the file's metadata are truly on stable storage. This is why databases \texttt{fsync} the write-ahead log before acknowledging a commit, and why "did you fsync?" is the first question after every corruption bug. Fast-by-default writeback and durable-on-demand \texttt{fsync} are two different promises — never confuse them.