Pipes: A Kernel Buffer Between Two Processes
A pipe is a small kernel-owned ring buffer with a write end and a read end; the kernel blocks the writer when it is full and the reader when it is empty, turns the last close into EOF, and that is enough to build every shell pipeline and every subprocess.PIPE.
The problem
What `pipe()` gives you
pipe(fds) asks the kernel for a buffer and returns two descriptors in the caller’s File Descriptors table: fds[0] reads from the buffer, fds[1] writes to it. The buffer is unidirectional, byte-oriented and lives in kernel memory; on Linux its default capacity is 16 pages, 64 kB, adjustable per pipe with fcntl(F_SETPIPE_SZ) up to /proc/sys/fs/pipe-max-size (1 MB by default). Bytes written appear to the reader in order and exactly once.
The two descriptors are ordinary descriptors. They survive fork(), which is the whole trick: a parent creates the pipe, forks, and now both processes hold both ends. The parent closes the end it does not need, the child closes the other, and the two ends are in different processes. Nothing about the pipe was ever “sent” anywhere — the descriptor table was copied (Creating Processes: fork, exec, wait).
That mechanism also explains the limitation. Because the ends are shared only by inheritance, an anonymous pipe can only connect a process with its ancestor, descendant or sibling. Two unrelated processes need a named pipe — a FIFO — which is the same kernel buffer given a path in the file system (mkfifo /tmp/events), so any process with permission can open() an end.
1int fds[2];2pipe(fds); // fds[0] = read end, fds[1] = write end3pid_t pid = fork();4if (pid == 0) { // child: producer5 close(fds[0]); // not reading6 dup2(fds[1], STDOUT_FILENO); // stdout now goes into the pipe7 close(fds[1]);8 execlp("cat", "cat", "big.log", nullptr);9}10close(fds[1]); // parent: consumer — MUST close its write end,11char buf[4096]; // or read() never sees EOF12ssize_t n;13while ((n = read(fds[0], buf, sizeof buf)) > 0) { /* consume */ }14// n == 0: every write end is closed → EOFFull, empty, closed: the three blocking rules
The pipe is a producer/consumer (Semaphores and Condition Variables) queue implemented by the kernel, and its rules are the classic ones. A write() into a full pipe blocks until the reader drains enough space (with O_NONBLOCK it returns EAGAIN instead). A read() on an empty pipe blocks until a writer adds bytes. Blocking is how a 4 GB cat and a slow grep coexist in 64 kB of memory: cat is asleep in write() almost all the time, and the kernel wakes it each time grep frees space. That is backpressure with no code — the same idea as TCP’s receive window (Flow Control: The Receive Window, What Happens When the Receiver Is Slow).
The third rule is about closing. When every write end is closed, read() returns 0 — EOF — after the buffer is drained. This is the signal that ends every pipeline, and it is why the parent in the code above must close its own copy of the write end: if it does not, the pipe has a live writer (the parent itself) forever, and read() blocks forever. The most common pipe bug in the world is a leaked write end.
The mirror case: writing when every read end is closed. The kernel sends the writer SIGPIPE (Signals: Asynchronous Notifications From the Kernel), whose default action terminates the process; if SIGPIPE is ignored, write() fails with EPIPE. This is why yes | head -1 terminates: head exits after one line, its read end closes, and yes dies of SIGPIPE on the next write. Servers ignore SIGPIPE for exactly this reason — a client disconnecting must not kill the server.
- Writes of at most
PIPE_BUFbytes (4096 on Linux) are atomic: they are not interleaved with other writers. Larger writes may be split. O_NONBLOCKplus I/O Multiplexing: select, poll, epoll, kqueue, IOCP turns a pipe into just another readable descriptor for an event loop.- A pipe has no size in the file-system sense and no seek;
lseek()returnsESPIPE— the errno is named after this.
The shell pipeline, fully expanded
cat access.log | grep 500 | sort is three processes and two pipes. The shell calls pipe() twice, fork()s three children, and in each child uses dup2() to wire descriptor 1 (stdout) or 0 (stdin) to the right pipe end before execing the program. cat writes into pipe 1; grep reads pipe 1 and writes pipe 2; sort reads pipe 2 and writes the terminal. The three run concurrently: grep is filtering while cat is still reading the file. Only sort has to wait for EOF, because it cannot emit anything until it has seen the last line.
None of the three programs knows it is in a pipeline. cat writes to descriptor 1 and grep reads descriptor 0 exactly as if they were a terminal or a file — this is the payoff of Everything Is I/O. It also means a program that buffers stdout differently when it is not a terminal (C stdio switches from line-buffered to fully-buffered; Python does the same) will appear to “hang” in a pipeline until its 4–8 kB buffer fills. stdbuf -oL, python -u and PYTHONUNBUFFERED=1 exist for this.
- shell: pipe() ×2, fork() ×3, dup2(), exec()wiring happens before exec; the programs never see it↓
- cat → fd 1 → pipe #1 (64 kB)blocks in write() when grep is slower↓
- grep: fd 0 ← pipe #1, fd 1 → pipe #2runs concurrently with cat↓
- sort: fd 0 ← pipe #2must see EOF (all writers closed) before it can output↓
- exit status: last command’s, unless `set -o pipefail`a failing cat is invisible by default
Pipes behind `subprocess.PIPE` and `spawn`
Python’s subprocess.Popen(cmd, stdout=PIPE, stderr=PIPE) calls os.pipe() for each requested stream, forks, and wires the child’s descriptors with dup2 exactly as the shell does. Node’s child_process.spawn(cmd, { stdio: 'pipe' }) does the same through libuv (which on Unix actually uses a Unix-domain socket pair for the stdio channels — bidirectional, but the blocking rules are the same). In both, the object you get back exposes the *parent’s* ends of the buffers.
The rules from the previous section apply directly and produce a famous deadlock. Capture both stdout and stderr, then read stdout to EOF, then read stderr: if the child writes more than 64 kB to stderr *before* finishing stdout, it blocks in write() on the stderr pipe, never reaches EOF on stdout, and the parent blocks in read() on stdout. Both wait forever. Python’s Popen.communicate() exists to read both pipes concurrently (with threads or select) precisely to avoid this; Node avoids it because spawn reads both streams from the event loop as data arrives. The lesson: whoever reads a pipe must keep reading it, or the writer stalls.
1import subprocess2p = subprocess.Popen(["make", "-j8"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)3 4# WRONG: if make emits > 64 kB on stderr before finishing, it blocks in write()5# while we block in read() on stdout. Nobody wakes up.6# out = p.stdout.read(); err = p.stderr.read()7 8# RIGHT: read both concurrently until EOF9out, err = p.communicate()10print(p.returncode)Key points
- A pipe is a kernel ring buffer (64 kB by default on Linux) with a read descriptor and a write descriptor; it is unidirectional and byte-oriented.
- Ends are shared by
fork()inheritance; unrelated processes need a FIFO (named pipe) with a path. - Writer blocks when full, reader blocks when empty: backpressure for free.
- EOF is “all write ends closed”. A leaked write end is the classic pipe hang.
- Writing with no readers raises
SIGPIPE(orEPIPEif ignored); servers must ignoreSIGPIPE. - A shell pipeline is N processes and N−1 pipes running concurrently;
subprocess.PIPEandspawnbuild the same thing.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why does the kernel own the buffer instead of one of the processes?
Because neither process can be trusted with the other’s memory. A kernel buffer lets the two stay isolated and lets the kernel enforce the blocking rules that make speed mismatches harmless.
▸Why block instead of growing the buffer?
A growing buffer converts a slow consumer into unbounded memory in the kernel. A fixed buffer converts it into a sleeping producer, which costs nothing. Every flow-control design makes this choice.
▸Why is EOF tied to closing rather than an explicit “done” message?
Because a process that crashes never sends “done”, but the kernel always closes its descriptors. Tying EOF to the last close makes crashes terminate pipelines correctly for free.
Pipe simulator
How it fails
What the failure looks like from inside real software.
- Parent forgets to close its copy of the write end after
fork(): the child exits, butread()never returns 0 and the parent hangs forever. - Reading stdout to EOF before touching stderr in a subprocess: the child blocks writing stderr, the parent blocks reading stdout — a two-process deadlock with no lock in sight.
- A server that did not ignore
SIGPIPEdies the moment a client disconnects mid-response; the log shows nothing because the process was killed by a signal (exit status 141). - A program appears to hang in a pipeline because stdio switched to full buffering when stdout stopped being a terminal — output appears only at exit.
- Treating pipe data as messages: the reader gets a partial line and parses garbage; frame with newlines or length prefixes.