Systems programming · lesson 07

Pipes and redirection

When you type <code>ls | grep foo</code>, the shell creates a pipe — a kernel buffer with two file descriptors — and wires <code>ls</code>'s stdout to <code>grep</code>'s stdin. No temporary files, no shared memory. Just fds.

in progress
12 min

What a pipe is

A pipe is a unidirectional byte stream maintained by the kernel, with two ends: a write end and a read end. Bytes written to the write end can be read from the read end in FIFO order. The kernel buffers the data between writes and reads. If the buffer is full, writes block; if it's empty, reads block.

pipe(int pipefd[2]) creates a pipe and fills pipefd[0] with the read-end fd and pipefd[1] with the write-end fd. Both are ordinary file descriptors — you use read and write on them exactly as with files.

c
#include <unistd.h> int pipefd[2]; pipe(pipefd); // pipefd[0] = read end, pipefd[1] = write end write(pipefd[1], "hello", 5); char buf[16]; ssize_t n = read(pipefd[0], buf, sizeof(buf)); // buf now contains "hello", n == 5

The fork-pipe-exec pattern

Pipes are most useful between processes. The pattern is: create the pipe, fork, each process closes the end it doesn't need, then exec. The child inherits both ends; closing the unused end is mandatory — if any process holds the write end open, reads on the read end will block forever waiting for more data rather than seeing EOF.

c
// implement: ls | grep foo int pipefd[2]; pipe(pipefd); pid_t pid1 = fork(); if (pid1 == 0) { // child 1 (ls): wire stdout to write end dup2(pipefd[1], 1); close(pipefd[0]); close(pipefd[1]); execlp("ls", "ls", NULL); } pid_t pid2 = fork(); if (pid2 == 0) { // child 2 (grep): wire stdin to read end dup2(pipefd[0], 0); close(pipefd[0]); close(pipefd[1]); execlp("grep", "grep", "foo", NULL); } // parent: close both ends, wait for both children close(pipefd[0]); close(pipefd[1]); waitpid(pid1, NULL, 0); waitpid(pid2, NULL, 0);
🚫
Always close unused pipe ends. If the parent holds the write end open after forking, grep will never see EOF on its stdin — it blocks forever waiting for more input that never comes. Every process that inherits a pipe fd must close the end it doesn't use.

EOF semantics

A read on a pipe returns 0 (EOF) when all write ends of the pipe are closed and the buffer is empty. This is how commands know the upstream is done: when ls exits, the write end closes, grep's read returns 0, and grep exits.

SIGPIPE

If you write to a pipe whose read end has no open file descriptors (all readers have closed or exited), the kernel sends SIGPIPE to the writing process. The default action is termination. This is why a command producing large output to a pipe exits cleanly when the reader exits early — rather than running to completion writing into nothing.

💡
Named pipes (FIFOs) work across unrelated processes. mkfifo("myfifo", 0644) creates a pipe-like file in the filesystem. Any two processes can open it by name and communicate through it. Anonymous pipes (from pipe()) only work between related processes (parent/child sharing the fd via fork).
one-line takeaway

A pipe is a kernel buffer with two fds — connect two processes by forking, dup2-ing the ends to stdin/stdout, and closing whatever you don't use.