Processes & Signals
Every running program on a Unix system is a process. Processes are the fundamental unit of execution: each has its own address space, file descriptors, and execution state. The fork and exec system calls create and transform processes. Signals are asynchronous notifications sent to processes — they handle events like Ctrl+C, child process termination, and segmentation faults. Understanding processes and signals is essential for any C program that interacts with the operating system.
fork: Creating a Child Process
fork creates a new process by duplicating the calling process. The child is an almost exact copy of the parent: same code, same data, same open file descriptors. The only difference is the return value of fork.
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main(void) {
printf("Before fork: PID %d\n", getpid());
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
return 1;
} else 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);
}
return 0;
}
Before fork: PID 1234
Parent: PID 1234, child PID 1235
Child: PID 1235, parent PID 1234
fork returns 0 to the child and the child's PID to the parent. This asymmetry lets each process know its role. The order of execution between parent and child is not guaranteed.
exec: Replacing the Process Image
exec replaces the current process's code and data with a new program. The process ID stays the same, but the program changes entirely.
#include <stdio.h>
#include <unistd.h>
int main(void) {
printf("About to exec ls\n");
/* Replace this process with /bin/ls */
execlp("ls", "ls", "-la", NULL);
/* This line only executes if exec fails */
perror("exec failed");
return 1;
}
The exec family has several variants:
execl— arguments as a list:execl("/bin/ls", "ls", "-l", NULL)execlp— search PATH:execlp("ls", "ls", "-l", NULL)execv— arguments as an array:execv("/bin/ls", argv)execvp— search PATH with array:execvp("ls", argv)
If exec succeeds, it never returns. The code after exec only runs if exec fails.
The fork+exec Pattern
The standard pattern for launching a program is fork, then exec in the child. The parent continues running its own code.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
/* Child: run a command */
execlp("grep", "grep", "-r", "TODO", "src/", NULL);
perror("exec failed");
exit(1);
}
/* Parent: wait for the child to finish */
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("Child exited with status %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("Child killed by signal %d\n", WTERMSIG(status));
}
return 0;
}
This is how shells work. Every command you type in bash or zsh follows this pattern: the shell forks, the child execs the command, and the parent waits for the child.
wait & waitpid
wait and waitpid block the parent until a child process terminates. They also retrieve the child's exit status.
#include <sys/wait.h>
/* Wait for any child */
int status;
pid_t child = wait(&status);
/* Wait for a specific child */
waitpid(pid, &status, 0);
/* Check how the child terminated */
if (WIFEXITED(status)) {
int exit_code = WEXITSTATUS(status);
printf("Exited normally with code %d\n", exit_code);
}
if (WIFSIGNALED(status)) {
int signal = WTERMSIG(status);
printf("Killed by signal %d\n", signal);
}
The WNOHANG flag makes waitpid non-blocking — it returns immediately if no child has exited yet.
pid_t result = waitpid(pid, &status, WNOHANG);
if (result == 0) {
printf("Child still running\n");
}
Zombie Processes
When a child process terminates, its entry remains in the process table until the parent calls wait. This lingering entry is a zombie process. Zombies consume a process table slot but no other resources.
/* This creates a zombie */
pid_t pid = fork();
if (pid == 0) {
exit(0); /* Child exits immediately */
}
/* Parent never calls wait - child becomes a zombie */
sleep(60);
To prevent zombies:
- Always call
waitorwaitpidfor every child - Handle
SIGCHLDto reap children asynchronously - Double-fork: the child forks again and exits, making the grandchild an orphan adopted by init (which always reaps children)
/* SIGCHLD handler to reap zombies */
#include <signal.h>
#include <sys/wait.h>
void sigchld_handler(int sig) {
(void)sig;
while (waitpid(-1, NULL, WNOHANG) > 0) {
/* Reap all available zombie children */
}
}
/* Install the handler */
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigaction(SIGCHLD, &sa, NULL);
Signals
Signals are asynchronous notifications delivered to a process. They interrupt the normal flow of execution and invoke a signal handler (if one is registered) or take a default action.
Common Signals
| Signal | Default Action | Cause |
|---|---|---|
| SIGINT | Terminate | Ctrl+C from terminal |
| SIGTERM | Terminate | Polite termination request |
| SIGKILL | Terminate | Forced kill (cannot be caught) |
| SIGSEGV | Core dump | Invalid memory access |
| SIGCHLD | Ignore | Child process terminated |
| SIGPIPE | Terminate | Write to broken pipe |
| SIGALRM | Terminate | Alarm timer expired |
| SIGSTOP | Stop | Pause process (cannot be caught) |
| SIGCONT | Continue | Resume paused process |
Sending Signals
#include <signal.h>
kill(pid, SIGTERM); /* Send SIGTERM to a process */
raise(SIGTERM); /* Send a signal to yourself */
From the shell:
kill -TERM 1234
kill -9 1234 # SIGKILL - cannot be caught or ignored
Signal Handlers
A signal handler is a function that runs when a signal is delivered. Use sigaction (not the older signal function) to install handlers.
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
volatile sig_atomic_t running = 1;
void handle_sigint(int sig) {
(void)sig;
running = 0; /* Set flag - do not do complex work here */
}
int main(void) {
struct sigaction sa;
sa.sa_handler = handle_sigint;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL);
printf("Running... Press Ctrl+C to stop\n");
while (running) {
/* Main loop */
sleep(1);
printf("Working...\n");
}
printf("Caught SIGINT, cleaning up...\n");
return 0;
}
Running... Press Ctrl+C to stop
Working...
Working...
^CCaught SIGINT, cleaning up...
Async-Signal-Safe Functions
Signal handlers interrupt normal execution at arbitrary points. Only async-signal-safe functions may be called from a handler. Most standard library functions are not safe, including printf, malloc, and free.
Safe functions include: write, _exit, signal, sigaction, and simple variable assignment to volatile sig_atomic_t.
/* WRONG: calling printf from a signal handler */
void bad_handler(int sig) {
printf("Got signal %d\n", sig); /* NOT async-signal-safe */
}
/* CORRECT: set a flag and handle it in the main loop */
volatile sig_atomic_t got_signal = 0;
void good_handler(int sig) {
(void)sig;
got_signal = 1;
}
The safe pattern is: set a flag in the handler, check the flag in the main loop, and do the actual work there.
Practical Example: A Graceful Server Shutdown
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
volatile sig_atomic_t shutdown_requested = 0;
void handle_shutdown(int sig) {
(void)sig;
shutdown_requested = 1;
}
int main(void) {
struct sigaction sa;
sa.sa_handler = handle_shutdown;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
printf("Server started (PID %d)\n", getpid());
while (!shutdown_requested) {
/* Accept connections, process requests */
sleep(1);
}
printf("Shutting down gracefully...\n");
/* Close sockets, flush buffers, save state */
return 0;
}
This pattern — catching SIGTERM and SIGINT, setting a flag, and shutting down cleanly in the main loop — is how production servers handle termination.
Common Pitfalls
- Not waiting for children — Every
forkshould have a correspondingwait. Failing to wait creates zombie processes that accumulate in the process table. - Calling unsafe functions in signal handlers —
printf,malloc,free, and most library functions are not async-signal-safe. Usevolatile sig_atomic_tflags and handle the event in the main loop. - Using
signal()instead ofsigaction()— Thesignalfunction has inconsistent behavior across platforms.sigactionis portable and predictable. - Trying to catch SIGKILL or SIGSTOP — These signals cannot be caught, blocked, or ignored. They are the operating system's guarantee that any process can be stopped.
- Ignoring fork failure —
forkreturns -1 on failure (out of memory, process limit reached). Always check the return value. - Race conditions after fork — The parent and child run concurrently. Do not assume the parent runs before the child or vice versa.
- exec without fork — Calling
execwithout forking first replaces your current program entirely. Always fork first unless you intentionally want to replace the current process.
Key Takeaways
forkcreates a child process that is a copy of the parent. It returns 0 to the child and the child's PID to the parent.execreplaces the current process with a new program. The fork+exec pattern is how Unix launches programs.waitpidretrieves a child's exit status and prevents zombie processes. Always wait for every child you fork.- Signals are asynchronous notifications. SIGINT (Ctrl+C), SIGTERM (polite kill), and SIGKILL (forced kill) are the most important.
- Signal handlers must only use async-signal-safe functions. Set a
volatile sig_atomic_tflag and handle the event in the main loop. - Use
sigactioninstead ofsignalfor portable, predictable signal handling. - Zombie processes are children that have exited but have not been waited on. Prevent them by always calling
waitor handling SIGCHLD.