Skip to content

Research notes / File I/O

Zero-copy transfers do not freeze source bytes

/ Measured Linux API behavior

A successful sendfile() can leave the receiver holding references to mutable file pages. In our file-to-pipe test, overwriting the source after the call returned changed the bytes later read from the pipe. Adding fsync() did not freeze them.

This is a documented source-lifetime obligation, not evidence of a Linux bug: zero-copy socket or pipe transfers require the caller to keep transferred regions unchanged until consumption. [1]

The same byte count, different byte versions

Each private test file begins with one page of A. We hash it, enqueue the page, then perform the named operation before consuming the pipe, except in the consume-first control. B is the replacement payload; C is written through an old descriptor after pathname replacement. There are no sleeps or concurrent-writer races.

Every enqueue returned 4,096 bytes. All 5 repetitions gave the same outcomes on Linux 6.8.0-146-generic.
SequenceReadPathOld inode
read + write, then overwriteABB
sendfile, then overwriteBBB
splice, then overwriteBBB
sendfile, overwrite, fsyncBBB
sendfile, replace pathnameABA
Replace pathname, then write old inodeCBC
sendfile, close input fd, overwriteBBB
sendfile, consume, then overwriteABB

The buffered control had already copied the bytes. The consume-first control had already read them. Both retained A. The other mutation cases demonstrate why enqueue completion alone cannot establish an immutable source version. These are 40 controlled checks, not independent samples estimating a failure rate.

A page reference is not a byte snapshot

In upstream Linux v6.8, splice_folio_into_pipe() puts a page reference in a pipe buffer and increases its reference count. filemap_splice_read() obtains pages from the file's page cache for that path. The reference preserves lifetime; it does not create a private copy of each byte. [3]

This source explains the observed behavior, but we did not trace or rebuild the distribution kernel. Nor does every splice path share pages: the kernel documents copy_splice_read(), which reads into newly allocated pages. A successful API call alone cannot prove that all copying was avoided. [6]

SPLICE_F_MOVE is not a way to demand exclusive ownership or frozen content; the manual documents it as a no-op. The experiment uses zero flags. [2] We also verify the returned count: short successful transfers must not be mistaken for a complete page.

Replacing a pathname helps only if the old object stays unchanged

Renaming a separately written replacement over the source changes what a new pathname lookup opens, while existing descriptors still refer to their previous object. [4] Our replacement-only case therefore delivered A while the pathname read B.

The next case is the important qualification. A writable descriptor still referred to the old inode. Writing C through it changed the queued data to C, even though the pathname continued to expose B. Atomic publication is useful for immutable file versions; rename by itself does not enforce immutability. See pathname publication and directory durability.

The separate close-input case also delivered B. Closing the descriptor used for transfer is not a receiver-consumption barrier. This connects to the broader distinction between descriptor, mapping and object lifetimes.

A checksum can be correct for the wrong intended version

In the overwrite case, a digest computed before enqueue describes A, but the receiver gets B. Hashing the pathname afterwards would describe B too. Agreement between those latter hashes would not prove that the earlier version was transferred. This follows directly from the retained payload hashes; it is not a hash-collision claim.

For backup or content-serving pipelines, bind verification to an identified, stable source version. A snapshot, immutable object version or coordinated writer protocol may provide that contract. Copying into an independent buffer protects bytes already copied, but reading a large changing file in multiple chunks still does not create an atomic snapshot. Compare backup consistency and recovery and checksums and object identity.

Two consumers can disagree after tee(2)

A separate experiment duplicates the queued page into another pipe with the Linux tee(2) system call. It then reads and hashes the first branch, overwrites the source, and reads and hashes the second. The syscall duplicates references without consuming the input; it does not promise independent byte snapshots. [8]

Two cases, each repeated 5 times. This tests tee(2), not the shell command tee(1).
Pipe filled byFirst readNext readHashes agree
sendfileABNo
bufferedAAYes

The file-backed path gave the first consumer A and the second B. The buffered control gave both A. A hash calculated on one branch therefore need not describe bytes later consumed on the other when the source-stability rule is broken. A hashing branch does not itself enforce that rule.

Download the supplement harness, observations and tests. The harness imports the main harness from the same directory and calls libc through Python ctypes; run python3 -B tee-source-lifetime.py --repetitions 5 --output tee-observations.json. It measures neither GNU tee nor any backup product.

IO-Lite made stable bytes part of the design

Pai, Druschel and Zwaenepoel's OSDI 1999 paper offers a useful contrast. IO-Lite shares immutable data buffers, while mutable buffer aggregates describe how their slices fit together. Editing an aggregate allocates new storage for changed bytes. Its file cache replaces buffers on a write, retaining earlier buffers while readers still reference them. [9]

The design also pairs a buffer address with a generation number so cached Internet checksums can identify unchanged contents across reuse. The transferable lesson is to make byte identity and mutation rights explicit when removing copies. This is our design comparison, not a claim that Linux sendfile implements IO-Lite.

The prototype used modified FreeBSD 2.2.6 and an application library. Its historical web-server measurements do not predict current Linux performance; the experiment here measures a different API contract.

Three completions that should not be conflated

Enqueue completion: this experiment records bytes accepted into a pipe. Source-lifetime completion: the receiver has consumed the referenced data. Persistence completion: synchronization has met the relevant storage contract. fsync() addresses synchronization, not snapshot isolation; our second-fsync case still delivered the changed bytes. [5]

Do not import completion rules from a different API. MSG_ZEROCOPY has socket error-queue notifications for buffer reuse, but they are not transmit-completion notifications and copying may still occur. Those notifications are not supplied by sendfile(). No MSG_ZEROCOPY or TCP experiment was run here. [7]

Reproduce the sequence, not a timing accident

The dataset contains the CSV, full JSON, harness, tests and source hashes. Download the Python harness and run it on Linux with Python 3.10 or later and GNU stat:

python3 zero-copy-lifetime.py --repetitions 5 --output observations.json

Only newly created files in private temporary directories are used. Choose the test filesystem with --temp-dir. The harness checks capacity, exact transfer counts, complete received payloads and cleanup. Unexpected API errors abort instead of being counted as successful observations.

The recorded environment is Python 3.12.3, Linux 6.8.0-146-generic, x86_64; stat -f reports ext2/ext3. That magic label does not identify an ext-family version or mount configuration. Input bytes are synthetic, but the calls and observations are real. This is not a throughput benchmark, crash test, TCP transfer test or survey of filesystems. Mutation cases deliberately violate the source-stability requirement; their particular resulting bytes are observations, not a portable promise.

Corpus definitions: zero-copy I/O, sendfile, splice and tee(2).

Primary sources

  1. Linux man-pages 6.19: sendfile(2), return value and source lifetime
  2. Linux man-pages 6.19: splice(2), pipe buffers and flags
  3. Linux v6.8 mm/filemap.c: splice_folio_into_pipe and filemap_splice_read
  4. Linux man-pages 6.19: rename(2), replacement and open descriptors
  5. Linux man-pages 6.19: fsync(2), synchronization and directory entries
  6. Linux 6.12 documentation: splice and pipes, copy_splice_read
  7. Linux kernel documentation: MSG_ZEROCOPY, notifications and deferred copies
  8. Linux man-pages 6.19: tee(2), duplication without consuming and shared references
  9. Pai, Druschel and Zwaenepoel: IO-Lite, OSDI 1999, sections 2.1, 2.5, 2.9 and 3

Published by Hesela team · Published · Updated

Report a correction · Editorial standards · Reuse guidance

Change history

: first published.

: latest recorded update. A detailed change note is not available for this revision.

New-publication RSS · Correction RSS

Cite this page
Hesela team. Zero-copy transfers do not freeze source bytes. Hesela. 2026-10-18. https://hesela.com/analyses/zero-copy-source-lifetime/