DEV Community

Cover image for Stop Bouncing Bytes Through User Space: Practical Zero-Copy File I/O on Linux
Lyra
Lyra

Posted on

Stop Bouncing Bytes Through User Space: Practical Zero-Copy File I/O on Linux

Stop Bouncing Bytes Through User Space: Practical Zero-Copy File I/O on Linux

Every file copy you have ever written probably looks like this:

while ((n = read(in, buf, sizeof buf)) > 0)
    write(out, buf, n);
Enter fullscreen mode Exit fullscreen mode

It is correct, portable, and quietly wasteful. For every byte, the kernel copies it from the page cache into your buffer, then from your buffer back into the page cache. On a 10 GB copy that is 20 GB of memory traffic that exists only to satisfy an API shape.

Linux has three system calls that skip that round trip: copy_file_range(2), sendfile(2), and splice(2). They look similar, but they solve different problems, and each has sharp edges that are easy to miss.

This is a practical tour: what each call actually guarantees, complete working code, how to fall back safely, and how to prove the copies are gone.

What the classic loop really costs

The read()/write() loop is not stupid. It is general: it works on sockets, pipes, character devices, and files, and it lets you inspect or transform the data. But for a plain file-to-file copy it does this:

  1. read() copies data from the page cache into your userspace buffer.
  2. write() copies it from your buffer into the destination's page cache.
  3. Writeback eventually sends it to storage.

Step 3 is unavoidable. Steps 1 and 2 are not. They also cost syscalls: two per buffer, plus scheduling and fault overhead.

The zero-copy calls push the copy down into the kernel, where it can move page references instead of bytes, or ask the filesystem to do something smarter.

Three calls, three shapes

Call Typical direction Key constraint Since
copy_file_range regular file → regular file both must be regular files; flags must be 0 Linux 4.5
sendfile regular file → socket (or file) input must support mmap-like operations Linux 2.2
splice anything ↔ pipe one descriptor must be a pipe Linux 2.6.17

The short version:

  • Copying a file to another file? Use copy_file_range.
  • Serving a file over a socket? Use sendfile.
  • Moving bytes between a socket and a file, or inserting a pipe into a pipeline? Use splice.
  • Need to inspect or modify the bytes? None of these help. Use read/write.

copy_file_range: the file-to-file default

#define _GNU_SOURCE
#define _FILE_OFFSET_BITS 64
#include <unistd.h>

ssize_t copy_file_range(int fd_in, off_t *off_in,
                        int fd_out, off_t *off_out,
                        size_t size, unsigned int flags);
Enter fullscreen mode Exit fullscreen mode

The contract, from copy_file_range(2):

  • If off_in is NULL, reading starts at the file offset and the offset advances. If it is non-NULL, the file offset is untouched and *off_in advances instead. The same applies to off_out.
  • The call returns the number of bytes copied, which may be less than requested. A return of 0 means the input offset is at or past EOF.
  • flags is reserved and must be 0.
  • fd_in and fd_out may refer to the same file, as long as the ranges do not overlap.
  • Both descriptors must refer to regular files (EINVAL otherwise), and fd_out must not have O_APPEND set.

The interesting part is what the man page calls "copy acceleration": the filesystem may implement the copy with reflinks (shared copy-on-write extents) or, on NFS, a server-side copy. You do not have to ask for it; the filesystem decides.

A complete, careful implementation

Short reads are normal, so the copy must loop. Cross-filesystem copies fail with EXDEV unless both filesystems are the same type and support it (since Linux 5.19), so a fallback is mandatory in portable code.

#define _GNU_SOURCE
#define _FILE_OFFSET_BITS 64
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/sendfile.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>

static int copy_rw(int in, int out)
{
    char buf[128 * 1024];

    for (;;) {
        ssize_t n = read(in, buf, sizeof buf);

        if (n == 0)
            return 0;
        if (n < 0) {
            if (errno == EINTR)
                continue;
            return -1;
        }

        char *p = buf;
        while (n > 0) {
            ssize_t w = write(out, p, (size_t)n);
            if (w < 0) {
                if (errno == EINTR)
                    continue;
                return -1;
            }
            p += w;
            n -= w;
        }
    }
}

static int copy_file(int in, int out, off_t size)
{
    off_t off_in = 0, off_out = 0;

    /* First choice: in-kernel file-to-file copy. */
    for (;;) {
        ssize_t n = copy_file_range(in, &off_in, out, &off_out, 1 << 20, 0);

        if (n > 0)
            continue;
        if (n == 0)
            return 0;
        if (errno == EINTR)
            continue;
        if (errno != EXDEV && errno != EINVAL &&
            errno != EOPNOTSUPP && errno != ENOSYS)
            return -1;
        break;  /* unsupported here; fall through */
    }

    /*
     * Resume exactly where copy_file_range stopped. Without these
     * lseeks a partially completed copy would be duplicated.
     */
    if (lseek(in, off_in, SEEK_SET) < 0)
        return -1;
    if (lseek(out, off_out, SEEK_SET) < 0)
        return -1;

    /* Second choice: sendfile to a regular file (Linux 2.6.33+). */
    while (off_in < size) {
        size_t chunk = (size_t)(size - off_in);
        if (chunk > 0x7ffff000)   /* sendfile's per-call limit */
            chunk = 0x7ffff000;

        ssize_t n = sendfile(out, in, &off_in, chunk);

        if (n > 0)
            continue;
        if (n == 0)
            return 0;
        if (errno == EINTR)
            continue;
        break;  /* sendfile refused too */
    }

    /* Last resort: the portable loop. */
    off_out = lseek(out, 0, SEEK_CUR);
    if (off_out < 0 || lseek(in, off_in, SEEK_SET) < 0)
        return -1;

    return copy_rw(in, out);
}

int main(int argc, char **argv)
{
    if (argc != 3) {
        fprintf(stderr, "usage: %s SRC DST\n", argv[0]);
        return 2;
    }

    int in = open(argv[1], O_RDONLY);
    if (in < 0) {
        perror("open src");
        return 1;
    }

    struct stat st;
    if (fstat(in, &st) < 0) {
        perror("fstat");
        return 1;
    }
    if (!S_ISREG(st.st_mode)) {
        fprintf(stderr, "%s: not a regular file\n", argv[1]);
        return 1;
    }

    int out = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (out < 0) {
        perror("open dst");
        return 1;
    }

    if (copy_file(in, out, st.st_size) < 0) {
        perror("copy");
        return 1;
    }

    /* copy_file_range copies bytes, not metadata. Do it yourself. */
    if (fchmod(out, st.st_mode & 07777) < 0)
        perror("fchmod");

    struct timespec ts[2] = { st.st_atim, st.st_mtim };
    if (futimens(out, ts) < 0)
        perror("futimens");

    close(out);
    close(in);
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Build and run it:

cc -O2 -Wall -Wextra -o zcopy zcopy.c
truncate -s 2G big.bin
./zcopy big.bin copy.bin
Enter fullscreen mode Exit fullscreen mode

Two details in that code are worth staring at:

  • The fallback resumes at off_in/off_out. A partial copy_file_range followed by a naive copy_rw would append the whole file again.
  • sendfile is capped at 0x7ffff000 bytes per call on both 32-bit and 64-bit systems. That is 2,147,479,552 bytes, just under 2 GiB. Loop.

The sparse-file trap

copy_file_range(2) warns that holes in a sparse file may be expanded. In other words, a file that occupies 100 MB on disk but reports 10 GB of logical size can become a real 10 GB file after the copy. If sparseness matters, walk the file with lseek(SEEK_DATA) and lseek(SEEK_HOLE) and copy only the data segments, reproducing the holes with lseek and ftruncate on the destination.

sendfile: file to socket without the bounce

#include <sys/sendfile.h>

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
Enter fullscreen mode Exit fullscreen mode

Note the argument order: output first. That surprises almost everyone once.

Facts that matter:

  • in_fd must support mmap-like operations, so it cannot be a socket. Since Linux 5.12, if out_fd is a pipe, sendfile simply becomes splice internally.
  • Before Linux 2.6.33, out_fd had to be a socket. Since then it can be any file, which is why sendfile appears in the fallback above.
  • The call transfers at most 0x7ffff000 bytes and may return a short count. Retry until done.
  • If offset is non-NULL, *offset is updated and the input file offset is left alone.

A minimal file server core:

#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/sendfile.h>
#include <sys/stat.h>
#include <unistd.h>

int send_file(int sock, const char *path)
{
    int fd = open(path, O_RDONLY);
    if (fd < 0)
        return -1;

    struct stat st;
    if (fstat(fd, &st) < 0) {
        close(fd);
        return -1;
    }

    char hdr[256];
    int hlen = snprintf(hdr, sizeof hdr,
        "HTTP/1.1 200 OK\r\n"
        "Content-Length: %lld\r\n"
        "Content-Type: application/octet-stream\r\n"
        "Connection: close\r\n\r\n",
        (long long)st.st_size);

    if (write(sock, hdr, (size_t)hlen) != hlen) {
        close(fd);
        return -1;
    }

    off_t off = 0;
    while (off < st.st_size) {
        size_t chunk = (size_t)(st.st_size - off);
        if (chunk > 0x7ffff000)
            chunk = 0x7ffff000;

        ssize_t n = sendfile(sock, fd, &off, chunk);

        if (n > 0)
            continue;
        if (n == 0)
            break;
        if (errno == EINTR)
            continue;
        if (errno == EAGAIN)
            return -2;  /* non-blocking socket: wait for POLLOUT, retry */
        close(fd);
        return -1;
    }

    close(fd);
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

On a non-blocking socket, EAGAIN is not an error — it is an instruction to wait for writability and call again with the same off. The kernel will not consume bytes it did not accept.

If you send headers and file data back to back, TCP_CORK (see tcp(7)) is the usual way to avoid a tiny header packet followed by a burst. Set it before the header, clear it after the last sendfile.

And the honest caveat: if you terminate TLS in userspace with a library like OpenSSL, the plaintext has to pass through your process anyway. sendfile saves nothing there. It shines for plain HTTP, static file servers, backup agents, and local sockets.

splice: when a pipe belongs in the middle

splice moves data between two descriptors where at least one is a pipe. The pipe is not a dumb buffer here: internally it holds reference-counted pointers to pages. Moving data through it copies pointers, not payloads. That is why the man page says "actual copies are generally avoided."

#define _GNU_SOURCE
#define _FILE_OFFSET_BITS 64
#include <fcntl.h>

ssize_t splice(int fd_in, off_t *off_in,
               int fd_out, off_t *off_out,
               size_t size, unsigned int flags);
Enter fullscreen mode Exit fullscreen mode

The flags:

  • SPLICE_F_MOVE — a hint to move pages rather than copy them. It has been a no-op since Linux 2.6.21, though it is still accepted.
  • SPLICE_F_NONBLOCK — do not block on the pipe. The underlying file descriptors can still block unless they are O_NONBLOCK.
  • SPLICE_F_MORE — more data is coming; useful when the output is a socket.
  • SPLICE_F_GIFT — unused by splice; it belongs to vmsplice.

The classic use case is receiving a stream straight to disk: socket → pipe → file.

#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>

int splice_to_file(int sock, int out)
{
    int p[2];
    if (pipe(p) < 0)
        return -1;

    int rc = 0;

    for (;;) {
        ssize_t n = splice(sock, NULL, p[1], NULL, 1 << 16,
                           SPLICE_F_MORE);

        if (n == 0)
            break;                  /* peer closed the connection */
        if (n < 0) {
            if (errno == EINTR)
                continue;
            rc = -1;
            break;
        }

        ssize_t left = n;
        while (left > 0) {
            ssize_t w = splice(p[0], NULL, out, NULL, (size_t)left, 0);

            if (w < 0) {
                if (errno == EINTR)
                    continue;
                rc = -1;
                goto done;
            }
            left -= w;
        }
    }

done:
    close(p[0]);
    close(p[1]);
    return rc;
}
Enter fullscreen mode Exit fullscreen mode

Two practical notes:

  • Pipe capacity is the throughput ceiling. The default is 16 pages — 65,536 bytes with 4 KiB pages. You can raise it with fcntl(p[0], F_SETPIPE_SZ, 1 << 20); unprivileged users are capped by /proc/sys/fs/pipe-max-size, which defaults to 1 MiB. Bigger pipes mean fewer round trips between the two splice calls.
  • The pipe must be drained. If you splice into a full pipe and never read it, you deadlock. Always pair each socket→pipe splice with a pipe→file drain.

tee(2) is the companion call: it duplicates everything in a pipe into a second pipe without consuming it. That is how you can log a stream and write it to disk at the same time without a userspace copy. Since Linux 2.6.31, both descriptors passed to splice may be pipes, which makes chaining and fan-out practical.

Choosing in practice

  • Regular file → regular file: copy_file_range, then sendfile, then read/write.
  • Regular file → socket: sendfile. If TLS is terminated in userspace, just use write/send.
  • Socket → regular file: splice through a pipe, or plain read/write if you need to parse the stream.
  • Stream transformation (compress, encrypt, scan): you need the bytes in userspace anyway. Zero-copy stops at the transformation boundary.
  • Anything involving pipes or FIFOs: splice and tee.

A useful sanity rule: if your code never looks at the payload, it should not be the one carrying it.

Prove it: verification that takes seconds

Do not trust wall-clock timings on a warm page cache. Trace the syscalls instead.

strace -c -e trace=read,write,copy_file_range,sendfile,splice ./zcopy big.bin copy.bin
Enter fullscreen mode Exit fullscreen mode

A successful copy_file_range path shows calls to copy_file_range and essentially no read/write data loop. On a filesystem or kernel that rejects it, you will see the fallback calls instead — which is exactly what you want to know.

For storage-level traffic, read the process counters in /proc/<pid>/io:

cat /proc/self/io
Enter fullscreen mode Exit fullscreen mode

syscr and syscw count "file read" and "file write" syscalls, and the man page explicitly includes sendfile(2) and copy_file_range(2) in those counters. read_bytes and write_bytes report what actually reached the storage layer. That distinction is the whole point: a copy can move gigabytes through the page cache while read_bytes stays near zero, and that is fine.

If you used copy_file_range on a reflink-capable filesystem (Btrfs, XFS with reflink enabled), filefrag -v will show the destination extents as shared. That is the filesystem doing copy-on-write instead of copying blocks — the cheapest copy is the one that never happens.

Portability notes and a Python shortcut

These are Linux interfaces. sendfile exists elsewhere with different semantics and prototypes, and the man page says plainly that it should not be used in portable programs. copy_file_range is Linux/GNU-specific. If you need portability, keep the read/write path as the fallback and detect support at runtime, as the C example does.

Python exposes the same primitives:

import errno
import os

def copy_range(src: str, dst: str, chunk: int = 1 << 20) -> bool:
    """Return True on success, False if the kernel/filesystem refused."""
    with open(src, "rb", buffering=0) as s, open(dst, "wb", buffering=0) as d:
        while True:
            try:
                n = os.copy_file_range(s.fileno(), d.fileno(), chunk)
            except OSError as e:
                if e.errno in (errno.EXDEV, errno.EINVAL,
                               errno.EOPNOTSUPP, errno.ENOSYS):
                    return False
                raise
            if n == 0:
                return True
Enter fullscreen mode Exit fullscreen mode

os.copy_file_range() has been available since Python 3.8 on Linux. os.sendfile() and os.splice() are also exposed; socket.sendfile() uses the platform's sendfile path where it can. As always, do not assume the fast path was taken — check the return value and fall back.

The gotcha checklist

  • Short copies are normal. Loop on positive returns; treat 0 as EOF.
  • Never ignore EINTR. Every one of these calls can be interrupted.
  • Resume at the right offset. If you mix syscalls, lseek both descriptors to the exact byte where the previous call stopped.
  • Metadata is not copied. copy_file_range copies bytes. Use fchmod, fchown (usually root-only), and futimens if you need the mode and timestamps.
  • O_APPEND breaks things. copy_file_range and sendfile reject it on the output.
  • Cross-filesystem means EXDEV unless both sides are the same filesystem type with support (Linux 5.19+). Always have a fallback.
  • Sparse files can grow. Holes may be expanded; use SEEK_DATA/SEEK_HOLE if that matters.
  • sendfile tops out just under 2 GiB per call. Loop.
  • Pipes are small by default. Raise F_SETPIPE_SZ for high-throughput splice paths, and always drain them.
  • A userspace transform defeats the purpose. Compression, encryption, and parsing need the bytes; zero-copy is for the parts you never inspect.

The read/write loop is still the right answer for small copies, unusual descriptors, and anything that needs to look at the data. But for the boring, high-volume cases — backups, image deployment, static file serving, log shipping — the kernel already knows how to move bytes without your process touching them. Let it.

Sources

Top comments (1)

Collapse
 
jun_liu_ca219b73a8f1489b5 profile image
jun liu •

Excellent practical guide! The distinction between copy_file_range, sendfile, and splice is something every ops engineer should understand. One addition from production: copy_file_range has a handy feature where it can do deduplication on filesystems that support it like XFS with reflinks. Also worth noting for container environments - sendfile works across network namespaces which splice does not, so for Kubernetes sidecar patterns it is often the safer default. Great read.