Operating Systems
Unix Signals
Asynchronous software interrupts — from Ctrl-C to SIGKILL
Unix signals are asynchronous software interrupts the kernel delivers to a process to notify it of an event: a Ctrl-C keystroke arrives as SIGINT, a shutdown request as SIGTERM, an invalid memory access as SIGSEGV, and an unconditional termination as SIGKILL. Each of the ~31 standard signals carries a default disposition — terminate, dump core, ignore, or stop — which a process may override by registering a handler with sigaction(2). Signals are the oldest IPC primitive in Unix, present in Bell Labs' Version 4 (1973) and reworked into a reliable, race-free form by 4.2BSD and POSIX.1-1990.
- IntroducedUnix V4, 1973; POSIX-reliable 1990
- Standard signals1–31 (not queued, bitset-pending)
- Real-time signalsSIGRTMIN..SIGRTMAX (queued, carry payload)
- UncatchableSIGKILL (9), SIGSTOP (19)
- Register handlersigaction(2); avoid legacy signal()
- Handler constraintAsync-signal-safe functions only
Interactive visualization
Press play, or step through manually. The visualization is yours to drive — try it before reading on.
Watch the 60-second explainer
A condensed visual walkthrough — narrated, captioned, under a minute.
Why signals matter
Signals are the mechanism that lets one process — or the kernel itself — interrupt another asynchronously, without either side arranging a rendezvous first. Every time you press Ctrl-C, watch a crash produce a core file, run kill in a shell, watch a server reload its config on SIGHUP, or see a Kubernetes pod drain gracefully on shutdown, you are watching signals at work. They are the thinnest, fastest notification channel in the Unix API — a single integer delivered out of band — and precisely because they are asynchronous, they are one of the hardest parts of systems programming to get right.
The difficulty is that a signal can be delivered between any two machine instructions of the target program. Your carefully written function can be paused mid-update, the handler runs, and control returns to the exact instruction that was interrupted. That single fact — arbitrary interruption — is the source of nearly every subtle signal bug: torn data structures, deadlocks inside malloc, lost SIGCHLD notifications, and system calls that fail with EINTR.
How signal delivery works, step by step
A signal moves through four distinct states between generation and handling:
- Generation. A signal is generated for a process by
kill(), by the terminal driver (Ctrl-C → SIGINT, Ctrl-Z → SIGTSTP), by a hardware trap the kernel translates (bad pointer → SIGSEGV, divide-by-zero → SIGFPE), byalarm()/timers (SIGALRM), or by a child changing state (SIGCHLD). - Pending. Between generation and delivery the signal is pending. For a standard signal, pending status is one bit — additional copies of the same signal collapse into that single bit and are lost.
- Blocked / masked. If the signal is in the thread's signal mask, delivery is deferred: it stays pending until unblocked. SIGKILL and SIGSTOP ignore the mask entirely.
- Delivery. On the return path from kernel to user mode, the kernel picks a pending, unblocked signal and applies its disposition: run the default action, ignore it, or call the registered handler on the process's stack (or an alternate stack set with
sigaltstack).
When a handler runs, the kernel automatically adds the signal being handled to the mask for the duration of the handler (unless SA_NODEFER is set), so a handler is not re-entered by its own signal. When the handler returns, the kernel restores the saved mask and register context and resumes the interrupted instruction stream.
The standard signals and their default dispositions
Every signal has a default action if you never install a handler. The five most important:
| Signal | Number* | Default action | Catchable? | Typical cause |
|---|---|---|---|---|
| SIGINT | 2 | Terminate | Yes | Ctrl-C at the terminal |
| SIGTERM | 15 | Terminate | Yes | Polite kill request (graceful shutdown) |
| SIGKILL | 9 | Terminate | No | kill -9 — unconditional destruction |
| SIGSEGV | 11 | Terminate + core dump | Yes | Invalid memory access (null/wild pointer) |
| SIGSTOP | 19 | Stop (freeze) | No | Job control — suspend a process |
*Signal numbers are architecture-dependent for a few signals; always use the symbolic names from <signal.h>, never the raw integers. The four default-action categories are Term (terminate), Core (terminate and write a core dump), Ign (ignore — SIGCHLD and SIGURG default here), and Stop/Cont (stop or resume execution).
Disposition vs. handler: three ways to respond
The disposition of a signal is what the process has decided should happen when it is delivered. There are exactly three choices:
- SIG_DFL — take the default action (terminate, core, ignore, or stop, per the table above).
- SIG_IGN — ignore the signal entirely; it is discarded on delivery. (You cannot ignore SIGKILL or SIGSTOP.)
- A handler function — a
void handler(int signo)the kernel calls, interrupting the main flow.
Dispositions are set with sigaction(2). The older signal(2) interface is portable but semantically ambiguous across systems (on some it reset the disposition to SIG_DFL before calling the handler — the notorious "System V unreliable signals" behavior). Modern code should always use sigaction, which gives explicit control over the mask-during-handler and flags like SA_RESTART, SA_SIGINFO, and SA_NODEFER.
Implementation: a correct SIGTERM handler in C
#include <signal.h>
#include <unistd.h>
#include <errno.h>
/* volatile + sig_atomic_t: safe to touch from a handler.
Do NOT do real work here — just record that the signal happened. */
static volatile sig_atomic_t got_term = 0;
static void on_term(int signo) {
(void)signo;
got_term = 1; /* async-signal-safe: a flag write */
/* write() IS async-signal-safe; printf() is NOT. */
const char msg[] = "SIGTERM received, draining...\n";
write(STDERR_FILENO, msg, sizeof msg - 1);
}
int main(void) {
struct sigaction sa = {0};
sa.sa_handler = on_term;
sigemptyset(&sa.sa_mask); /* no extra signals blocked in handler */
sa.sa_flags = 0; /* NO SA_RESTART: let read() return EINTR so
the loop can notice got_term and drain */
sigaction(SIGTERM, &sa, NULL);
char buf[4096];
while (!got_term) {
ssize_t n = read(STDIN_FILENO, buf, sizeof buf);
if (n < 0) {
if (errno == EINTR) continue; /* interrupted — recheck got_term */
break; /* real error */
}
if (n == 0) break; /* EOF */
/* ... process buf ... */
}
/* Graceful cleanup happens HERE, in the main flow — not the handler. */
return 0;
}
The pattern is the canonical one: the handler does the absolute minimum — set a volatile sig_atomic_t flag and, optionally, a single write() — and the real cleanup happens back in the main loop where locks, allocation, and buffered I/O are safe again. This is often called the "self-pipe trick" when generalized: the handler writes one byte to a pipe, and the main event loop (via select/poll/epoll) wakes up and does the work. Linux's signalfd(2) makes signals a readable file descriptor, folding them cleanly into an event loop.
Signal masks, pending signals, and critical sections
Each thread carries a signal mask: a sigset_t of currently blocked signals. Blocking is not discarding — a blocked signal becomes pending and is delivered the instant you unblock it. This is how you protect a critical section:
sigset_t block, old;
sigemptyset(&block);
sigaddset(&block, SIGINT);
/* Block SIGINT while touching shared state */
sigprocmask(SIG_BLOCK, &block, &old); /* pthread_sigmask() in threads */
update_shared_data_structure(); /* cannot be interrupted */
sigprocmask(SIG_SETMASK, &old, NULL); /* restore; pending SIGINT now fires */
There is a subtle race: if you unblock a signal and then call pause() to wait for it, the signal can arrive in the gap and you sleep forever. The fix is sigsuspend(), which atomically installs a temporary mask and sleeps, closing the window. In multithreaded programs, signal masks are per-thread but signal dispositions are per-process — a common design is to block all signals in every thread except one dedicated signal-handling thread that calls sigwait().
Reentrancy and async-signal-safety
Because a handler can interrupt the program at any instruction, it may interrupt a function that is not reentrant. Suppose the main code is halfway through malloc(), holding the allocator's internal lock, when SIGTERM arrives and your handler also calls malloc(). The handler tries to take a lock it already holds — a self-deadlock — or corrupts the heap's internal free lists. The same hazard applies to printf (buffered stdio + locks), localtime (static buffer), and most of the C library.
POSIX defines a specific, short whitelist of async-signal-safe functions guaranteed to work in a handler: write, read, _exit, signal, sigaction, kill, and a few dozen others — but not malloc, free, printf, or anything that might touch shared library state. The golden rule: a signal handler should set a flag and get out. Anything more should be deferred to normal program flow.
EINTR: the interrupted system call
When a signal is delivered while the process is blocked in a "slow" system call — read on a pipe or socket, write, accept, wait, select, nanosleep — the kernel may abort the call so the handler can run promptly. The call returns -1 with errno == EINTR. There are two remedies:
- Retry manually. Wrap the call in
while ((n = read(...)) < 0 && errno == EINTR);. - SA_RESTART. Set the flag in
sigactionand the kernel automatically restarts many interrupted calls. But not all —select,poll,nanosleep, and calls with timeouts are never restarted, because restarting them would silently reset the timeout. Robust code therefore checks for EINTR even with SA_RESTART set.
Common misconceptions and pitfalls
- "kill only kills processes." The
kill()syscall sends any signal — the name is historical.kill(pid, SIGSTOP)freezes,kill(pid, SIGCONT)resumes,kill(pid, 0)sends nothing but checks whether the process exists and you have permission. - "kill -9 is the clean way to stop a program." The opposite — SIGKILL denies the process any chance to flush data, close files, or release locks, risking corruption and orphaned resources. Always try SIGTERM first.
- "Standard signals queue up." They do not. Fire SIGUSR1 a hundred times while it is blocked and the handler runs once. Losing a SIGCHLD this way is why child-reapers must loop on
waitpid(-1, ..., WNOHANG)until it returns 0. - "printf in a handler is fine for debugging." It can deadlock. Use
write(2)to a fixed buffer instead. - "A handler can safely modify any global." Only a single
volatile sig_atomic_tis guaranteed atomic and visible. Anything larger can be torn by the interruption. - "SIGSEGV means my program simply exits." By default it also writes a
coredump (ifulimit -cpermits), which is the single most useful artifact for post-mortem debugging withgdb.
Standard vs. real-time signals
POSIX added real-time signals (SIGRTMIN through SIGRTMAX, at least 8 slots, 33–64 on Linux) to fix the two biggest weaknesses of standard signals. Real-time signals are queued — multiple deliveries are preserved, not collapsed — and delivered in priority order (lowest number first). They can carry a payload: sigqueue(pid, sig, value) attaches an int or pointer that the handler reads from a siginfo_t when installed with SA_SIGINFO. They are the right tool when you cannot afford to lose an event or need to distinguish senders.
A short history
Early Unix signals (V4, 1973) were "unreliable": the handler was reset to default before it ran, so a rapid second signal could kill the process, and there was an unavoidable race re-installing the handler. Programs raced to call signal() again at the top of every handler and still lost. 4.2BSD (1983) introduced reliable signals with per-process masks, restartable system calls, and sigvec/sigblock. POSIX.1-1990 standardized this as sigaction, sigprocmask, and sigsuspend — the interface still in use today. POSIX.1b (1993) added real-time signals and sigqueue. Linux later layered on signalfd, timerfd, and pidfd_send_signal to make signals cooperate with modern event loops and eliminate PID-reuse races.
Frequently asked questions
What is the difference between SIGTERM and SIGKILL?
SIGTERM (signal 15) is the polite request to terminate: a process can catch it, run cleanup — flush buffers, close sockets, remove lock files — and exit gracefully. SIGKILL (signal 9) cannot be caught, blocked, or ignored; the kernel destroys the process immediately with no chance to clean up. Well-behaved shutdown code sends SIGTERM first, waits a few seconds, then escalates to SIGKILL only if the process hasn't exited. That is exactly what kill (default SIGTERM) versus kill -9 do, and what init systems like systemd do on shutdown.
Why can't SIGKILL and SIGSTOP be caught or ignored?
SIGKILL and SIGSTOP are hard-wired in the kernel so that the operating system always retains ultimate control over a process. If a process could install a handler for SIGKILL, a runaway or malicious program could refuse to die, and the administrator would have no reliable way to reclaim resources. SIGSTOP is unblockable for the same reason — job control and the scheduler must be able to freeze any process. sigaction() returns EINVAL if you try to change the disposition of either signal.
What does async-signal-safe mean?
A function is async-signal-safe if it is safe to call from inside a signal handler — because a signal can interrupt the main program at any instruction, including in the middle of another library call. If the handler calls a non-reentrant function (like malloc or printf) that the interrupted code was already executing, it can deadlock on an internal lock or corrupt shared state. POSIX guarantees a specific list of async-signal-safe functions (write, _exit, signal-fd reads, sig_atomic_t assignments); the classic safe pattern is to set a volatile sig_atomic_t flag and do the real work back in the main loop.
What is a signal mask and how does sigprocmask work?
Each thread has a signal mask: a set of signals that are currently blocked. A blocked signal is not discarded — it becomes pending and is delivered as soon as it is unblocked. sigprocmask(2) (or pthread_sigmask in threaded programs) reads or changes the mask with SIG_BLOCK, SIG_UNBLOCK, or SIG_SETMASK. Masking is how you protect a critical section from being interrupted, and how sigsuspend() atomically unblocks a signal and waits for it — closing the race window between checking a flag and going to sleep.
Why do system calls fail with EINTR, and how do I handle it?
When a signal arrives while a process is blocked in a slow system call (read, write, wait, accept, select), the kernel may abort the call so the handler can run, returning -1 with errno set to EINTR. The classic fix is to retry the call in a loop while errno == EINTR. Alternatively, install the handler with the SA_RESTART flag via sigaction(), which asks the kernel to automatically restart many interrupted calls — though not all of them (select and some others never restart), so defensive code still checks for EINTR.
What happens if two identical signals arrive before the first is handled?
Standard signals are not queued — pending signals are tracked as a bitset, so if the same standard signal is raised many times while blocked, it collapses to a single pending instance and the handler runs only once. This is a real source of lost-signal bugs (e.g. missing a SIGCHLD when several children exit at once, which is why SIGCHLD handlers must reap in a waitpid loop). Real-time signals (SIGRTMIN..SIGRTMAX) ARE queued and carry an integer or pointer payload via sigqueue(), preserving both count and order up to a per-process limit.
What is the difference between a signal and a hardware interrupt?
A hardware interrupt is a signal from a device to the CPU, handled by the kernel in kernel mode. A Unix signal is a software-level notification the kernel delivers to a user process — often as the software consequence of an event. Some signals do originate from hardware traps: a bad pointer dereference triggers a CPU fault that the kernel translates into SIGSEGV; a divide-by-zero becomes SIGFPE. Others are purely software (SIGTERM from kill(), SIGINT from your terminal). Delivery happens on the return path from kernel to user mode, not at an arbitrary instant.