The Kernel's Adaptive Read-Ahead: Why Sequential Reads Complete Before You Ask for the Bytes

2026-08-23

When you read() the first byte of a file, the kernel doesn't fetch just the page you asked for. It also asynchronously issues I/O for pages you haven't asked for yet. That's read-ahead, and it's why your cat huge.log is bottlenecked on disk bandwidth, not on the round-trip latency of every 4KB request.

The mechanism lives in mm/readahead.c and hangs off every file struct as a file_ra_state. It tracks three things: the current read-ahead window size, the position where the window starts, and an "async marker" page inside the window. When your read touches the async marker, the kernel launches the next window before returning your data — so by the time you're done processing this batch, the next batch is already in flight.

The window grows adaptively. First sequential access: 16KB. Confirmed sequential (you hit the marker): doubles to 32KB, 64KB, up to /sys/block/*/queue/read_ahead_kb (usually 128KB). Hit a non-sequential access and the window collapses back to nothing. This is why a purely random-access workload gets zero benefit from read-ahead, while a strictly sequential one gets asymptotic single-syscall throughput.

Rule of thumb: effective throughput ≈ window_size / (seek_latency + window_size/bandwidth). For a spinning disk with 8ms seek and 200MB/s bandwidth, a 4KB window gives ~500KB/s. A 128KB window gives ~15MB/s. Same disk, 30× the throughput, purely from prefetching.

Real-world example: PostgreSQL's sequential scan on a 100GB table on an NVMe drive. Without read-ahead, each 8KB block read costs ~10μs of queueing per request: ~10 seconds of pure latency stacked. With read-ahead pumping 128KB windows, the kernel keeps 32 pages in flight at once, saturating the drive's queue depth. Same query drops from ~30s to ~4s — and PostgreSQL never issued a single explicit prefetch.

You can steer this: posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL) doubles the window ceiling. POSIX_FADV_RANDOM disables read-ahead entirely — the right call for a database index scan where prefetching next-page-in-file is pure waste. readahead(fd, offset, len) forces population synchronously.

The pathology to watch: interleaved sequential streams. Two threads reading two files sequentially on the same fd (or heavily seeking within one file) can look non-sequential to the heuristic and collapse both windows. The fix is per-fd file handles or explicit POSIX_FADV_SEQUENTIAL.

Key Takeaway: Read-ahead turns your synchronous read() loop into a pipelined stream by predicting sequential access and issuing the next window's I/O before you ask — but only if the access pattern stays predictable enough for the heuristic to keep the window open.

All newsletters