Mastering POSIX Signals, Fork, and Pipes in C: A Practical Guide
Learn POSIX signals, fork, and pipes in C with production-ready examples.
20+ years shipping performance-critical C and C++ systems. Drawn from code that ran under real load.
- ✓Basic C programming (functions, pointers, file I/O)
- ✓Understanding of Unix process model
- ✓Familiarity with system calls and error handling
- Signals are asynchronous notifications sent to a process to handle events like termination or user interrupts.
- Fork creates a new child process by duplicating the parent; both continue execution from the same point.
- Pipes provide unidirectional inter-process communication; data written to one end can be read from the other.
- Combining fork and pipes allows parent-child processes to communicate safely.
- Proper signal handling prevents race conditions and ensures clean resource cleanup.
Imagine you're a chef in a busy kitchen. Signals are like waiters shouting orders or warnings (e.g., 'fire!'). Fork is like cloning yourself to handle multiple tasks simultaneously—you and your clone share the same recipe book but work independently. Pipes are like a conveyor belt between you and your clone: you put a finished dish on the belt, and your clone picks it up to plate it. In programming, signals notify processes of events, fork creates child processes, and pipes let them exchange data.
In the world of Unix-like operating systems, processes are the fundamental units of execution. But what happens when you need a program to handle unexpected events, create child processes, or communicate between them? That's where POSIX signals, fork, and pipes come into play. These three mechanisms form the backbone of process control and inter-process communication (IPC) in C programming.
Consider a web server that must handle multiple client requests simultaneously. Without fork, you'd be stuck serving one client at a time. Without pipes, child processes couldn't send results back to the parent. Without signals, you couldn't gracefully shut down the server or handle errors like segmentation faults.
This tutorial dives deep into practical POSIX programming. You'll learn how to send and catch signals, spawn child processes with fork, and create unidirectional data channels with pipes. We'll cover common pitfalls like race conditions, deadlocks, and zombie processes, and show you production-ready patterns. By the end, you'll be able to write robust, concurrent C programs that leverage the full power of Unix process management.
Understanding POSIX Signals
Signals are software interrupts that notify a process of an event. They can be sent by the kernel (e.g., SIGSEGV for segmentation fault) or by other processes (e.g., SIGINT from Ctrl+C). Each signal has a default action: terminate, ignore, core dump, stop, or continue. You can override most signals with a custom handler.
To set a handler, use the function or the more robust signal(). The sigaction() function is preferred because it's portable and provides finer control over signal behavior.sigaction()
Here's a simple example that catches SIGINT (Ctrl+C) and prints a message instead of terminating:
waitpid() with WNOHANG to reap children without blocking.sigaction() instead of signal() for reliable signal handling. Keep handlers simple and async-signal-safe.Creating Processes with Fork
The system call creates a new process by duplicating the calling process. The new process (child) gets a copy of the parent's memory, file descriptors, and execution state. Both processes continue from the same point after fork()fork(). The only difference is the return value: 0 in the child, and the child's PID in the parent.
Fork is used to create concurrent tasks. For example, a server might fork a child to handle each client connection. However, fork is expensive because it copies the entire address space. Modern systems use copy-on-write to optimize.
Here's a basic fork example:
wait() to avoid zombies.Inter-Process Communication with Pipes
Pipes provide a unidirectional communication channel between processes. They are created with pipe(int fd[2]), where fd[0] is the read end and fd[1] is the write end. Data written to fd[1] can be read from fd[0]. Pipes are typically used between a parent and child after fork.
To communicate, the parent closes the read end and writes to the write end, while the child closes the write end and reads from the read end (or vice versa). This prevents deadlocks.
Here's an example where the parent sends a message to the child via a pipe:
Combining Fork and Pipes for Bidirectional Communication
For two-way communication, you need two pipes: one for parent-to-child and one for child-to-parent. This allows each process to send and receive data independently.
Here's an example where the parent sends a number, and the child sends back its square:
socketpair() which creates a pair of connected sockets, or use higher-level libraries like libevent.Handling Signals in Multi-Process Programs
When using fork, signal handlers are inherited by the child. However, you must be careful: if the parent sets up a signal handler before fork, the child will have the same handler. This can cause issues if the child needs different behavior.
A common pattern is to reset signal handlers in the child after fork. Also, SIGCHLD must be handled to reap children asynchronously.
Here's an example that forks multiple children and uses a SIGCHLD handler to reap them:
waitpid() and WNOHANG to reap children asynchronously. Set SA_RESTART to avoid interrupted system calls.Advanced Patterns: Pipes and Signal Safety
Combining pipes and signals requires careful design. For example, a common pattern is to use a self-pipe trick: create a pipe, and in the signal handler, write a byte to the pipe. The main loop can then use select() or poll() to monitor the pipe and handle signals without race conditions.
This avoids the need for volatile sig_atomic_t flags and allows the main loop to block on I/O while still being responsive to signals.
Here's an example:
The Ghost Process That Ate Memory
wait() or waitpid(), so child processes became zombies, consuming process table entries.waitpid() in a loop to reap all terminated children.- Always reap child processes using
wait()orwaitpid()to avoid zombies. - Use SIGCHLD handlers for asynchronous reaping in event-driven programs.
- Monitor process table usage in production to detect leaks early.
- Consider using double-fork technique to orphan children if you don't need to wait.
- Test under load to ensure signal handlers don't miss notifications.
wait() or waitpid(); add SIGCHLD handler.ps aux | grep Zkill -SIGCHLD <parent_pid>wait() after fork.| File | Command / Code | Purpose |
|---|---|---|
| signal_demo.c | volatile sig_atomic_t flag = 0; | Understanding POSIX Signals |
| fork_demo.c | int main() { | Creating Processes with Fork |
| pipe_demo.c | int main() { | Inter-Process Communication with Pipes |
| bidirectional_pipe.c | int main() { | Combining Fork and Pipes for Bidirectional Communication |
| sigchld_reaper.c | volatile sig_atomic_t child_exit = 0; | Handling Signals in Multi-Process Programs |
| self_pipe.c | int signal_pipe[2]; | Advanced Patterns |
Key takeaways
sigaction() and async-signal-safe handlers.wait() to avoid zombies.Common mistakes to avoid
5 patternsNot closing unused pipe ends
Using non-async-signal-safe functions in signal handlers
Ignoring the return value of fork()
Not handling SIGCHLD in long-running servers
Assuming pipe writes are atomic for large data
Interview Questions on This Topic
Explain the difference between signal() and sigaction().
sigaction() provides finer control: you can specify signal masks, flags (like SA_RESTART), and it's guaranteed to be reliable across platforms.Frequently Asked Questions
20+ years shipping performance-critical C and C++ systems. Drawn from code that ran under real load.
That's C Basics. Mark it forged?
3 min read · try the examples if you haven't