Linux splice() can transfer bytes between file descriptors without routing those bytes through a user-space buffer, but the interface is not a generic descriptor-to-descriptor copy primitive. At least one endpoint must be a pipe. That requirement makes pipe state part of the transfer contract: capacity, readable data, writer presence, blocking mode, and partial progress can all affect an otherwise straightforward data path.
The useful boundary is therefore not simply “kernel copy versus user copy.” splice() changes the shape of ownership and flow control. Application code stops owning an intermediate byte array, while it still owns the control loop that accounts for bytes transferred, handles readiness, and preserves offset semantics.
A pipe is an explicit transfer endpoint
The system call accepts an input descriptor, an output descriptor, a maximum byte count, optional offsets, and flags. One or both descriptors may refer to pipes, but a call with no pipe endpoint is invalid.
A file-to-socket transfer therefore commonly uses two operations with a pipe between them:
file -> splice -> pipe -> splice -> socketThe intermediate pipe is not merely syntax required by the API. It is a bounded kernel object with its own readable and writable state. The first operation can stop because the pipe cannot accept more data. The second can stop because the destination cannot currently accept the full amount. Progress across the complete path must be tracked across both boundaries.
This differs from an application buffer in one important control-plane sense. With read() followed by write(), the process owns a memory region containing the bytes between the calls. With splice(), those bytes can remain represented by kernel-managed pipe buffers, so application logic tracks quantities and descriptor state rather than directly inspecting the payload.
Successful calls report progress, not completion of intent
A positive return value is the number of bytes transferred by that call. It can be smaller than the requested count. Correct control logic therefore treats the requested size as an upper bound rather than a completion guarantee.
Suppose a relay wants to move 256 KiB from a file into a pipe. A call requesting that amount may transfer less. The next stage must consume only the amount actually placed into the pipe, and the outer operation must retain the remaining logical byte count.
ssize_t n = splice(in_fd, &off, pipefd[1], NULL, wanted, 0);
if (n > 0) {
ssize_t pending = n;
while (pending > 0) {
ssize_t m = splice(pipefd[0], NULL, out_fd, NULL,
(size_t)pending, 0);
if (m > 0) {
pending -= m;
continue;
}
/* handle EOF, retryable state, or error */
}
}The fragment only illustrates accounting. Production code must define its response to interrupted calls, nonblocking results, destination failure, shutdown, and cancellation. The important invariant is that bytes accepted into the pipe remain pending output until the downstream stage accounts for them.
A zero return has endpoint-specific meaning. For a non-pipe input it indicates end of input. For a pipe input, zero indicates no data remains and no writers are connected, so waiting cannot produce more bytes from that pipe.
Offset pointers separate transfer position from descriptor position
For a non-pipe input, a null offset pointer makes splice() use and advance the descriptor’s file offset. A non-null pointer instead supplies an explicit position; the pointed-to value advances while the descriptor’s own file offset remains unchanged. The output side has analogous rules when it is seekable.
This distinction becomes significant when a descriptor is shared. Using the descriptor position makes transfer progress participate in the state of the open file description. Another operation that uses the same position can interact with that state. An explicit offset keeps the splice operation’s position in caller-owned storage instead.
Pipe descriptors are different: their offset argument must be null because pipes are not seekable byte-addressed files. A pipeline that mixes files and pipes therefore has asymmetric position semantics even though every endpoint is represented by an integer file descriptor.
That asymmetry is useful during API review. “Both parameters are file descriptors” does not imply that both expose the same operations or state. Descriptor type remains part of the contract.
Nonblocking mode applies at more than one boundary
SPLICE_F_NONBLOCK requests nonblocking behavior for splice pipe operations. It does not establish that every underlying endpoint is incapable of blocking. The Linux interface explicitly separates the splice flag from the O_NONBLOCK state of the descriptors involved.
This matters in event-driven code. A relay can observe that a pipe is writable and still need to account for the source side’s behavior. Conversely, readable data in a pipe does not mean a socket destination can accept the entire pending amount.
When an operation would block under the applicable nonblocking conditions, EAGAIN is a flow-control result rather than evidence of corrupted data. The control loop must preserve its pending-byte accounting and resume only when the relevant endpoint state permits more progress.
Pipe capacity also prevents an upstream producer from advancing indefinitely while the downstream side stalls. Once the bounded pipe cannot accept more data, the upstream splice stops making progress. Backpressure is therefore represented directly by the intermediate kernel object rather than by an unbounded application queue.
Avoiding a user-space copy is not a universal zero-copy guarantee
The documented contract states that splice() moves data without copying between kernel address space and user address space. That statement is narrower than a promise that no data copy occurs anywhere in the kernel or device path.
Kernel implementations may represent pipe buffers using references to pages and can sometimes avoid copying payload bytes when connecting supported producers and consumers. The exact path depends on endpoint types and kernel implementation. Filesystems may also reject splicing for particular operations.
SPLICE_F_MOVE does not turn this implementation opportunity into a portable guarantee. The flag is a hint, and current documented behavior does not permit application correctness to depend on physical page movement. A design that requires semantic correctness must therefore rely on transferred byte counts and API-visible errors, not on an assumed internal copy count.
This boundary also separates splice() from performance claims. Eliminating a user-space transfer buffer can reduce some copying and memory traffic on suitable paths, but actual throughput and CPU cost depend on the kernel, filesystem, socket path, workload, and surrounding control logic. The syscall contract supplies semantics, not a workload-independent speedup.
Pipe-buffer ownership changes observability
A conventional read() into application memory creates an inspection point. The process can parse, hash, transform, log, or retain the bytes before writing them onward. A splice path intentionally removes that ordinary inspection point.
That property is useful for opaque relays, but it is also a constraint. If the application must transform framing, calculate a digest over payload bytes, inspect protocol fields, or retain an exact copy in user memory, a pure splice path no longer supplies the data representation those operations require.
The choice is therefore architectural rather than cosmetic. Keeping data in kernel-managed buffers can simplify an opaque forwarding path, while payload-aware processing requires another interface boundary. Mixing both models often means deliberately diverting selected data through user space rather than treating splice() as a universal replacement for read() and write().
The control loop remains the owner of transfer correctness
splice() moves the byte-storage boundary into the kernel, but it does not move transaction semantics there. The kernel reports local progress between endpoints. It does not know whether a higher-level message is complete, whether a downstream peer has durably processed data, or whether a partially forwarded stream should be retried after another component fails.
That leaves several obligations with the caller: maintain exact byte counts, distinguish end-of-input from retryable lack of progress, coordinate readiness for bounded pipes and downstream endpoints, preserve intended file-position semantics, and define failure behavior after bytes have entered the intermediate pipe.
The resulting design boundary is precise. splice() can remove a user-space payload buffer from supported Linux data paths, while pipe capacity and descriptor state become first-class parts of the transfer mechanism. The payload may remain outside application memory, but progress, ordering, cancellation, and higher-level completion remain application-owned state.