Pipes and FIFOs

Pipes are the oldest IPC mechanism on Unix. When you type ls | grep foo | wc -l in a shell, three processes are connected by two pipes. This chapter covers unnamed pipes, named pipes (FIFOs), dup2 for I/O redirection, and building a mini shell pipeline.

pipe(): The Basics

pipe() creates two file descriptors: one for reading, one for writing. Data written to the write end comes out the read end, in order, like a one-way queue.

/* pipe_basic.c */
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main(void) {
    int fd[2];
    if (pipe(fd) == -1) {
        perror("pipe");
        return 1;
    }

    /* fd[0] = read end, fd[1] = write end */
    const char *msg = "Hello through a pipe!\n";
    write(fd[1], msg, strlen(msg));
    close(fd[1]);  /* close write end so read sees EOF */

    char buf[128];
    ssize_t n = read(fd[0], buf, sizeof(buf) - 1);
    buf[n] = '\0';
    printf("Read: %s", buf);
    close(fd[0]);

    return 0;
}
pipe() returns:

  fd[0] ----READ----<  KERNEL BUFFER  <----WRITE---- fd[1]
                       (4096-65536 bytes)

Parent-Child Communication

The real power of pipes comes with fork(). The parent and child share the pipe file descriptors.

/* pipe_fork.c */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

int main(void) {
    int fd[2];
    pipe(fd);

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        /* Child: write to pipe */
        close(fd[0]);  /* close unused read end */
        const char *msg = "Message from child\n";
        write(fd[1], msg, strlen(msg));
        close(fd[1]);
        _exit(0);
    } else {
        /* Parent: read from pipe */
        close(fd[1]);  /* close unused write end */
        char buf[128];
        ssize_t n = read(fd[0], buf, sizeof(buf) - 1);
        buf[n] = '\0';
        printf("Parent received: %s", buf);
        close(fd[0]);
        wait(NULL);
    }

    return 0;
}

Caution: Always close the unused ends of the pipe in each process. If the child does not close fd[0], and the parent does not close fd[1], read() may block forever because the kernel thinks a writer still exists.

The flow after fork:

Before fork:
  Process: fd[0]=read, fd[1]=write

After fork:
  Parent:  fd[0]=read,  fd[1]=CLOSE
  Child:   fd[0]=CLOSE, fd[1]=write

  Child writes --> kernel buffer --> Parent reads

dup2: Redirecting stdin/stdout

dup2(oldfd, newfd) makes newfd a copy of oldfd, closing newfd first if open. This is how shells redirect I/O.

/* dup2_example.c */
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void) {
    int fd[2];
    pipe(fd);

    pid_t pid = fork();
    if (pid == 0) {
        /* Child: redirect stdout to pipe write end */
        close(fd[0]);
        dup2(fd[1], STDOUT_FILENO);  /* stdout now writes to pipe */
        close(fd[1]);                /* original fd no longer needed */

        execlp("echo", "echo", "Hello from echo", NULL);
        _exit(1);
    }

    /* Parent: read from pipe */
    close(fd[1]);
    char buf[256];
    ssize_t n = read(fd[0], buf, sizeof(buf) - 1);
    buf[n] = '\0';
    printf("Captured: %s", buf);
    close(fd[0]);
    wait(NULL);

    return 0;
}

After dup2(fd[1], STDOUT_FILENO):

Before dup2:              After dup2:
  fd[1] -> pipe_write       fd[1] -> pipe_write (closed next)
  stdout -> terminal         stdout -> pipe_write

Implementing a Shell Pipeline

Let us implement ls -la /tmp | grep log | wc -l with pipes and fork.

/* pipeline.c -- ls -la /tmp | grep log | wc -l */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void) {
    int pipe1[2], pipe2[2];
    pipe(pipe1);
    pipe(pipe2);

    /* First child: ls -la /tmp */
    pid_t p1 = fork();
    if (p1 == 0) {
        close(pipe1[0]);
        close(pipe2[0]);
        close(pipe2[1]);
        dup2(pipe1[1], STDOUT_FILENO);
        close(pipe1[1]);
        execlp("ls", "ls", "-la", "/tmp", NULL);
        _exit(1);
    }

    /* Second child: grep log */
    pid_t p2 = fork();
    if (p2 == 0) {
        close(pipe1[1]);
        close(pipe2[0]);
        dup2(pipe1[0], STDIN_FILENO);
        close(pipe1[0]);
        dup2(pipe2[1], STDOUT_FILENO);
        close(pipe2[1]);
        execlp("grep", "grep", "log", NULL);
        _exit(1);
    }

    /* Third child: wc -l */
    pid_t p3 = fork();
    if (p3 == 0) {
        close(pipe1[0]);
        close(pipe1[1]);
        close(pipe2[1]);
        dup2(pipe2[0], STDIN_FILENO);
        close(pipe2[0]);
        execlp("wc", "wc", "-l", NULL);
        _exit(1);
    }

    /* Parent: close all pipe ends and wait */
    close(pipe1[0]);
    close(pipe1[1]);
    close(pipe2[0]);
    close(pipe2[1]);
    wait(NULL);
    wait(NULL);
    wait(NULL);

    return 0;
}

The data flow:

ls -la /tmp --pipe1--> grep log --pipe2--> wc -l --> stdout

Try It: Modify the pipeline to run cat /etc/passwd | grep root | head -1. Remember to create two pipes and three child processes.

Pipe Capacity and Blocking

Linux pipes have a default capacity of 65536 bytes (16 pages). You can query and change it with fcntl:

/* pipe_capacity.c */
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main(void) {
    int fd[2];
    pipe(fd);

    int capacity = fcntl(fd[0], F_GETPIPE_SZ);
    printf("Default pipe capacity: %d bytes\n", capacity);

    /* Increase capacity (requires CAP_SYS_RESOURCE for > 1MB) */
    fcntl(fd[0], F_SETPIPE_SZ, 1048576);
    capacity = fcntl(fd[0], F_GETPIPE_SZ);
    printf("New pipe capacity: %d bytes\n", capacity);

    close(fd[0]);
    close(fd[1]);
    return 0;
}

Blocking behavior:

  • write() to a full pipe blocks until space is available (or returns EAGAIN if O_NONBLOCK is set).
  • read() from an empty pipe blocks until data arrives.
  • read() returns 0 when all write ends are closed (EOF).
  • write() to a pipe with no readers sends SIGPIPE to the writer.

Caution: SIGPIPE kills the process by default. In servers, set signal(SIGPIPE, SIG_IGN) and check the return value of write() for EPIPE instead.

Named Pipes (FIFOs)

Unnamed pipes only work between related processes (parent-child). FIFOs are special files on the filesystem that unrelated processes can open.

/* fifo_writer.c */
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <string.h>

int main(void) {
    const char *fifo_path = "/tmp/myfifo";
    mkfifo(fifo_path, 0666);   /* create the FIFO */

    printf("Opening FIFO for writing (blocks until a reader opens)...\n");
    int fd = open(fifo_path, O_WRONLY);
    const char *msg = "Hello through a FIFO!\n";
    write(fd, msg, strlen(msg));
    close(fd);

    return 0;
}
/* fifo_reader.c */
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main(void) {
    const char *fifo_path = "/tmp/myfifo";

    printf("Opening FIFO for reading (blocks until a writer opens)...\n");
    int fd = open(fifo_path, O_RDONLY);

    char buf[256];
    ssize_t n = read(fd, buf, sizeof(buf) - 1);
    buf[n] = '\0';
    printf("Received: %s", buf);
    close(fd);

    unlink(fifo_path);  /* clean up */
    return 0;
}

Run the writer and reader in two terminals. The open() call blocks until both ends are connected.

Terminal 1: ./fifo_writer     Terminal 2: ./fifo_reader
  "Opening FIFO..."            "Opening FIFO..."
  (blocks)                     (connects)
  (writes, exits)              "Received: Hello through a FIFO!"

Rust: Pipes with std::process

Rust's standard library makes pipe-based communication easy through Command and Stdio:

// rust_pipe.rs
use std::process::{Command, Stdio};
use std::io::Read;

fn main() {
    let mut child = Command::new("echo")
        .arg("Hello from echo")
        .stdout(Stdio::piped())
        .spawn()
        .expect("Failed to spawn echo");

    let mut output = String::new();
    child.stdout.take().unwrap().read_to_string(&mut output).unwrap();
    child.wait().unwrap();

    println!("Captured: {}", output.trim());
}

Rust: Shell Pipeline

// rust_pipeline.rs
use std::process::{Command, Stdio};
use std::io::Read;

fn main() {
    // ls -la /tmp | grep log | wc -l
    let ls = Command::new("ls")
        .args(["-la", "/tmp"])
        .stdout(Stdio::piped())
        .spawn()
        .expect("Failed to start ls");

    let grep = Command::new("grep")
        .arg("log")
        .stdin(Stdio::from(ls.stdout.unwrap()))
        .stdout(Stdio::piped())
        .spawn()
        .expect("Failed to start grep");

    let mut wc = Command::new("wc")
        .arg("-l")
        .stdin(Stdio::from(grep.stdout.unwrap()))
        .stdout(Stdio::piped())
        .spawn()
        .expect("Failed to start wc");

    let mut output = String::new();
    wc.stdout.take().unwrap().read_to_string(&mut output).unwrap();
    wc.wait().unwrap();

    println!("Lines matching 'log': {}", output.trim());
}

Rust Note: Stdio::from() transfers ownership of the pipe file descriptor. Rust's type system ensures you cannot accidentally use the same stdout twice. In C, you manually close file descriptors and hope you did not make a mistake.

Rust: Low-Level Pipes with nix

For direct pipe() and dup2() access:

// rust_nix_pipe.rs
// Cargo.toml: nix = { version = "0.29", features = ["process", "unistd"] }
use nix::unistd::{pipe, fork, ForkResult, write, read, close, dup2};
use std::os::fd::AsRawFd;

fn main() {
    let (read_fd, write_fd) = pipe().expect("pipe failed");

    match unsafe { fork() }.expect("fork failed") {
        ForkResult::Child => {
            close(read_fd.as_raw_fd()).ok();
            let msg = b"Hello from child via nix\n";
            write(&write_fd, msg).unwrap();
            close(write_fd.as_raw_fd()).ok();
            std::process::exit(0);
        }
        ForkResult::Parent { child: _ } => {
            close(write_fd.as_raw_fd()).ok();
            let mut buf = [0u8; 128];
            let n = read(read_fd.as_raw_fd(), &mut buf).unwrap();
            close(read_fd.as_raw_fd()).ok();
            print!("Parent got: {}", std::str::from_utf8(&buf[..n]).unwrap());
            nix::sys::wait::wait().ok();
        }
    }
}

Driver Prep: Linux kernel modules use struct pipe_inode_info internally. The concept of pipes extends to kernel-space communication: relay channels and trace_pipe use the same ring-buffer idea for high-throughput kernel-to-user data transfer.

Knowledge Check

  1. What happens if you write() to a pipe whose read end has been closed by all processes?
  2. Why must you close unused pipe ends after fork()?
  3. What is the difference between an unnamed pipe and a FIFO?

Common Pitfalls

  • Not closing unused pipe ends -- leads to deadlock because read() never sees EOF.
  • Forgetting to handle SIGPIPE -- a write to a pipe with no readers kills the process.
  • Using pipes for large data transfers -- pipes are limited to kernel buffer size. For bulk data, use shared memory or files.
  • Opening a FIFO with O_RDWR -- technically works but defeats the blocking semantics and is not portable.
  • Race condition with mkfifo -- if the file already exists, mkfifo returns EEXIST. Check or use unlink first.
  • Assuming pipe writes are atomic -- writes up to PIPE_BUF (4096 on Linux) are atomic. Larger writes may be interleaved with other writers.