Page Cache

#internals#mm#page_cache#filemap#xarray#readahead#writeback

Yes, everyone knows what the page cache is. In short, it stores the contents of files from disk in RAM and associates those cached pages with the file’s inode(More on this in VFS section). This way, the kernel doesn’t have to perform disk I/O every time a process reads from or writes to a file, which would be much slower.

While learning about the page cache, I traced the open/read/write path of a hello.txt file deep into the kernel source code to understand how the page cache actually works. Let me show you the call chain along with an explanation of what happens at each step.

hello.txt itself is just prepared ahead of time, outside the VM — this script is only here to document exactly what bytes end up in the file, it’s not part of the trace:

# produces hello.txt: 3 pages, one word repeated per page — content only, not traced
a = open("./hello.txt", 'w')
a.write("onepagee" * 512)   # exactly one page
a.write("twopagee" * 512)   # another  page
a.write("threpage" * 512)   # another  page
a.close()

The actual trace starts here, inside the VM, on a kernel that has never touched this file before:

int main(void) {
    int fd;
    char buf[0x400];               // 1024 bytes, on the stack

    fd = open("/hello.txt", O_RDWR);
    read(fd, buf, sizeof(buf));    // read #1: talks to disk, populates the cache
    read(fd, buf, sizeof(buf));    // read #2: served straight from the cache
    write(fd, "AAAAA", 5);         // modifies a cached page -> dirty page
}

hello.txt ends up exactly 12288 bytes = 3 pages, no partial page at the end — page 0 is "onepagee" repeated, page 1 is "twopagee" repeated, page 2 is "threpage" repeated. That’s deliberate: it makes every folio’s content and index unambiguous when you’re staring at a hexdump.

Three questions this note answers by tracing exactly this program:

  1. read #1 runs against a fresh open(), on a kernel that has never touched this file before — how does it actually reach out to disk, and what gets stored back into the cache as a result.
  2. read #2 reads the next 1024 bytes — why doesn’t it repeat any of that disk interaction
  3. write() modifies bytes that are already cached — what does “dirty page” actually mean at the struct/flag level, and when does that change reach disk?

1. The two structs involved

Every file has one address_space — struct address_space (include/linux/fs.h:472):

struct address_space {
	struct inode		*host;
	struct xarray		i_pages;  // the page cache storage
	struct rw_semaphore	invalidate_lock;
	gfp_t			gfp_mask;
	atomic_t		i_mmap_writable;
	struct rb_root_cached	i_mmap;
	unsigned long		nrpages;
	pgoff_t			writeback_index;
	const struct address_space_operations *a_ops;
	unsigned long		flags;
	errseq_t		wb_err;
	spinlock_t		i_private_lock;
	struct rw_semaphore	i_mmap_rwsem;
} 
  • i_pages is an XArray. This stores the list of page caches
  • pgoff_t (page index within the file) → struct folio *.
  • For hello.txt, once fully cached, i_pages holds exactly three slots — index 0, 1, 2 — one per page.

The address_space itself lives inside the inode, and not the file descriptor:

struct inode {
	struct address_space	*i_mapping;   // fs.h:775 — pointer to i_data
	...
	struct address_space	i_data;       // fs.h:851 — the REAL object
};
struct file {
	struct address_space	*f_mapping;   // fs.h:1259 — also just a pointer to i_data
};

At inode creation (fs/inode.c:287), inode->i_mapping = &inode->i_data; — it points at itself. file->f_mapping is copied from inode->i_mapping on open(). This is why it doesn’t matter that read #1 and write() below happen on the exact same struct file, or that a totally different process opening hello.txt a second time would get a different struct file — every one of them resolves f_mapping back to this same, singular address_space, owned by the inode. Whatever ends up cached in i_pages is visible to anyone with the file open, immediately.

Each cached page is a struct folio (include/linux/mm_types.h:402), identified by the pair (mapping, index):

struct address_space *mapping;
pgoff_t index;

plus flags that matter throughout this trace: PG_locked (I/O in progress, don’t touch), PG_uptodate (data is valid), PG_dirty (modified, not yet on disk), PG_readahead (marks a folio as the trigger point for prefetching the next window(data from disk)).

tmpfs aside: if mapping->a_ops == shmem_aops, hello.txt has no separate disk backing at all — the page cache is the storage, and folios get populated directly rather than via disk I/O. Check p *(struct address_space *)file->f_mapping and look at a_ops if you want to know which case you’re actually debugging.


2. Question 1 - talks to disk, then fills the cache

read #1 in this example is a cold miss: the kernel has never opened this inode before, so i_pages is empty when the C program starts.

sys_read → vfs_read() → new_sync_read() → generic_file_read_iter()   mm/filemap.c:2965
  → filemap_read()   mm/filemap.c:2777
    → filemap_get_pages()   mm/filemap.c:2676

First thing filemap_get_pages() does is ask the cache directly, before considering any disk I/O:

index = iocb->ki_pos >> PAGE_SHIFT;                                   
last_index = round_up(iocb->ki_pos + count, PAGE_SIZE) >> PAGE_SHIFT; 
filemap_get_read_batch(mapping, 0, last_index - 1, fbatch);  // xas_load() at index 0 -> nothing there

Nothing is found, so the batch is empty. That’s the page-cache miss. Because the page is not cached, the kernel doesn’t immediately read only the requested 1024 bytes. page_cache_sync_ra() calculates how many pages should be fetched. For our small file, the readahead calculation initially decides on several pages, but do_page_cache_ra() limits this to the actual file size. Our hello.txt is 12 KB(3 Pages). So all 3 pages can be brought into the cache.

if (!folio_batch_count(fbatch))
    page_cache_sync_ra(&ractl, last_index - index /* = 1 */);   // mm/readahead.c:577

page_cache_ra_unbounded() eventually adds the folios to the page cache (i_pages) through:

filemap_add_folio(mapping, folio, index, gfp);

one call per folio, so three calls total for indices 0, 1, 2. Each folio is initially locked and not yet uptodate. In other words, the cache slot exists, but the actual file data hasn’t arrived from disk yet. Once all three are inserted, read_pages() submits the actual I/O for all of them at once, through mapping->a_ops->readahead().

Since the whole file fit inside this one readahead window, there’s nothing left in the file to prefetch further, so none of the three folios get the readahead marker (PG_readahead) set. That flag only matters when there’s more of the file left ahead.

Back in filemap_get_pages(), the lookup runs again. Folio 0 is there now, but it’s still locked — the I/O it just kicked off hasn’t finished yet. So the read can’t just grab the data, it has to wait. It calls folio_put_wait_locked(), which puts the process to sleep until that I/O completes. When it does, folio_end_read() marks the folio uptodate and unlocks it, waking the read back up.

Only then does the actual copy happen: copy_folio_to_iter() copies the first 1024 bytes into buf.

So for one 1024-byte read(), the kernel ended up asking the disk for the entire file, and all three folios are now sitting in i_pages, uptodate.


3. Question 2 - same folio, fetches from the cache

By the second read(), iocb->ki_pos is 1024. The lookup runs the same way it did before:

index      = 1024 >> PAGE_SHIFT;                          // still 0 — still folio 0
last_index = round_up(1024 + 1024, PAGE_SIZE) >> PAGE_SHIFT;
filemap_get_read_batch(mapping, 0, 0, fbatch);

Offsets 1024–2047 are still entirely inside folio 0. A page is 4096 bytes, so read() would need to reach past offset 4095 before this file’s second page ever gets touched. The lookup finds folio 0 already uptodate, so this is a straight hit and nothing gets called at all, no page_cache_sync_ra, no page_cache_async_ra.

That’s the point of having a second read in this example: same file, same cache, and because it lands on a page that’s already resident, there’s zero disk interaction this time. Two read() calls, two syscalls, but only one page ever actually got touched.

(If a third read had pushed past offset 4095, that’s the point where folio 1 would come into play. And if this file had been too big to fit inside one readahead window back, that’s also exactly when a PG_readahead-flagged folio would trigger the next async prefetch. Neither happens here, because the whole file already got pulled in on read #1.)


4. Question 3 - what a dirty page actually is

By the time write() runs, iocb->ki_pos is 2048 — wherever the two reads left it, still inside folio 0.

sys_write → vfs_write() → new_sync_write() → generic_file_write_iter()   mm/filemap.c:4492
  → generic_perform_write()   mm/filemap.c:4330

generic_perform_write() does the same three steps every buffered write does: find the folio, copy the bytes in, mark it dirty.

status = a_ops->write_begin(iocb, mapping, pos, bytes, &folio, &fsdata);
copied = copy_folio_from_iter_atomic(folio, offset, bytes, i);
status = a_ops->write_end(iocb, mapping, pos, bytes, copied, folio, fsdata);

write_begin asks for folio index 0 the same way write_begin always does , and this time it’s already there, uptodate, left over from read #1. So there’s no allocation and no filemap_add_folio() call. It just locks the folio that’s already cached and hands it straight back.

copy_folio_from_iter_atomic then overwrites 5 bytes of the cached "onepagee" text in place with "AAAAA". That’s the only copy of this data anywhere right now , nothing on disk has changed.

write_end is what actually makes the page dirty, through folio_mark_dirty():

xa_lock_irqsave(&mapping->i_pages, flags);
if (folio->mapping)
	__xa_set_mark(&mapping->i_pages, folio->index, PAGECACHE_TAG_DIRTY);
xa_unlock_irqrestore(&mapping->i_pages, flags);

That’s the whole “dirty page” mechanism. No new folio, no separate list of dirty pages, nothing moves anywhere. The exact same folio that was clean a moment ago just has one bit flipped in the XArray, plus the PG_dirty flag set on the folio itself. The inode also gets marked dirty, which is how writeback later finds out this file needs attention at all, before it even looks at which pages inside it changed.

At this point the modification only exists in RAM. write() already returned success. This is the reason why write syscall return so fast even for a larger file write. Two separate things eventually push it out:

  • Every time a page gets dirtied, balance_dirty_pages_ratelimited() checks whether the system has too much dirty memory outstanding. Most calls it does nothing. Once the dirty threshold is actually crossed, it can block the writer until some of that memory clears.
  • A background flusher thread also wakes up on its own every few seconds (or sooner, if the background threshold is crossed), and writes dirty pages out without anyone asking:
wb_workfn() → wb_writeback() → writeback_sb_inodes() → do_writepages()
  → mapping->a_ops->writepages()

Once that runs, the folio gets submitted for I/O and the dirty tag gets cleared right away . the page counts as clean the moment it’s queued, not once the bytes physically land on disk.

(If fd had been O_RDONLY instead, none of this would happen at all. vfs_write() checks file->f_mode before doing anything else, and rejects the call immediately with -EBADF — before it ever reaches the page cache. That’s a permission check on the file descriptor, nothing to do with the cache itself.)


5. What else can happen to these pages

Under memory pressure, (these folios sit on the same LRU list) , the kernel scans for anything reclaimable. A folio that hasn’t been touched recently, and isn’t dirty, can just get dropped. When that happens, the kernel usually leaves a small marker behind instead of clearing the slot completely, so if the same page gets asked for again soon after, it can tell “this just got evicted and immediately wanted back” and react to it (can optimise the LRU subsystem) rather than treating it like an ordinary cold miss.


The whole thing comes down to one idea: i_pages on the address_space is the only place any of this gets tracked, and it’s shared by every struct file pointing at the same inode. open() never touches it. Reads, writes, and readahead only ever agree with each other by locking a folio and touching that same slot.