Linux I/O Stack and the Page Cache¶
What happens when you call read()¶
- The system call switches to kernel mode.
- The VFS (virtual file system) finds the file's in-memory inode from the file descriptor.
- The kernel checks the page cache — an in-memory cache of file contents, indexed by (inode, page offset). On a hit, it copies the page to your buffer and returns. No disk I/O at all.
- On a miss, the file system translates the file offset to a disk block, the block layer queues a request, the driver submits it, the device DMAs the data into a freshly allocated page-cache page, and the process sleeps until the interrupt signals completion.
- The page is copied into your buffer.
The key consequence: with ordinary ("buffered") I/O, every byte of file data passes through the page cache. Reads may never reach the disk; writes almost never reach it immediately.
The page cache uses all your free memory¶
The kernel uses otherwise unused RAM as page cache, and evicts it when
applications need memory. This is why free -h on a database server
shows little "free" memory but plenty "available":
total used free shared buff/cache available
Mem: 62Gi 12Gi 1.1Gi 0.2Gi 49Gi 49Gi
Here 49 GB of file data is cached. That memory is reclaimable, so it counts toward "available." Many databases (PostgreSQL in particular) deliberately rely on the page cache as a second layer of caching on top of their own buffer pool.
Writes: dirty pages and writeback¶
n, err := f.Write(data) // returns once the data is in the page cache
A buffered write() copies data into page-cache pages, marks them
dirty, and returns — usually in microseconds. The data is not on
disk. Kernel flusher threads write dirty pages back later, triggered
by:
| Trigger | Default (typical) | Meaning |
|---|---|---|
| Age | vm.dirty_expire_centisecs = 3000 |
Dirty pages older than 30 s get written back |
| Background threshold | vm.dirty_background_ratio = 10 |
Start background writeback when dirty pages exceed 10% of available memory |
| Hard threshold | vm.dirty_ratio = 20 |
Above 20%, processes calling write() are throttled and do writeback themselves |
Two consequences matter for storage engines:
- After a crash or power loss, up to ~30 seconds of "successful"
writes can vanish. Only
fsync/fdatasyncmake data durable — see Durability and fsync. - A burst of writes that pushes dirty memory past
dirty_ratiomakeswrite()itself suddenly block for a long time — a classic source of unexplained latency spikes in write-heavy services. Engines that write large files (compaction output) often callfdatasyncorsync_file_rangeperiodically while writing, to keep dirty pages bounded (RocksDB'sbytes_per_syncoption does exactly this).
Readahead¶
When the kernel sees sequential reads on a file, it reads ahead — fetching the next pages before they are requested. This makes sequential scans fast but wastes I/O and cache space for random access. Applications can hint their pattern:
import "golang.org/x/sys/unix" // Linux
fd := int(f.Fd())
unix.Fadvise(fd, 0, 0, unix.FADV_RANDOM) // disable readahead (point lookups)
unix.Fadvise(fd, 0, 0, unix.FADV_SEQUENTIAL) // aggressive readahead (compaction input)
unix.Fadvise(fd, off, n, unix.FADV_DONTNEED) // drop these pages from the cache
POSIX_FADV_DONTNEED after reading compaction inputs prevents a
one-time scan of gigabytes of old SSTables from evicting the hot data
that foreground reads depend on.
Positional I/O: pread and pwrite¶
n, err := f.ReadAt(buf, blockOffset) // pread(2)
pread reads at an explicit offset without moving the file
position. That makes it safe for many goroutines to read the same
file concurrently — no Seek + Read race. Every SSTable reader uses
ReadAt on a shared, read-only *os.File.
Short reads are legal at the system-call level: pread can return
fewer bytes than requested (at end of file, or when interrupted by a
signal). (*os.File).ReadAt already loops until buf is full, retries
on EINTR, and returns io.EOF if the file ends first — the
io.ReaderAt contract requires a non-nil error whenever n <
len(buf). Code that calls unix.Pread directly must write that loop
itself:
func readFully(fd int, buf []byte, off int64) error {
for len(buf) > 0 {
n, err := unix.Pread(fd, buf, off)
if err == unix.EINTR {
continue
}
if err != nil {
return err
}
if n == 0 {
return io.ErrUnexpectedEOF // the file is shorter than its metadata says
}
buf, off = buf[n:], off+int64(n)
}
return nil
}
The io.Reader interface has the weaker contract — a single Read
may return fewer bytes than asked even when more are coming — which is
why framing code uses io.ReadFull
(Designing a Binary Protocol).
Direct I/O: bypassing the page cache¶
f, err := os.OpenFile(path, os.O_RDONLY|unix.O_DIRECT, 0) // Linux
O_DIRECT transfers data straight between the device and your buffer,
skipping the page cache. Databases with their own buffer pool
(InnoDB, ScyllaDB, RocksDB optionally) use it for three reasons:
- No double caching. Without it, a hot page lives once in the buffer pool and again in the page cache — wasting half the memory.
- Predictable memory and latency. The kernel's eviction and writeback decisions no longer interfere with the engine's own.
- Control. The engine decides exactly what is cached and when writes happen.
The cost is strict rules: the buffer address, file offset, and length
must all be aligned to the device's logical block size (typically
512 B or 4 KB) — otherwise the call fails with EINVAL. You also lose
readahead and must implement your own caching, which is a lot of work
to do better than the kernel. That is why LevelDB, and RocksDB by
default, use buffered I/O plus their own block cache of
uncompressed blocks, and treat O_DIRECT as an option.
// Go's allocator does not guarantee 4 KB alignment: over-allocate and
// slice from the first aligned address.
func alignedBuf(size, align int) []byte {
b := make([]byte, size+align)
off := (align - int(uintptr(unsafe.Pointer(&b[0]))&uintptr(align-1))) % align
return b[off : off+size : off+size]
}
buf := alignedBuf(64<<10, 4096) // aligned buffer for O_DIRECT
An anonymous unix.Mmap is another way to get page-aligned memory;
packages such as github.com/ncw/directio wrap both steps. On macOS
there is no O_DIRECT; fcntl(F_NOCACHE) (unix.FcntlInt(fd,
unix.F_NOCACHE, 1)) disables caching for a file instead, which is how
the random-read measurements in
Async I/O and io_uring bypassed the cache.
O_DIRECT does not imply durability: the device's volatile write
cache can still hold the data. You still need fdatasync (or
O_DSYNC).
Memory-mapped files: mmap¶
data, err := unix.Mmap(int(f.Fd()), 0, int(size), unix.PROT_READ, unix.MAP_SHARED)
// data is a []byte backed by the file: data[off] may page-fault and load it.
defer unix.Munmap(data)
mmap maps a file into the address space, so reading the file is
just reading memory; the kernel loads pages on
page faults. It is attractive —
zero copies, no read calls, the kernel manages caching — and LMDB is
built entirely on it. It also has serious problems for a general
database:
| Problem | Why it hurts |
|---|---|
| No control over eviction | The kernel may evict your hottest page; you cannot pin it |
I/O errors become SIGBUS |
A disk error on a page fault crashes the Go program instead of returning an error (unless the goroutine called debug.SetPanicOnFault(true)) |
| Stalls are invisible | Any memory access can block on disk I/O, including while holding a lock — and in Go the scheduler doesn't know the thread is blocked, so the P stalls with it |
| Write ordering | With a writable mapping, the kernel may write dirty pages back at any time, in any order — breaking WAL-before-data rules |
| TLB shootdowns | Unmapping or evicting pages requires interrupting other cores |
The 2022 CIDR paper "Are You Sure You Want to Use MMAP in Your
Database Management System?" (Crotty, Leis, Pavlo) works through these
in detail. A reasonable rule: mmap is acceptable for read-only
immutable files that fit comfortably in memory (LevelDB optionally
mmaps SSTables on 64-bit systems); use pread and an explicit cache
otherwise.
Observing the I/O stack¶
| Tool | Shows |
|---|---|
iostat -x 1 |
Per-device IOPS, throughput, queue size, latency (r_await, w_await), utilization |
vmstat 1 |
Blocked processes (b), I/O wait (wa), memory and swap |
grep -E 'Dirty|Writeback' /proc/meminfo |
How much dirty data is waiting for writeback |
pidstat -d 1 / iotop |
Which process is doing the I/O |
strace -f -e trace=pread64,pwrite64,fsync,fdatasync -T |
Each I/O system call your program makes, with its duration |
biolatency (bcc/bpftrace) |
Histogram of block-device latency |
Common pitfall¶
Measuring read performance right after writing a dataset reports page
cache speed, not disk speed — the data just written is still cached.
A benchmark that writes 2 GB and then reads it back on a machine with
16 GB of RAM never touches the disk on the read side. Either use a
dataset several times larger than memory, open files with O_DIRECT
for the measurement, or drop the cache between phases
(sync; echo 3 | sudo tee /proc/sys/vm/drop_caches on a test machine).