Home C / C++ Mastering POSIX Signals, Fork, and Pipes in C: A Practical Guide
Advanced 3 min · July 13, 2026

Mastering POSIX Signals, Fork, and Pipes in C: A Practical Guide

Learn POSIX signals, fork, and pipes in C with production-ready examples.

N
Naren Founder & Principal Engineer

20+ years shipping performance-critical C and C++ systems. Drawn from code that ran under real load.

Follow
Production
production tested
July 13, 2026
last updated
2,073
articles · all by Naren
Before you start⏱ 20-25 min read
  • Basic C programming (functions, pointers, file I/O)
  • Understanding of Unix process model
  • Familiarity with system calls and error handling
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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.
✦ Definition~90s read
What is POSIX Programming?

POSIX programming with signals, fork, and pipes allows you to create and manage multiple processes, handle asynchronous events, and enable inter-process communication in C.

Imagine you're a chef in a busy kitchen.
Plain-English First

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 signal() function or the more robust sigaction(). The sigaction() function is preferred because it's portable and provides finer control over signal behavior.

Here's a simple example that catches SIGINT (Ctrl+C) and prints a message instead of terminating:

signal_demo.cC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

volatile sig_atomic_t flag = 0;

void handle_sigint(int sig) {
    flag = 1;
}

int main() {
    struct sigaction sa;
    sa.sa_handler = handle_sigint;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    sigaction(SIGINT, &sa, NULL);

    while (!flag) {
        printf("Working...\n");
        sleep(1);
    }
    printf("\nExiting gracefully.\n");
    return 0;
}
Output
Working...
Working...
^C
Exiting gracefully.
⚠ Signal Handler Safety
📊 Production Insight
In production, avoid complex logic in signal handlers. Set a flag and let the main loop handle it. For SIGCHLD, use waitpid() with WNOHANG to reap children without blocking.
🎯 Key Takeaway
Use sigaction() instead of signal() for reliable signal handling. Keep handlers simple and async-signal-safe.

Creating Processes with Fork

The fork() 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(). 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.

fork_demo.cC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

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

    if (pid == 0) {
        // Child process
        printf("Child: PID = %d, Parent PID = %d\n", getpid(), getppid());
    } else {
        // Parent process
        printf("Parent: PID = %d, Child PID = %d\n", getpid(), pid);
        wait(NULL); // Wait for child to finish
    }
    return 0;
}
Output
Parent: PID = 1234, Child PID = 1235
Child: PID = 1235, Parent PID = 1234
🔥Fork and File Descriptors
📊 Production Insight
In production, fork can fail due to resource limits. Always check for -1 and implement retry logic or fallback. Also, be aware of file descriptor inheritance; close unused descriptors in child.
🎯 Key Takeaway
Fork creates a child process that runs concurrently. Always check the return value and handle errors. Use 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:

pipe_demo.cC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

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

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

    if (pid == 0) {
        // Child: read from pipe
        close(fd[1]); // Close unused write end
        char buffer[256];
        ssize_t n = read(fd[0], buffer, sizeof(buffer) - 1);
        if (n > 0) {
            buffer[n] = '\0';
            printf("Child received: %s\n", buffer);
        }
        close(fd[0]);
    } else {
        // Parent: write to pipe
        close(fd[0]); // Close unused read end
        char *msg = "Hello from parent!";
        write(fd[1], msg, strlen(msg));
        close(fd[1]);
        wait(NULL);
    }
    return 0;
}
Output
Child received: Hello from parent!
💡Closing Pipe Ends
📊 Production Insight
Pipe buffers are limited (typically 64KB on Linux). Writing more than that without reading will block. For large data, consider using socket pairs or shared memory.
🎯 Key Takeaway
Pipes are simple and efficient for one-way communication between related processes. Remember to close unused ends to avoid deadlocks.

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:

bidirectional_pipe.cC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    int to_child[2], to_parent[2];
    if (pipe(to_child) == -1 || pipe(to_parent) == -1) {
        perror("pipe");
        return 1;
    }

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

    if (pid == 0) {
        // Child
        close(to_child[1]); // Close write end of to_child
        close(to_parent[0]); // Close read end of to_parent

        int num;
        read(to_child[0], &num, sizeof(num));
        int result = num * num;
        write(to_parent[1], &result, sizeof(result));

        close(to_child[0]);
        close(to_parent[1]);
    } else {
        // Parent
        close(to_child[0]); // Close read end of to_child
        close(to_parent[1]); // Close write end of to_parent

        int num = 5;
        write(to_child[1], &num, sizeof(num));
        int result;
        read(to_parent[0], &result, sizeof(result));
        printf("Square of %d is %d\n", num, result);

        close(to_child[1]);
        close(to_parent[0]);
        wait(NULL);
    }
    return 0;
}
Output
Square of 5 is 25
⚠ Deadlock Risk
📊 Production Insight
For complex IPC, consider using socketpair() which creates a pair of connected sockets, or use higher-level libraries like libevent.
🎯 Key Takeaway
Two pipes enable bidirectional communication. Carefully coordinate reads and writes to avoid deadlocks.

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:

sigchld_reaper.cC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>

volatile sig_atomic_t child_exit = 0;

void sigchld_handler(int sig) {
    int status;
    pid_t pid;
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        printf("Child %d exited with status %d\n", pid, WEXITSTATUS(status));
    }
    child_exit = 1;
}

int main() {
    struct sigaction sa;
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
    sigaction(SIGCHLD, &sa, NULL);

    for (int i = 0; i < 3; i++) {
        pid_t pid = fork();
        if (pid == 0) {
            // Child
            printf("Child %d starting\n", getpid());
            sleep(1);
            exit(i);
        }
    }

    // Parent waits for all children (handler does the reaping)
    while (!child_exit) {
        pause(); // Wait for signal
    }
    printf("All children done.\n");
    return 0;
}
Output
Child 1236 starting
Child 1237 starting
Child 1238 starting
Child 1236 exited with status 0
Child 1237 exited with status 1
Child 1238 exited with status 2
All children done.
🔥SA_RESTART Flag
📊 Production Insight
In high-load servers, ensure your SIGCHLD handler is efficient. Avoid printf inside handlers; instead, set a flag and log in the main loop.
🎯 Key Takeaway
Use SIGCHLD with 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.

self_pipe.cC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <sys/select.h>

int signal_pipe[2];

void sigint_handler(int sig) {
    write(signal_pipe[1], "", 1); // Write one byte
}

int main() {
    if (pipe(signal_pipe) == -1) {
        perror("pipe");
        return 1;
    }

    struct sigaction sa;
    sa.sa_handler = sigint_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGINT, &sa, NULL);

    fd_set readfds;
    FD_ZERO(&readfds);
    FD_SET(signal_pipe[0], &readfds);

    printf("Waiting for SIGINT...\n");
    select(signal_pipe[0] + 1, &readfds, NULL, NULL, NULL);

    char buf;
    read(signal_pipe[0], &buf, 1);
    printf("Signal received. Exiting.\n");

    close(signal_pipe[0]);
    close(signal_pipe[1]);
    return 0;
}
Output
Waiting for SIGINT...
^C
Signal received. Exiting.
💡Self-Pipe Trick
📊 Production Insight
In high-performance systems, consider using signalfd (Linux-specific) to handle signals via file descriptors, which integrates seamlessly with epoll.
🎯 Key Takeaway
The self-pipe trick combines signals and I/O multiplexing for robust event handling. It's a production-grade pattern.
● Production incidentPOST-MORTEMseverity: high

The Ghost Process That Ate Memory

Symptom
The server's memory usage grew over time, eventually crashing. Users saw 'Out of memory' errors.
Assumption
The developer assumed child processes cleaned up automatically after exit.
Root cause
The parent process never called wait() or waitpid(), so child processes became zombies, consuming process table entries.
Fix
Added a SIGCHLD handler that calls waitpid() in a loop to reap all terminated children.
Key lesson
  • Always reap child processes using wait() or waitpid() 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.
Production debug guideSymptom to Action5 entries
Symptom · 01
Process hangs waiting for pipe read
Fix
Check if write end is closed; ensure all writers close their file descriptors.
Symptom · 02
Zombie processes accumulating
Fix
Verify parent calls wait() or waitpid(); add SIGCHLD handler.
Symptom · 03
Signal handler not executing
Fix
Ensure signal is not blocked; check sigprocmask; verify handler registration before fork.
Symptom · 04
Data corruption in pipe
Fix
Ensure only one writer and one reader; use mutexes if multiple writers.
Symptom · 05
Fork fails with EAGAIN
Fix
Check system resource limits (ulimit -u); reduce number of concurrent processes.
★ Quick Debug Cheat SheetCommon POSIX IPC issues and immediate fixes.
Zombie processes
Immediate action
Add waitpid(-1, NULL, WNOHANG) in SIGCHLD handler
Commands
ps aux | grep Z
kill -SIGCHLD <parent_pid>
Fix now
Modify parent to call wait() after fork.
Pipe read blocks forever+
Immediate action
Check all file descriptors; close unused ends
Commands
lsof -p <pid> | grep pipe
strace -p <pid> -e read
Fix now
Ensure write end is closed in reader.
Signal ignored+
Immediate action
Verify signal is not SIGKILL/SIGSTOP; check sigaction flags
Commands
kill -l
cat /proc/<pid>/status | grep SigCgt
Fix now
Re-register handler with sigaction.
Fork fails+
Immediate action
Check ulimit -u; reduce process count
Commands
ulimit -u
cat /proc/sys/kernel/threads-max
Fix now
Increase limits or refactor to use threads.
FeaturePipesFIFOs (Named Pipes)SocketsShared Memory
CommunicationUnidirectionalUnidirectionalBidirectionalBidirectional
Process RelationshipRelated onlyAnyAnyAny
PersistenceProcess lifetimeFile systemProcess lifetimeKernel
ComplexityLowMediumHighHigh
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
signal_demo.cvolatile sig_atomic_t flag = 0;Understanding POSIX Signals
fork_demo.cint main() {Creating Processes with Fork
pipe_demo.cint main() {Inter-Process Communication with Pipes
bidirectional_pipe.cint main() {Combining Fork and Pipes for Bidirectional Communication
sigchld_reaper.cvolatile sig_atomic_t child_exit = 0;Handling Signals in Multi-Process Programs
self_pipe.cint signal_pipe[2];Advanced Patterns

Key takeaways

1
Signals are asynchronous; use sigaction() and async-signal-safe handlers.
2
Fork creates a child; always wait() to avoid zombies.
3
Pipes provide unidirectional communication; close unused ends.
4
Combine fork and pipes for bidirectional IPC with careful protocol design.
5
Use self-pipe trick or signalfd for robust signal handling in event loops.

Common mistakes to avoid

5 patterns
×

Not 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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between signal() and sigaction().
Q02SENIOR
How would you implement a simple shell that supports pipes (e.g., ls | g...
Q03JUNIOR
What is a zombie process and how do you prevent it?
Q04SENIOR
What is the purpose of the volatile keyword in signal handlers?
Q05SENIOR
How can you make a pipe bidirectional?
Q01 of 05SENIOR

Explain the difference between signal() and sigaction().

ANSWER
signal() is simpler but less portable and may reset the handler after delivery. sigaction() provides finer control: you can specify signal masks, flags (like SA_RESTART), and it's guaranteed to be reliable across platforms.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What happens if I don't call wait() after fork?
02
Can I use pipes for communication between unrelated processes?
03
How do I avoid race conditions when using signals and pipes?
04
What is the maximum size of a pipe buffer?
05
Is fork() safe in multithreaded programs?
N
Naren Founder & Principal Engineer

20+ years shipping performance-critical C and C++ systems. Drawn from code that ran under real load.

Follow
Verified
production tested
July 13, 2026
last updated
2,073
articles · all by Naren
🔥

That's C Basics. Mark it forged?

3 min read · try the examples if you haven't

Previous
Embedded C Programming: Patterns and Pitfalls
24 / 24 · C Basics
Next
Introduction to C++