2026-07-11
You know splice() moves data between file descriptors without copying it into user space — the kernel just re-points pipe buffer references. tee() is its lesser-known sibling: it duplicates data between two pipes without consuming the source. After tee(in, out, N, 0), both pipes still contain the N bytes, and neither made a copy — the kernel bumped the refcount on the underlying pipe buffer pages.
The signature: ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags). Both fds must be pipes (this is the constraint that trips people up). The kernel walks the pipe_buffer ring on the input side, and for each buffer it wants to forward, it calls the buffer's ->get() op — which for anonymous pipe pages just increments a page refcount — then links the same struct page* into a new pipe_buffer on the output side.
The zero-copy tee pattern: log every byte flowing through a proxy without touching it.
splice() → pipe Atee(pipeA, pipeB, len, 0) — pipe B now sees the same bytes, no copysplice() pipe A → upstream socket (the real forward path)splice() pipe B → log file (the audit copy)Both consumers drain independently. The pages get freed when the last pipe_buffer referencing them is released. Zero user-space memory touched the payload, zero read()/write() calls, zero cache pollution from an application-level buffer.
The gotcha: tee() is bounded by whichever pipe has less headroom. Default pipe capacity is 16 pages = 64KB. If your audit consumer (pipe B → log) falls behind, pipe B fills up, tee() returns short or blocks, and now your forward path stalls too — because you can't advance pipe A's read pointer until you've teed those bytes off. Bump both pipes with fcntl(fd, F_SETPIPE_SZ, 1<<20) up to /proc/sys/fs/pipe-max-size.
Rule of thumb: if you're calling read() into a buffer just to write() the same bytes to two destinations, you're paying ~2 syscalls + one memcpy per KB. The splice+tee+splice chain is 3 syscalls total for the whole transfer window, and the memcpy count is zero. At 10 Gbps, that's the difference between saturating the wire and eating a core on memory bandwidth.
The name comes from the Unix tee(1) command, and tee(1) on Linux uses this syscall when its inputs and outputs are both pipes — which is why cmd | tee >(logger) | next is genuinely zero-copy on modern kernels.
