How Do Pipes and Signals Enable Inter-Process Communication?
pipe(), signal(), kill() — how processes talk to each other and respond to events
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
dup2to redirectstdin/stdoutto pipe ends - Implement
ls | wc -lin C usingpipe,fork,dup2, andexeclp - Register a signal handler with
signal(SIGINT, handler_fn)forCtrl+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.
ls | wc -l implementation, why does the first child (running ls) call close(fd[0])?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:
pipe(fd)— creates the pipe (two file descriptors: read end and write end)fork()— creates child 1 (will runcmd1)- Child 1:
dup2(fd[1], STDOUT_FILENO)— redirects its stdout to the pipe’s write end - Child 1: closes both original
fd[0]andfd[1]— no longer needed after dup2 - Child 1:
execlp(cmd1, ...)— replaces itself withcmd1, which writes to “stdout” (actually the pipe) fork()— creates child 2 (will runcmd2)- Child 2:
dup2(fd[0], STDIN_FILENO)— redirects its stdin to the pipe’s read end - Child 2: closes both original
fd[0]andfd[1] - Child 2:
execlp(cmd2, ...)— replaces itself withcmd2, which reads from “stdin” (actually the pipe) - Parent: closes both
fd[0]andfd[1]— critical for EOF detection - 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.
dup2(fd[0], STDIN_FILENO) do?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+Csends SIGINTCtrl+Zsends SIGTSTPkill PIDsends 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
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.