Memory Mapping, the Page Cache and Network I/O
High-throughput systems are built by letting the page cache be the shared buffer between disk, process and NIC, and by batching every crossing of the user/kernel boundary: Kafka’s log, a database’s buffer pool, writev, and io_uring.
The problem
The page cache is the shared buffer
The kernel keeps recently used file pages in RAM — the page cache — and every read(), write(), mmap and sendfile goes through it (see Follow a File Read and Memory Mapping). A process that writes to a file is writing into page-cache pages that the kernel flushes to disk later; a process that maps the file sees those same pages in its address space; a sendfile to a socket hands those same pages to the NIC. There is one copy of the data in memory and three ways to reach it. Systems that exploit this let the page cache be their buffer, their cache and their transfer mechanism at once, and skip building all three.
The price is that the page cache is managed by the kernel’s policy, not the application’s: eviction is approximately LRU across every file on the box (see Memory Pressure, Swap and the OOM Killer); a write is durable only after fsync, which the application must call; and a page fault on a mapped page that was evicted costs a disk read at a moment the application did not choose. Systems split on whether that trade is acceptable, and the split is the most interesting design decision in this lesson.
Kafka’s shape: append, page cache, sendfile
A Kafka broker stores each partition as a sequence of append-only segment files. Producers’ batches are written with ordinary file writes into the page cache — sequential, so the disk sees large writes when the kernel flushes, and the write call itself is a memory copy. The broker does not maintain its own cache of messages: recent data is in the page cache because it was just written, and the operating system keeps it there as long as memory allows. On a machine with 64 GB and a 32 GB heap deliberately kept small, 30 GB of page cache holds the last minutes of every partition.
Consumers fetch by offset; the broker answers with FileChannel.transferTo — sendfile — from the segment file to the consumer’s socket. With a scatter-gather NIC the data goes from page cache to wire with no CPU copy and no visit to the JVM heap (see Zero-Copy: Serving a File Without Touching It). Ten consumers of the same partition read the same page-cache pages ten times; the disk is read once, if at all. This is why a Kafka broker’s throughput is bounded by network and disk bandwidth rather than by CPU, and why its documentation tells you to leave memory to the OS.
Durability is the other half: Kafka does not fsync on every write by default — it relies on replication across brokers for durability and lets the kernel flush in the background. A system that needed each write on disk before acknowledging (a database’s write-ahead log — see A Transaction, Inside the Engine) would call fsync or fdatasync per commit, at a cost of tens of microseconds to milliseconds each, and would batch commits to amortise it. The same page cache serves both; the fsync policy is the durability decision.
- Producer batch arrives on socketrecv into broker buffer; one syscall per batch, not per record↓
- Append to segment filewrite() into page cache; sequential; no fsync by default↓
- Page cache holds itkernel flushes to disk asynchronously; recent data stays resident↓
- Replicate to followersdurability via copies, not fsync; leader waits for ISR acks↓
- Consumer fetchsendfile(segment → socket); no heap copy; N consumers, one page-cache read↓
- Evictionold segments leave the cache under memory pressure; lagging consumers hit disk
Databases: buffer pool versus page cache
A relational database usually refuses the page cache’s help and manages its own buffer pool (see How Is Database Data Physically Stored? and Pages: The Unit of Everything): it knows which pages are index roots and which are a one-off scan, it needs dirty pages to be written in a specific order relative to the WAL, and it needs eviction decisions to respect transactions. PostgreSQL reads through the page cache into shared_buffers (so hot pages are cached twice — the "double buffering" cost, accepted for the control it gives); InnoDB and most commercial engines open files with O_DIRECT and bypass the page cache entirely, so their buffer pool is the only cache and its size is a first-class tuning parameter.
The consequence for the network path is that a database cannot use `sendfile`: the bytes to send are in its private buffers (and are usually a computed result, not a file region anyway), so query results go out through ordinary send() — user space to socket buffer, one CPU copy — and the engine batches rows into large writes to amortise the syscall. mmap as a buffer-pool strategy (LMDB, early MongoDB, some LSM engines for reads) is the middle road: the page cache is the buffer pool, eviction is the kernel’s, durability still requires msync/fsync, and the well-known paper "Are You Sure You Want to Use MMAP in Your DBMS?" catalogues why most engines that tried it moved away — page-fault stalls, TLB shootdowns, no control over write ordering.
The rule of thumb: let the OS own the cache when data is append-only or immutable, accessed sequentially, and served unchanged (logs, object storage, static files, search-index segments). Own the cache yourself when you need write ordering, transactional eviction, or knowledge the kernel does not have about which pages matter. Both are correct; the mistake is choosing without knowing which regime you are in.
| System | Cache owner | Disk I/O | Network path | Durability |
|---|---|---|---|---|
| Kafka, log-structured brokers | page cache | buffered, sequential append | sendfile from page cache | replication; background flush |
| nginx static files, CDNs | page cache | buffered reads | sendfile (+ kTLS) | n/a (immutable) |
| PostgreSQL | shared_buffers over page cache | buffered (double-cached) | send() of computed results | WAL + fsync per commit |
| InnoDB, Oracle, SQL Server | private buffer pool | O_DIRECT, engine-scheduled | send() of computed results | log + fsync per commit |
| LMDB, mmap-based stores | page cache via mmap | page faults; msync | copy from mapping | msync/fsync; write ordering hard |
Batching the boundary: writev, sendmsg, io_uring
A syscall costs on the order of 100–300 ns to enter and leave on modern hardware, more with mitigations for speculative-execution bugs, plus the cache and TLB disturbance of running kernel code (see System Calls and User Mode vs Kernel Mode). At a million operations per second, one syscall each is a substantial fraction of a core doing nothing but crossing. Every high-throughput system therefore does fewer, larger crossings. The oldest tools are writev/readv and sendmsg/recvmsg: a vector of buffers in one call, so a header and a body, or a hundred small records, cost one crossing and no concatenation copy. sendmmsg/recvmmsg (Linux) go further and move many datagrams per call, which is how QUIC stacks recover UDP’s per-packet cost.
io_uring (Linux ≥ 5.1) is the generalisation: two ring buffers shared between the process and the kernel — a submission queue and a completion queue. The process writes operation descriptors (read this file at this offset, send this buffer on this socket, accept on this listener) into the submission ring and, in the extreme configuration, never makes a syscall at all: a kernel thread polls the ring. Completions appear in the other ring. It is a completion model, like Windows IOCP, and it works for regular files — which The Event-Driven Server noted epoll cannot handle. One crossing can submit hundreds of operations; a database or a proxy can queue a batch of disk reads and socket writes together and wait once.
Batching has a cost the throughput numbers hide: latency for the first item. A record that waits for its batch to fill waits by the batch interval (Kafka’s linger.ms, a database’s group-commit window, Nagle’s algorithm in TCP — see TCP: A Reliable Ordered Byte Stream over an Unreliable Network). Every batching system has a knob that trades those two, and the right setting is workload-specific. Keep the implementation details labelled: io_uring is Linux; IOCP is Windows; kqueue supports aio differently; the concept — amortise the crossing — is what transfers.
1# one syscall, two buffers, no concatenation2writev(sock, [ {hdr, 64}, {body, 65536} ], 2)3 4# many datagrams per syscall (UDP / QUIC senders)5sendmmsg(udp_sock, msgs, 64, 0)6 7# io_uring: queue many operations, one submit, completions in a ring8sqe = io_uring_get_sqe(ring); io_uring_prep_read(sqe, file, buf1, 4096, off)9sqe = io_uring_get_sqe(ring); io_uring_prep_send(sqe, sock, buf2, n, 0)10sqe = io_uring_get_sqe(ring); io_uring_prep_accept(sqe, listen_fd, ...)11io_uring_submit(ring) # one crossing (or zero with SQPOLL)12io_uring_wait_cqe(ring, &cqe) # completions, not readinessKey points
- The page cache is one copy of file data reachable by
read/write, bymmapand bysendfile; systems that let it be their cache and buffer skip building their own and get zero-copy to the NIC for free. - Kafka: sequential append into the page cache, durability by replication,
sendfileto consumers; throughput bounded by disk and network, not CPU. - Databases mostly own a private buffer pool (often with
O_DIRECT) because they need write ordering and transactional eviction; the cost is that results leave throughsend()with a copy, and the benefit is control.mmapas a buffer pool is a known trap. - Let the OS own the cache for append-only, immutable, sequentially served data; own it yourself when ordering and eviction policy matter.
- Syscalls cost ~100–300 ns each plus cache disturbance;
writev/sendmsg,sendmmsgandio_uringamortise the crossing.io_uringis a completion model that also covers regular files. - Batching trades first-item latency for throughput; every batching system has a linger knob, including TCP itself.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why does Kafka leave most of the machine’s RAM to the operating system?
Because the page cache already does what a message cache would do — hold recent segments in memory — and does it with zero-copy access from sendfile and no GC cost in the JVM. A large heap would cache the same bytes a second time and lose the zero-copy path.
▸Why does a database not just use mmap and let the kernel handle it?
Because the kernel cannot know the database’s rules: that a data page must not reach disk before the WAL record that describes it, that an index root is worth more than a scanned page, that eviction must respect pinned pages. Engines that tried mmap hit page-fault stalls, TLB shootdowns and write-ordering bugs, and moved to private buffer pools with O_DIRECT.
▸Why is io_uring a bigger change than a faster epoll?
Because it changes the model from "tell me when I can start" to "do this and tell me when it is done", covers files as well as sockets, and lets many operations cross the boundary together. It is the kernel offering the batching that high-throughput systems used to build themselves.
How it fails
What the failure looks like from inside real software.
- A Kafka broker given a 60 GB heap on a 64 GB box: the page cache is starved, every consumer fetch reads disk, and throughput collapses to disk speed while the heap sits mostly empty.
- A lagging consumer on a busy cluster: its offsets are past the page cache, each fetch is a disk read, and its disk reads evict the pages the fast consumers need. Symptom: one slow consumer degrades everyone.
- A PostgreSQL server with
shared_buffersat 75% of RAM: the page cache has no room, so every read that misses the buffer pool is a real disk read; the "generous" setting doubled I/O. - An mmap-based store on a box under memory pressure: reads that were memory speed become page faults to disk at unpredictable moments; p99 explodes with no change in the workload.
- A service that calls
write()once per 100-byte log line at 500,000 lines per second: 30–40% of a core in syscall overhead; switching towritevbatches of 64 lines removes it. - Group commit window set to 0 for "lower latency": each commit pays a full
fsync; throughput drops tenfold and latency under load gets worse, not better, because the disk queue fills.