Process Control: fork, exec, wait, signals

A process is a running program. On Linux it’s a task_struct in the kernel with an address space, a file descriptor table, a signal disposition table, credentials, and a scheduling entity. In this file you learn how processes are created, replaced, reaped, and signaled — the four verbs that define Unix process management. Get these right and you can build a shell, a daemon, a supervisor, a container runtime, or an inference-worker pool.

fork() — the copy-on-write clone

fork() creates an almost-identical copy of the calling process. Same code, same data, same fds, same everything — except:

  • The child gets a new PID; fork() returns 0 in the child and the child’s PID in the parent.

  • On failure fork() returns -1 in the parent, no child is created.

  • Modern Linux uses copy-on-write: the child’s pages point at the parent’s until either writes, then the kernel copies. This makes fork cheap enough for shells and expensive for huge-memory processes (a 30GB inference server forking is not free — you get COW overhead and possibly overcommit issues).

pid_t pid = fork();
if (pid < 0)     { perror("fork"); exit(1); }
else if (pid == 0) {
    /* child */
    execvp("ls", (char*[]){"ls", "-l", NULL});
    _exit(127);        // exec failed; use _exit not exit in child after fork
} else {
    /* parent */
    int status;
    waitpid(pid, &status, 0);
}

Two things to internalize.

  1. Use _exit, not exit, in a child that has forked but not yet exec’d. exit() runs atexit handlers and flushes stdio buffers — which may double-flush the parent’s buffered output (both processes now have a copy of the buffer). _exit skips all that and goes straight to the kernel.

  2. fork in a multi-threaded program is a minefield. Only the calling thread continues in the child. Any mutex held by another thread at fork time is now locked forever in the child. This is why posix_spawn exists, and why in a threaded server you fork-then-exec immediately with no work between them.

The exec family — replacing yourself

exec* replaces the current process image with a new program. Same PID, same fd table (unless O_CLOEXEC), same PPID, but everything about the running code is discarded and reloaded.

Variant

Argv

Env

Path search

execl(path, arg0, arg1, ..., NULL)

list

inherited

no

execlp(file, arg0, ...)

list

inherited

yes ($PATH)

execle(path, arg0, ..., NULL, envp)

list

explicit

no

execv(path, argv)

vector

inherited

no

execvp(file, argv)

vector

inherited

yes

execvpe(file, argv, envp)

vector

explicit

yes

Mnemonic: l=list, v=vector, p=path search, e=env.

If exec* returns, it failed. Otherwise it never returns.

wait, waitpid, and zombies

When a child exits, the kernel keeps a tiny record of it (exit status, resource usage) until the parent reaps it. That record is a zombie. A process that exits and has children left running becomes those children’s PPID = 1 (the init process, e.g., systemd), and init reaps them — those are orphans, not zombies.

int status;
pid_t child = waitpid(pid, &status, 0);           // block until this specific child
pid_t any   = waitpid(-1, &status, WNOHANG);      // any child, don't block
if (WIFEXITED(status))    printf("exit code %d\n", WEXITSTATUS(status));
if (WIFSIGNALED(status))  printf("killed by signal %d\n", WTERMSIG(status));

Zombie prevention patterns:

  • If you don’t care about exit status: signal(SIGCHLD, SIG_IGN) on Linux/BSD tells the kernel to auto-reap. But this is signal-based magic and disables wait for you.

  • Better: install a SIGCHLD handler that loops waitpid(-1, ..., WNOHANG) until it returns 0. That handles the case where multiple children exit simultaneously (signals coalesce).

  • Best for daemons: daemon(3) + prctl(PR_SET_CHILD_SUBREAPER, 1) to become a subreaper for orphans.

Signals — the async part of Unix

A signal is a software interrupt delivered to a process (or a specific thread). The kernel or another process sends it; the receiver’s signal handler runs at some arbitrary point between two normal instructions.

Signals you must know: SIGINT (Ctrl-C), SIGTERM (polite kill), SIGKILL (uncatchable kill), SIGSTOP/SIGCONT (pause/resume, uncatchable), SIGCHLD (child died), SIGPIPE (wrote to a closed pipe/socket — default is terminate, almost always wrong), SIGSEGV (invalid memory), SIGBUS (misaligned or mmap I/O error), SIGHUP (terminal hangup or config-reload convention), SIGUSR1/SIGUSR2 (application-defined).

sigaction, not signal

The signal(3) function’s semantics are underspecified across Unix versions. On some, the handler is reset to default after being called once (SysV semantics). On others it’s persistent (BSD semantics). It doesn’t let you specify which signals to block during handler execution. It doesn’t reliably give you SA_RESTART. Do not use signal() in new code except for SIG_IGN / SIG_DFL restore.

Use sigaction:

struct sigaction sa = {0};
sa.sa_handler = handle_sigint;
sigemptyset(&sa.sa_mask);
sigaddset(&sa.sa_mask, SIGTERM);         // block SIGTERM while SIGINT is being handled
sa.sa_flags = SA_RESTART;                // auto-restart slow syscalls
sigaction(SIGINT, &sa, NULL);

The three specific things sigaction gives you that signal doesn’t:

  1. Guaranteed persistent handler (no re-installation dance).

  2. sa_mask — signals to block during handler execution, preventing nested-signal races.

  3. Flags: SA_RESTART (auto-restart EINTR’d syscalls), SA_SIGINFO (extended handler with siginfo_t giving sender PID, faulting address, etc.), SA_NOCLDWAIT / SA_NOCLDSTOP for SIGCHLD tuning.

What you can do inside a signal handler

Almost nothing. The handler runs at an arbitrary point in your program — possibly inside malloc, inside printf, inside anything holding a lock. Only async-signal-safe functions are legal to call. man 7 signal-safety lists them; the short list you’ll actually use: write, _exit, sig_atomic_t reads/writes, sem_post.

Never call printf, malloc, fprintf, exit, or any stdio inside a signal handler. It compiles. It seems to work. It will deadlock in production at 3 AM. The correct pattern is:

volatile sig_atomic_t got_sigint = 0;
void handler(int sig) { got_sigint = 1; }

/* main loop */
while (!got_sigint) { /* work */ }

Or the self-pipe / signalfd patterns below.

signalfd — Linux’s escape hatch

Signal handlers are miserable to work with. signalfd() turns a signal into a readable file descriptor, so you can wait for it in your main event loop alongside network and file fds.

sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, NULL);       // block; signalfd will consume

int sfd = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);
// now add sfd to epoll/poll and read struct signalfd_siginfo from it

This is a huge quality-of-life upgrade for servers. Same principle: timerfd for timers, eventfd for cross-thread wakeups. In Phase 5 you’ll wire all three into an epoll loop.

The self-pipe trick — the pre-signalfd standard

Before signalfd, the portable way to poll for signals was:

  1. Create a pipe.

  2. Signal handler writes one byte to the pipe (write is async-signal-safe).

  3. Main loop’s select/poll wakes up when the pipe becomes readable, then processes the signal.

int pfd[2];
pipe2(pfd, O_CLOEXEC | O_NONBLOCK);
void handler(int sig) { char c = (char)sig; write(pfd[1], &c, 1); }
/* select on pfd[0] as one of your read fds */

Still useful on non-Linux (macOS, BSDs) where signalfd doesn’t exist. On Linux, prefer signalfd.

What most people get wrong about this

They install a signal handler that calls printf “just for debugging” and never remove it. Then in production the process hangs and no one knows why. The stack trace shows printfflockfile → stuck. This is the single most common signal-handling bug in the wild. Rule: signal handler sets a flag, main loop does the work. Or use signalfd. Nothing else.

Practice this week

  1. Write a program that forks 3 children, each sleeping for a random 1-5 seconds and exiting. The parent installs a SIGCHLD handler that sets a flag; main loop reaps in a while (waitpid(-1, &status, WNOHANG) > 0) loop. Verify no zombies with ps mid-run.

  2. Write a system() replacement: int mysystem(const char *cmd) that forks, execs sh -c cmd, waits, returns the status. Handle EINTR.

  3. Take your favorite blocking program and add a SIGINT handler that lets it exit cleanly (drain buffers, close fds, print summary). Use both the volatile sig_atomic_t pattern and (separately) signalfd. Notice the difference in ergonomics.

  4. Read man 7 signal cover to cover. Yes, all of it. Once. You’ll come back for reference for the rest of your life.

References

  • man 2 fork, man 2 execve, man 2 waitpid, man 2 sigaction, man 2 signalfd, man 7 signal, man 7 signal-safety.

  • APUE ch. 8 (Process Control), ch. 10 (Signals).

  • TLPI ch. 20-27 (comprehensive on signals; ch. 22 for signalfd).

  • Bryan Cantrill’s talks on signal-handling war stories — entertaining and instructive.


Return to README.md · Next: 04_ipc_pipes_shm_sockets.md