student@ubuntu:~$
systems-programming Lesson 17 10 min read

Pipes & Signals

Connecting processes with pipes and handling interrupts with signals

Reading: Supplemental (not in textbook)

Quick check before you start: In the shell, ls | wc -l counts the number of files. How does the shell connect those two programs? If you are not sure, read on.

Practice this topic: Pipes & Signals skill drill

After this lesson, you will be able to:

  • Create a pipe with pipe(fd) and explain which end is read vs. write
  • Use dup2 to redirect stdin/stdout through a pipe
  • Build ls | wc -l in C
  • Write a basic signal handler for SIGINT

Creating a Pipe

A pipe is a one-way communication channel between two processes. The pipe() system call creates it:

int fd[2];
pipe(fd);

After this call:

  • fd[0] is the read end
  • fd[1] is the write end

Data written to fd[1] comes out of fd[0]. Think of it as a physical pipe — water goes in one end and comes out the other.

Connecting Two Processes

The pattern for piping one program’s output into another:

  1. Create the pipe
  2. Fork
  3. In the child: close the read end, redirect stdout to the write end
  4. In the parent: close the write end, redirect stdin to the read end
int fd[2];
pipe(fd);

pid_t pid = fork();
if (pid == 0) {
    // Child: will write to pipe
    close(fd[0]);              // close read end
    dup2(fd[1], STDOUT_FILENO); // stdout → pipe write end
    close(fd[1]);              // close original write fd
    execlp("ls", "ls", NULL);
    perror("execlp");
    exit(EXIT_FAILURE);
}

dup2: Redirecting File Descriptors

dup2(old_fd, new_fd) makes new_fd point to the same place as old_fd. After dup2(fd[1], STDOUT_FILENO), anything the child writes to stdout goes into the pipe instead of the terminal.

Building ls | wc -l in C

Here is the complete program:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

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

    pid_t pid1 = fork();
    if (pid1 == 0) {
        // Child 1: ls (writes to pipe)
        close(fd[0]);
        dup2(fd[1], STDOUT_FILENO);
        close(fd[1]);
        execlp("ls", "ls", NULL);
        perror("ls");
        exit(EXIT_FAILURE);
    }

    pid_t pid2 = fork();
    if (pid2 == 0) {
        // Child 2: wc -l (reads from pipe)
        close(fd[1]);
        dup2(fd[0], STDIN_FILENO);
        close(fd[0]);
        execlp("wc", "wc", "-l", NULL);
        perror("wc");
        exit(EXIT_FAILURE);
    }

    // Parent: close both ends and wait
    close(fd[0]);
    close(fd[1]);
    wait(NULL);
    wait(NULL);

    return 0;
}

The parent closes both pipe ends because it does not use the pipe. Then it waits for both children. Closing unused pipe ends is essential — if the read end stays open in a process that is not reading, the writer may block forever.

Closing the Right End

Rule: each process closes the pipe end it does not use. If you forget:

  • Leaving the write end open in the reader means the reader never sees EOF
  • Leaving the read end open in the writer wastes a file descriptor

Signals

A signal is an asynchronous notification sent to a process. The most common one is SIGINT — sent when you press Ctrl+C.

By default, SIGINT terminates the program. You can install a custom handler:

#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

void handle_sigint(int sig) {
    (void)sig;  // suppress unused warning
    const char msg[] = "\nCaught SIGINT — cleaning up\n";
    write(STDOUT_FILENO, msg, sizeof(msg) - 1);
    // free resources, close files, etc.
    _exit(0);
}

int main(void) {
    signal(SIGINT, handle_sigint);

    printf("Running... press Ctrl+C to stop\n");
    while (1) {
        // work
    }
    return 0;
}

When the user presses Ctrl+C, the kernel delivers SIGINT to the process. Instead of dying, the process runs handle_sigint, which can clean up resources before exiting.

Why write() instead of printf()? A signal can arrive while printf() is in the middle of executing and holding an internal lock on stdout. If the handler calls printf() too, it tries to acquire the same lock, causing a deadlock. The write() system call is async-signal-safe — it does not use internal locks, so it is always safe to call from a handler. Similarly, _exit() is safe where exit() is not, because exit() flushes stdio buffers and runs atexit handlers that may themselves use non-signal-safe functions.

Common signals:

Signal Default Triggered by
SIGINT Terminate Ctrl+C
SIGTERM Terminate kill command
SIGSEGV Core dump Invalid memory access
SIGPIPE Terminate Writing to a broken pipe
SIGKILL Terminate kill -9 (cannot be caught or ignored)

Check Your Understanding
In a pipe created with pipe(fd), which file descriptor does the reading process close?
Afd[0] — the read end
Bfd[1] — the write end
CBoth ends
DNeither end
Answer: B. Each process closes the pipe end it does not use. The reading process uses fd[0] to read, so it closes fd[1] (the write end). If it kept the write end open, the pipe would never signal EOF because there would still be a potential writer.

What Comes Next

You have covered the full systems programming toolkit: processes, pipes, and signals. In the final lesson, you will step back and see how everything in this course connects — from Java to C, from the shell to the kernel.

Next: Course Synthesis