Skip to main content

Exit Codes, Signals, and Process Lifetime

What This Concept Is

A process can end in exactly one of two ways: it exits voluntarily, or it is killed by a signal.

  • Voluntary exit. return from main, or a call to exit(n) (libc: flushes stdio, calls atexit handlers, then the _exit syscall) or _exit(n) (raw syscall, no flush). The low 8 bits of n are returned to the parent as the exit status.
  • Signal termination. Another process (or the kernel) sent the process a signal it did not handle. The default action for most signals is "terminate," sometimes with a core dump. Examples: SIGINT from Ctrl-C, SIGTERM from kill, SIGSEGV from touching unmapped memory.

A signal is an asynchronous notification. It can arrive at any instruction boundary. A program can, for most signals, install a handler via sigaction, ignore it via SIG_IGN, or let the default action happen.

Between exit and reaping, a process is a zombie -- it keeps only the exit status in the kernel's process table. When the parent calls wait/waitpid, the zombie is cleared. If the parent dies without reaping, the child is adopted by init (pid 1), which reaps continuously, so zombies disappear.

Why It Matters Here

Every C program you write has an exit code, even if you never set it -- "program exited successfully" means main returned 0. CI systems, shells, and test runners all read the exit code. Misunderstanding signals causes a distinct class of bugs: servers that ignore SIGTERM and must be kill -9'd, printf inside a SIGSEGV handler that deadlocks, Ctrl-C that does not work because somebody installed a handler and forgot to reinstall the default.

Concrete Example

A small program that installs a clean shutdown handler for SIGINT:

#include <signal.h>
#include <stdio.h>
#include <unistd.h>

static volatile sig_atomic_t g_stop = 0;

static void on_sigint(int signo) { (void)signo; g_stop = 1; }

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

while (!g_stop) {
write(1, ".", 1);
sleep(1);
}
write(1, "\ngoodbye\n", 9);
return 0;
}

Three disciplined choices: the flag is volatile sig_atomic_t (the only type C guarantees is safe to read/write inside a handler); the handler calls write rather than printf (printf is not async-signal-safe); SA_RESTART tells the kernel to restart syscalls interrupted by the handler rather than returning EINTR.

Running it:

$ ./sig &
[1] 8123
........
$ kill -INT 8123
goodbye
[1] Done ./sig
$ echo $?
0

Common Confusion / Misconception

"I can printf inside a signal handler; it's just output." No. Signal handlers run asynchronously. If the main program was inside printf when the signal arrived, and the handler also calls printf, the internal stdio lock can deadlock or the buffer can be corrupted. Async-signal-safe functions are a short list (see man 7 signal-safety): write, _exit, kill, most of them syscalls. Stick to them inside handlers, or set a flag and handle it in the main loop, as above.

Another trap: "exit(0) and _exit(0) are the same." They are not. exit flushes stdio and runs atexit hooks. If you call exit from a signal handler, you can re-enter code that is not reentrant. If you exit in a child after a failed exec, you can flush the parent's buffered output twice.

A third trap: ignoring SIGCHLD. If you fork children and never wait, you accumulate zombies. Either call waitpid in a loop, or set signal(SIGCHLD, SIG_IGN) on Linux (which tells the kernel to auto-reap), or handle SIGCHLD and loop waitpid(-1, ..., WNOHANG) until it returns 0.

How To Use It

Whenever a program has a lifecycle beyond "compute and return":

  1. Pick the exit codes deliberately. Convention: 0 success, 1 generic failure, 2 usage error, 64-113 for program-specific failures (see sysexits.h).
  2. For every long-running program, install a handler for SIGINT and SIGTERM that sets a flag.
  3. Make the main loop check the flag.
  4. In handlers: only async-signal-safe functions, only volatile sig_atomic_t flags.
  5. If you fork children, reap them. No exceptions.

Check Yourself

  1. What is the difference between exit(0) and _exit(0)?
  2. Why must a signal handler only call async-signal-safe functions?
  3. What is a zombie, and who reaps a process whose parent has already died?

Mini Drill or Application

Extend the program above. Do all four:

  1. Add a SIGTERM handler that sets the same flag. Test with kill (no -9).
  2. Remove SA_RESTART. Observe that sleep returns early with errno == EINTR on signal. Adapt the loop.
  3. From memory, write a program that forks 5 children, each of which sleeps a random 1-5 seconds and exits with its pid modulo 256. Reap them all and print each exit status.
  4. Explain, in one sentence, why kill -9 (SIGKILL) cannot be caught, blocked, or ignored.

Read This Only If Stuck