“Zero-copy” is often used for several different optimizations. A parser that returns string slices, a reference-counted byte buffer, a memory-mapped file, and Linux sendfile can all avoid a copy—but at different layers and with different constraints.
A useful discussion starts by naming the copy we are trying to remove.
A taxonomy of copy avoidance
Consider a file served over a TCP socket. A conventional path can involve:
- storage into the kernel page cache;
- a copy from kernel memory into a userspace buffer during
read; - parsing or reshaping that allocates additional userspace buffers;
- a copy from userspace into the kernel’s socket buffers during
write; - transfer to the network device.
Different techniques remove different steps:
- borrowing avoids copying inside a process;
- shared immutable buffers avoid copies between owners in a process;
- memory mapping avoids an explicit
readinto an application buffer; - kernel-assisted transfer such as
sendfilecan avoid a userspace round trip; - socket copy avoidance such as
MSG_ZEROCOPYcan avoid copying a large userspace send buffer into the kernel, but adds page-pinning and completion handling.
No single technique makes the entire storage-to-network path universally copy-free.
Borrowed parsing: zero allocation on the success path
Rust slices make it natural for a parser to return views into an input buffer.
#[derive(Debug, PartialEq, Eq)]
struct Header<'a> {
name: &'a str,
value: &'a str,
}
fn parse_header(line: &str) -> Result<Header<'_>, &'static str> {
let (name, value) = line.split_once(':').ok_or("missing colon")?;
let name = name.trim();
let value = value.trim();
if name.is_empty() {
return Err("empty name");
}
Ok(Header { name, value })
}
let input = "content-type: application/json";
let header = parse_header(input).unwrap();
assert_eq!(header.value, "application/json");
Header contains two (pointer, length) views. It allocates no strings and cannot outlive input; the lifetime parameter makes that dependency explicit.
This is zero-copy within the parser, not zero-copy networking. The bytes had to arrive in input somehow, and a later consumer may still need owned storage.
Clone on write when mutation is uncommon
Cow<'a, str> can remain borrowed when input is already normalized and allocate only when a transformation is required.
use std::borrow::Cow;
fn ascii_lowercase(input: &str) -> Cow<'_, str> {
if input.bytes().all(|b| !b.is_ascii_uppercase()) {
Cow::Borrowed(input)
} else {
let mut owned = input.to_owned();
owned.make_ascii_lowercase();
Cow::Owned(owned)
}
}
assert!(matches!(ascii_lowercase("content-type"), Cow::Borrowed(_)));
assert!(matches!(ascii_lowercase("Content-Type"), Cow::Owned(_)));
The function is deliberately named ascii_lowercase. Unicode case conversion can change length and may map one character to multiple characters, so it needs different semantics.
Cow is useful when most inputs pass through unchanged. If every call mutates, it adds branching and type complexity without saving work.
Shared byte storage with Bytes
The bytes::Bytes type represents immutable byte storage that can be cloned and sliced cheaply. Implementations may share backing storage rather than copying the referenced bytes.
use bytes::Bytes;
fn split_frame(frame: Bytes) -> Result<(Bytes, Bytes), &'static str> {
if frame.len() < 4 {
return Err("short frame");
}
Ok((frame.slice(..4), frame.slice(4..)))
}
let frame = Bytes::from_static(b"HEADpayload");
let (header, body) = split_frame(frame).unwrap();
assert_eq!(&header[..], b"HEAD");
assert_eq!(&body[..], b"payload");
The slices keep the backing allocation alive. That is excellent for fan-out and queues, but it can retain a large allocation for one tiny surviving slice. Copying a small long-lived field may use less memory than retaining the entire input buffer.
Memory mapping avoids an explicit read buffer
mmap maps a file’s pages into a process address space. Accesses fault pages in as needed, and the OS can share page-cache pages across processes.
In Rust, crates such as memmap2 wrap the platform API:
use memmap2::MmapOptions;
use std::{fs::File, io};
fn checksum(path: &str) -> io::Result<u64> {
let file = File::open(path)?;
// Safety: the file must not be truncated or modified incompatibly while mapped.
let map = unsafe { MmapOptions::new().map(&file)? };
Ok(map.iter().map(|&byte| u64::from(byte)).sum())
}
Mapping avoids allocating a second userspace buffer and calling read to fill it. It does not mean the storage device places data directly into CPU registers, nor does it guarantee no page faults or disk I/O. Access patterns determine whether the kernel’s paging and readahead decisions help.
The unsafe boundary matters. If another process truncates a mapped file, later access can fault at the hardware/OS boundary rather than returning an ordinary Rust error. Applications need an ownership or immutability protocol for mapped files.
Most importantly, this is still not a zero-copy socket send:
stream.write_all(&map)?;
An ordinary write normally copies bytes from the mapped userspace address range into kernel-managed networking buffers. The mapping saved the file-to-userspace copy, not necessarily the userspace-to-socket copy.
Kernel-assisted file transfer
On Linux, sendfile(out_fd, in_fd, ...) asks the kernel to transfer file data to another descriptor without first returning the bytes to an application buffer. A successful call may transfer fewer bytes than requested, so callers must loop, handle EINTR/EAGAIN, and provide a fallback for unsupported descriptor combinations.
splice moves data between descriptors through a pipe and can connect more general producer/consumer paths. Even its “move pages” flag is a hint; implementations may copy.
These APIs reduce memory bandwidth and context-switch overhead for pass-through workloads. They are less useful when the application must parse, compress, encrypt, or otherwise transform every byte in userspace.
MSG_ZEROCOPY: copy avoidance with completions
Linux’s MSG_ZEROCOPY can avoid copying large userspace send buffers into the kernel for supported sockets. It is not a free flag:
- the socket must opt in with
SO_ZEROCOPY; - the application must keep each buffer immutable until a completion notification arrives on the socket error queue;
- page pinning and notifications add fixed overhead, so small writes can be slower;
- the kernel may fall back to copying and report that in the completion;
- a zero-copy completion means the buffer may be reused, not that the peer received the data.
The kernel documentation notes that the mechanism is generally useful only above roughly 10 KiB on the tested implementation. That is a starting point, not a universal threshold; hardware, kernel, and workload determine the break-even point.
Modern io_uring also exposes zero-copy send operations on supported kernels. The same lifetime rule remains: a user buffer cannot be mutated or freed until the relevant completion says it is safe.
Ownership is the hard part
Copies are convenient because they create independent lifetimes. Removing a copy couples the producer, consumer, and backing storage:
- a borrowed parser result cannot outlive its input;
- a shared slice retains its allocation;
- an outstanding kernel operation owns access to a buffer;
- a mapped view depends on the file remaining valid;
- a DMA/network path may require pages to stay pinned.
Rust can encode many of these rules with lifetimes and ownership transfer. Low-level APIs still require unsafe because the kernel retains raw addresses outside the compiler’s view. A sound wrapper must prevent buffer reuse until completion, including cancellation and error paths.
Measure the whole path
Copy avoidance is worthwhile only when copying is a meaningful portion of end-to-end cost. Benchmark with realistic buffer sizes, concurrency, NUMA placement, TLS, storage, and network devices. Record at least:
- throughput and tail latency;
- CPU cycles and instructions;
- bytes copied or memory bandwidth where tools expose it;
- page faults and pinned memory;
- system calls and context switches;
- queue depth and backpressure;
- retained heap memory for shared slices.
TLS can change the design because encryption usually transforms data. NIC or kernel TLS offload may recover a copy-avoiding path, but only with specific platform support.
A practical decision sequence
- Avoid accidental allocations with slices and iterators.
- Use
Cowwhen mutation is demonstrably rare. - Use shared immutable buffers when ownership crosses tasks or components.
- Consider mapping for large, mostly read-only files with a safe file-lifecycle protocol.
- Use
sendfile/splicefor pass-through file or pipe transfers. - Consider
MSG_ZEROCOPYor zero-copyio_uringsends only for large buffers and after measurement. - Preserve a copying fallback; portability and small-message performance often require it.
The useful question is not “Is this zero-copy?” It is “Which copy is avoided, which lifetime is now shared, and does the complete workload improve?”