systems-programming Lesson 2 22 min read

How Do Pipes and Signals Enable Inter-Process Communication?

pipe(), signal(), kill() — how processes talk to each other and respond to events

Reading: Linux Text: Ch. 10 §5 (pp. 140–142, Signals), Ch. 16 §1–3 (pp. 213–226, IPC)

After this lesson, you will be able to:

  • Create a pipe with pipe(fd) and identify read (fd[0]) and write (fd[1]) ends
  • Use dup2 to redirect stdin/stdout to pipe ends
  • Implement ls | wc -l in C using pipe, fork, dup2, and execlp
  • Register a signal handler with signal(SIGINT, handler_fn) for Ctrl+C
  • Identify which signals are catchable (SIGINT, SIGTERM) vs. uncatchable (SIGKILL)

The Shell’s Pipe, From Scratch

When you type ls | wc -l, the shell creates two processes and connects them with a pipe — the output of ls flows directly into the input of wc. You’ve been using this since Week 2. Now you’ll build it yourself.


Pipes: Connecting Processes

How pipe() Works

int fd[2];
pipe(fd);
// fd[0] = read end
// fd[1] = write end

pipe() creates a one-way communication channel. Data written to fd[1] can be read from fd[0]. Think of it as a tube: write into one end, read from the other.

Building ls | wc -l

#include <stdio.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
        close(fd[0]);               // Close unused read end
        dup2(fd[1], STDOUT_FILENO); // Redirect stdout to pipe write end
        close(fd[1]);               // Close original write end (dup'd)
        execlp("ls", "ls", NULL);
        perror("ls failed");
        _exit(1);
    }

    pid_t pid2 = fork();
    if (pid2 == 0)
    {
        // Child 2: wc -l
        close(fd[1]);               // Close unused write end
        dup2(fd[0], STDIN_FILENO);  // Redirect stdin to pipe read end
        close(fd[0]);               // Close original read end (dup'd)
        execlp("wc", "wc", "-l", NULL);
        perror("wc failed");
        _exit(1);
    }

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

    return 0;
}

Key Insight: dup2(fd[1], STDOUT_FILENO) rewires stdout to point to the pipe’s write end. After this, anything the child writes to stdout goes into the pipe. The second child does the reverse — dup2(fd[0], STDIN_FILENO) makes it read from the pipe. This is what the shell does behind the scenes.

Check Your Understanding
In the ls | wc -l implementation, why does the first child (running ls) call close(fd[0])?
A Because the writer doesn't need the read end, and leaving it open wastes a file descriptor
B Because fd[0] was already closed by the parent
C To prevent ls from reading its own output
D To signal to wc that data is ready
Answer: A. Each process inherits both ends of the pipe from the fork. The writer (ls) only needs fd[1] (write end). Closing fd[0] is good practice — it frees the file descriptor and, more importantly, ensures the read end has the correct number of open write ends for EOF detection.
Deep dive: The complete recipe — how the shell implements |

Every time you type cmd1 | cmd2 in the shell, this exact sequence happens:

  1. pipe(fd) — creates the pipe (two file descriptors: read end and write end)
  2. fork() — creates child 1 (will run cmd1)
  3. Child 1: dup2(fd[1], STDOUT_FILENO) — redirects its stdout to the pipe’s write end
  4. Child 1: closes both original fd[0] and fd[1] — no longer needed after dup2
  5. Child 1: execlp(cmd1, ...) — replaces itself with cmd1, which writes to “stdout” (actually the pipe)
  6. fork() — creates child 2 (will run cmd2)
  7. Child 2: dup2(fd[0], STDIN_FILENO) — redirects its stdin to the pipe’s read end
  8. Child 2: closes both original fd[0] and fd[1]
  9. Child 2: execlp(cmd2, ...) — replaces itself with cmd2, which reads from “stdin” (actually the pipe)
  10. Parent: closes both fd[0] and fd[1] — critical for EOF detection
  11. Parent: wait() twice — waits for both children to finish

Neither cmd1 nor cmd2 knows it’s part of a pipeline — they just read from stdin and write to stdout as usual. The shell rewired their file descriptors before they started. This is the power of the Unix “everything is a file descriptor” model.

For longer pipelines like cmd1 | cmd2 | cmd3, the shell repeats this pattern: create a pipe for each |, fork each command, wire up the file descriptors, and wait for all children.

Close Unused Ends

Common Pitfall: You must close the unused ends of the pipe in each process. If the reader doesn’t close the write end, it will never see EOF (it thinks more data might come from itself). If the writer doesn’t close the read end, it wastes a file descriptor.

Check Your Understanding
What does dup2(fd[0], STDIN_FILENO) do?
A Copies data from fd[0] into standard input
B Makes file descriptor 0 (stdin) point to the same place as fd[0] (the pipe's read end)
C Closes standard input permanently
D Creates a new pipe connected to standard input
Answer: B. dup2 redirects one file descriptor to point where another one points. After this call, any read from stdin actually reads from the pipe. The process doesn't need to know it's reading from a pipe — it just reads from stdin as usual. This is the Unix mechanism that makes pipelines invisible to the programs inside them.
Why does this matter?

Pipes are the implementation behind the | operator you’ve been using since Week 2. Understanding how pipe(), fork(), and dup2() work together means understanding how the shell connects programs. This knowledge is essential for OS courses and for building any tool that coordinates multiple processes.


Signals: Responding to Events

What Are Signals?

Signals are asynchronous notifications sent to a process. You’ve used them from the shell:

  • Ctrl+C sends SIGINT
  • Ctrl+Z sends SIGTSTP
  • kill PID sends SIGTERM

Now you can handle them in code:

#include <signal.h>

void handle_sigint(int sig)
{
    printf("\nCaught SIGINT! Cleaning up...\n");
    _exit(0);
}

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

    printf("Running... Press Ctrl+C to stop.\n");
    while (1)
    {
        sleep(1);
    }

    return 0;
}

Common Signals

Signal Default Action Catchable? When Sent
SIGINT Terminate Yes Ctrl+C
SIGTERM Terminate Yes kill PID
SIGKILL Terminate No kill -9 PID
SIGSEGV Core dump Yes (rarely useful) Invalid memory access
SIGPIPE Terminate Yes Write to broken pipe
SIGCHLD Ignore Yes Child process exits

Sending Signals Programmatically

#include <signal.h>

kill(child_pid, SIGTERM);    // Send SIGTERM to a specific process
Check Your Understanding
Which signal cannot be caught or ignored by a process?
A SIGINT
B SIGTERM
C SIGSEGV
D SIGKILL
Answer: D. SIGKILL (signal 9) cannot be caught, blocked, or ignored — the kernel terminates the process immediately with no cleanup. SIGINT and SIGTERM can both be caught with signal() for graceful shutdown. Even SIGSEGV is technically catchable, though it's rarely useful. That's why kill -9 is the "last resort" — the process gets no chance to clean up.

From Java: Java has Thread.interrupt() and shutdown hooks (Runtime.addShutdownHook) for similar purposes. C’s signals are lower-level — they interrupt at any point, not at a safe checkpoint. Signal handlers must be careful about what functions they call.

Quick Check: What does dup2(fd[1], STDOUT_FILENO) do?

It makes file descriptor STDOUT_FILENO (standard output, fd 1) point to the same place as fd[1] (the pipe’s write end). After this, any write to stdout goes into the pipe instead of the terminal.

Quick Check: Why must you close unused pipe ends?

The read end of a pipe returns EOF only when all write ends are closed. If the reader keeps its write end open, it will block forever waiting for data that will never come. Each process should close the pipe end it doesn’t use.

Quick Check: Can a process catch SIGKILL?

No. SIGKILL (signal 9) cannot be caught, blocked, or ignored. It immediately terminates the process with no cleanup. That’s why it’s the last resort — use SIGTERM first to allow graceful shutdown.


Big Picture: Pipes and signals are the two main ways Unix processes communicate. Pipes carry data between processes (the plumbing of pipelines). Signals deliver asynchronous events (interrupts, termination requests, child exits). Together with fork and exec, they give you the complete toolkit for building programs that create, coordinate, and communicate with other programs — which is exactly what a shell does.


What’s Next

The final lesson brings everything together — Unix, C, Java, pointers, memory, processes — into a unified picture of how software systems work.