TogetherOS + Networkingzero-copysendfilesplicemmapDMA

Zero-Copy: Serving a File Without Touching It

Serving a file the naive way copies it four times and crosses the user/kernel boundary four times; sendfile, splice, scatter-gather DMA and, at the extreme, kernel bypass remove the copies the CPU does not need to make — until TLS puts one back.

ConceptualLinuxUnix-styleWindows
▶ InteractiveInterview question
Progress

The problem

A static file server reads a file into a buffer and writes the buffer to a socket. The data is already in the kernel (page cache) and ends up in the kernel (socket buffer); the trip through user space exists only because the API was read() then write(). How many copies and mode switches is that costing, and which of them can be removed?

The naive path: four copies, four crossings

Conceptual

read(file_fd, buf, n): the kernel finds the file’s pages in the page cache (loading them from disk by DMA if absent — see Follow a File Read), then copies them into buf in user space (copy 1: CPU). write(sock_fd, buf, n): the kernel copies buf into the socket send buffer (copy 2: CPU); later the NIC copies from the socket buffer by DMA (copy 3: DMA). Counting the disk-to-page-cache DMA, that is four copies of every byte, two of them by the CPU, and each syscall is two mode switches — four crossings per chunk. For a 1 GB file in 64 kB chunks: 32,768 syscalls and 2 GB of memcpy the CPU performs for no reason except that the data visited a buffer it did not need.

At low throughput this is invisible. At 10 Gbit/s, 1.25 GB/s of memcpy per direction is a substantial fraction of a core, cache pollution for everything else on it, and memory bandwidth you are paying for twice. File servers, CDN edges, video origins, log shippers and message brokers hit this wall first, which is why the mechanisms below come from exactly those systems.

read() + write(): every byte, four times
  1. Disk → page cacheDMA; copy 1 (unavoidable if not cached)
  2. page cache → user bufferCPU copy 2; read() returns (mode switch ×2)
  3. user buffer → socket send bufferCPU copy 3; write() returns (mode switch ×2)

sendfile and splice: keep it in the kernel

Linux

sendfile(out_fd, in_fd, offset, count) asks the kernel to move bytes from one descriptor to another without visiting user space. On Linux, with a NIC that supports scatter-gather DMA and checksum offload, the kernel does not even copy from the page cache to the socket buffer: it appends *descriptors* — page address plus length — to the socket’s send queue, and the NIC gathers the payload directly from the page-cache pages. That leaves two DMA transfers and zero CPU copies, with one syscall for the whole file: one mode-switch pair instead of thousands. Without scatter-gather the kernel makes one CPU copy (page cache → socket buffer) — still half the naive cost. This is what nginx’s sendfile on does and what Kafka uses (via Java’s FileChannel.transferTo) to ship log segments to consumers at near line rate.

splice(fd_in, …, fd_out, …) generalises the idea with a pipe as the intermediary: data moves between two descriptors by passing references to the pages through the pipe buffer, not by copying. It handles socket-to-socket (a proxy forwarding a stream), socket-to-file (recording an upload) and file-to-socket alike; sendfile on modern Linux is implemented on top of it. HAProxy uses splice for TCP forwarding for precisely the proxy case.

The APIs are not portable. Linux sendfile takes (out, in, offset*, count) and originally required a socket as the output; FreeBSD and macOS have a sendfile with a different signature and extra header/trailer arguments; Windows has TransmitFile on a socket handle; Java hides the differences behind transferTo, with fallbacks when the platform cannot do it. The concept — "let the kernel move bytes between descriptors" — is universal; the calls, limits and edge cases are not.

The file-serving path with sendfile (Linux; scatter-gather NIC)
1# naive: 2 syscalls per chunk, 2 CPU copies per chunk
2while (n = read(file, buf, 64K)) > 0:
3 write(sock, buf, n)
4
5# sendfile: 1 syscall for the file, 0 CPU copies with SG-DMA
6sendfile(sock, file, &offset, file_size)
7# page cache pages --descriptors--> socket send queue --gather DMA--> NIC
8
9# splice: same via a pipe; works socket -> socket (proxy)
10splice(in_sock, NULL, pipe_w, NULL, 64K, SPLICE_F_MOVE)
11splice(pipe_r, NULL, out_sock, NULL, 64K, SPLICE_F_MOVE)

mmap + write, scatter-gather from user space, and the extreme

Conceptual

mmap the file and write() the mapping to the socket, and one copy disappears: the page cache *is* the user-visible buffer (see Memory Mapping), so write() copies page cache → socket buffer directly, three copies total. It costs page-table setup and faults, it is unsafe if the file is truncated while mapped (SIGBUS), and for one-shot sends sendfile beats it; it wins when the process also needs to read the data (a database serving from a mapped file, a search engine scanning a mapped index).

When the data must be assembled in user space — an HTTP header followed by a body, a protocol frame with a computed prefix — writev/sendmsg take an array of buffers and copy them into the socket in one syscall, avoiding either a concatenation copy or one syscall per piece. Linux additionally offers MSG_ZEROCOPY on send(), which pins the user pages and lets the NIC DMA from them directly, with a completion notification when the pages may be reused; it pays off only above roughly 10 kB per call, because the pinning costs more than a small copy.

The extreme is kernel bypass: DPDK, netmap, AF_XDP, and user-space TCP stacks map the NIC’s rings directly into a process and poll them, so packets never touch the kernel at all. A single core can then handle tens of millions of packets per second. The price is total: no kernel TCP, no epoll, no firewall, no sharing the NIC with anything else, and a process that busy-polls one core forever. It is the right design for a software router, a load balancer at the edge, or an exchange’s market-data gateway, and wrong for everything else.

Copies and crossings per byte, serving a cached file to a socket
MethodCPU copiesDMA copiesSyscalls per fileScope / notes
read() + write()22 (disk once, NIC)2 per chunkportable
mmap() + write()121 per chunk + mmapportable; SIGBUS on truncation
sendfile, no SG121Linux/BSD/macOS differ; Windows TransmitFile
sendfile with SG-DMA021Linux + capable NIC; nginx, Kafka
splice via pipe022 per chunkLinux; socket→socket for proxies
send MSG_ZEROCOPY0 (pinned user pages)21 per chunk + completionLinux ≥ 4.14; ≥ ~10 kB to pay off
Kernel bypass (DPDK/AF_XDP)010 (polling)no kernel stack; dedicated cores

Where it does not help: TLS, and the kTLS answer

Linux

Zero-copy assumes the bytes on the wire are the bytes in the page cache. TLS breaks that: every byte must be encrypted, and the encryption has to happen somewhere with the key. If TLS runs in the application (OpenSSL in nginx, the JVM’s TLS in Kafka), the data must come up to user space to be encrypted and go back down as ciphertext — the naive path plus AES. Kafka’s documentation says it directly: enabling TLS disables the zero-copy path. For most services TLS overhead is dominated by the handshake, not the record encryption; for a file origin at 40 Gbit/s it is the whole story.

Kernel TLS (kTLS, Linux since 4.13 for transmit, 4.17 for receive) moves the record encryption into the socket: the application does the handshake in user space, hands the negotiated keys to the kernel with setsockopt(SOL_TLS), and thereafter sendfile on that socket encrypts on the way out — with hardware offload on NICs that support it, zero CPU copies again. nginx supports it with OpenSSL 3; FreeBSD has an equivalent used by Netflix’s origins, which is the canonical case study for this whole lesson. Windows has no equivalent exposed the same way; again, the idea is portable and the API is not.

Two more limits. Zero-copy removes CPU copies, not disk reads: a file not in the page cache still costs a disk DMA and a wait, and sendfile on a blocking socket blocks for the disk read as well. And it removes nothing from the network: a 100 ms RTT path is still bounded by bandwidth × delay (The Buffer Chain) whatever the server’s CPU is doing. Zero-copy is a CPU and memory-bandwidth optimisation for high-throughput file-to-socket paths, and only that.

  • Application TLS forces the data through user space; zero-copy is off.
  • kTLS (Linux, FreeBSD) puts record encryption in the socket so sendfile works again; NIC crypto offload makes it free.
  • Not a latency fix, not a network fix, not a disk fix.

Key points

  • read() + write() to serve a cached file: two CPU copies, two DMA copies, two syscalls per chunk. The user-space visit is pure overhead.
  • sendfile keeps the data in the kernel; with scatter-gather DMA the NIC reads page-cache pages directly — zero CPU copies, one syscall per file. splice does the same through a pipe and handles socket-to-socket.
  • mmap + write saves one copy and suits processes that also read the data; writev/sendmsg batch user-space pieces into one syscall; MSG_ZEROCOPY pins user pages for large sends.
  • Kernel bypass (DPDK, AF_XDP) removes the kernel entirely, at the cost of the kernel’s stack, sharing and firewall; right for packet-processing appliances only.
  • The APIs are platform-specific: Linux sendfile/splice, BSD/macOS sendfile with different signatures, Windows TransmitFile. Do not assume portability.
  • TLS in user space disables zero-copy; kTLS restores it by moving record encryption into the socket.
  • Zero-copy is a CPU/memory-bandwidth optimisation for high-throughput file-to-socket paths; it does nothing for latency, disk misses or bandwidth × delay.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why did the data ever go through user space?

Because the Unix I/O model is "read into your buffer, write from your buffer": simple, composable, and correct for the common case where the program transforms the data. Serving files unchanged is the special case where the model’s generality is pure cost, and sendfile is the special-case API.

Why does scatter-gather matter so much?

Because the last CPU copy — page cache to socket buffer — exists only so the payload is contiguous for the NIC. A NIC that can gather from a list of (address, length) pairs does not need contiguity, so the kernel can point at the page-cache pages and never touch the bytes.

Why does TLS undo it?

Encryption must read every byte and produce different bytes; whoever holds the key does that work. If the key is in the application, the data must visit the application. kTLS exists to put the key where the data already is.

Why not always bypass the kernel?

Because the kernel’s stack is what gives you TCP, sharing the NIC among processes, firewalls, epoll, and a CPU that sleeps when idle. Bypass trades all of that for packet rate; only workloads whose entire job is packets can afford the trade.

Copy count: serving a file

How many times is a 1 MB file copied?
Serve one file to one socket four ways and count copies, mode switches and simulated CPU time.
Method
Disk● 1 MB here
Page cache
User buffer
Socket buffer
NIC
Copies
0 (0 by CPU, 0 by DMA)
Mode switches
1
CPU time (simulated)
1 µs
  1. 1switchread(file, buf, 1 MB): user → kernel
  2. 2DMACopy 1: DMA disk → page cache
  3. 3CPU copyCopy 2: CPU page cache → user buffer
  4. 4switchread() returns: kernel → user
  5. 5switchwrite(sock, buf, 1 MB): user → kernel
  6. 6CPU copyCopy 3: CPU user buffer → socket buffer
  7. 7DMACopy 4: DMA socket buffer → NIC
  8. 8switchwrite() returns: kernel → user
4 copies, 4 mode switches. Two of the copies are the CPU shuffling the same bytes between kernel and user memory for no reason but the API.
1/8Unix-styleSimulated

How it fails

What the failure looks like from inside real software.

  • sendfile on in nginx with a network file system or a FUSE mount: some filesystems do not support the splice path and the server falls back or errors; the symptom is truncated or corrupt responses on that mount only.
  • Kafka cluster upgraded to TLS between brokers and consumers: consumer throughput halves and broker CPU doubles, because the zero-copy path is now the copy-and-encrypt path.
  • mmap + write on a log file that is truncated by rotation while mapped: the writer dies with SIGBUS mid-response.
  • MSG_ZEROCOPY used for 200-byte messages: throughput drops, because page pinning and the completion queue cost more than the copy they replaced.
  • A DPDK-based gateway deployed on a host that also runs ordinary services: the bypassed NIC disappears from the kernel; monitoring, SSH and the firewall on that interface stop working.
  • Zero-copy enabled and then blamed for latency: the p99 was disk misses on uncached files, which sendfile cannot remove; the fix was cache warming, not copy counting.