5 min read
On this page

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:

  1. Always call wait or waitpid for every child
  2. Handle SIGCHLD to reap children asynchronously
  3. 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 fork should have a corresponding wait. Failing to wait creates zombie processes that accumulate in the process table.
  • Calling unsafe functions in signal handlersprintf, malloc, free, and most library functions are not async-signal-safe. Use volatile sig_atomic_t flags and handle the event in the main loop.
  • Using signal() instead of sigaction() — The signal function has inconsistent behavior across platforms. sigaction is 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 failurefork returns -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 exec without forking first replaces your current program entirely. Always fork first unless you intentionally want to replace the current process.

Key Takeaways

  • fork creates a child process that is a copy of the parent. It returns 0 to the child and the child's PID to the parent.
  • exec replaces the current process with a new program. The fork+exec pattern is how Unix launches programs.
  • waitpid retrieves 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_t flag and handle the event in the main loop.
  • Use sigaction instead of signal for portable, predictable signal handling.
  • Zombie processes are children that have exited but have not been waited on. Prevent them by always calling wait or handling SIGCHLD.