Signals: Asynchronous Notifications From the Kernel
A signal is a number delivered to a process at a moment it did not choose, interrupting whatever it was doing; that is enough to implement Ctrl-C, graceful shutdown and crash reporting, and it is also why signal handlers are the most constrained code you will ever write.
The problem
A number, a pending bit, and an interruption
A signal is a small integer with a conventional meaning. The kernel keeps a pending set per process (and per thread) and a blocked mask; sending a signal sets the pending bit. Nothing else happens until the target is about to return to user mode — after a system call, after a timer interrupt, after a page fault. At that moment the kernel checks pending-and-not-blocked and, if it finds one, either applies the default action (terminate, terminate with core dump, ignore, stop, continue) or, if the process installed a handler with sigaction(), arranges for the handler to run on the thread’s stack before the interrupted code resumes.
Because delivery is a *bit*, standard signals do not queue: three SIGUSR1s sent while one is pending are delivered as one. This is the first thing to internalise — a signal is a notification that something happened at least once, never a message stream. (POSIX real-time signals SIGRTMIN…SIGRTMAX do queue and carry a small payload, and are used accordingly rarely.)
The conceptual model — asynchronous notification with a per-process pending set — is Unix. Windows has no signals in this sense; its console control events and structured exception handling cover the same ground and are described at the end.
| Signal | Number | Sent by | Default action | Catchable? |
|---|---|---|---|---|
| SIGINT | 2 | terminal on Ctrl-C | terminate | yes |
| SIGTERM | 15 | kill, docker stop, Kubernetes, systemd | terminate | yes — the graceful-shutdown signal |
| SIGKILL | 9 | kill -9, OOM killer, orchestrator after grace period | terminate | no — cannot be caught, blocked or ignored |
| SIGSEGV | 11 | kernel, on an invalid memory access | terminate + core | yes, but only to log and die |
| SIGPIPE | 13 | kernel, on write to a pipe/socket with no reader | terminate | yes — servers ignore it |
| SIGCHLD | 17 | kernel, when a child exits or stops | ignore | yes — reap children here |
| SIGHUP | 1 | terminal hangup; by convention “reload config” | terminate | yes |
| SIGSTOP / SIGCONT | 19 / 18 | kill, job control | stop / continue | SIGSTOP no, SIGCONT yes |
Why Ctrl-C works
Ctrl-C is not a key the program sees. The terminal driver in the kernel — the line discipline behind the tty — recognises the interrupt character (^C, configurable with stty intr) and sends SIGINT to every process in the terminal’s foreground process group. That is why Ctrl-C kills the whole pipeline cat | grep | sort at once: the shell put all three in one process group. A background job (&) is in a different group and is untouched.
The default action of SIGINT is to terminate, so a program that never thought about signals dies cleanly. A program that installs a handler can instead set a flag and finish its current unit of work — which is what your editor does, and what Python turns into KeyboardInterrupt by installing its own handler and raising the exception at the next bytecode boundary. Node registers process.on('SIGINT') through libuv, which converts the signal into an event on the loop; no JavaScript ever runs inside the handler.
- Keyboard interrupt → tty driver (kernel)line discipline sees ^C↓
- kill(-pgrp, SIGINT) to the foreground process groupevery pipeline member gets it↓
- pending bit set in each target processnothing runs yet↓
- target returns to user mode (syscall exit / interrupt)delivery point↓
- default action (terminate) or handler on the thread’s stackthe interrupted code resumes afterwards
Handlers: the most constrained code you will write
A handler runs *between two instructions* of the interrupted thread, which might have been in the middle of malloc() holding the heap lock, or inside printf() holding the stdio lock. If the handler calls malloc() it deadlocks against itself; if it calls printf() it corrupts the buffer. POSIX therefore defines a short list of async-signal-safe functions — write(), _exit(), sigaction(), kill(), and a few dozen others — that are safe to call from a handler. printf, malloc, free, anything that takes a lock, and practically every library function are not. Nor is most of your language runtime: Python and Node do not run user code in the handler at all for this reason.
The robust pattern is therefore: the handler does the minimum — sets a volatile sig_atomic_t flag, or writes one byte to a pipe — and the main loop notices. The self-pipe trick (write to a pipe from the handler, select/epoll on its read end in the loop) is how signals were folded into event loops for decades. Linux offers signalfd() to skip the handler entirely: block the signal and read it as data from a descriptor, and eventfd for the same trick between threads. libuv does the equivalent internally, which is why process.on('SIGTERM', …) is a normal callback.
Two more rules. SIGKILL and SIGSTOP cannot be handled, blocked or ignored — they are the kernel’s guarantee to an administrator that a process can always be stopped. And a handler for SIGSEGV runs on the same stack that just overflowed if the fault *was* a stack overflow (Stack Overflow), so crash reporters install an alternate stack with sigaltstack() first; without it, the handler faults again and the process dies with no report.
1static volatile sig_atomic_t stop_requested = 0;2 3static void on_term(int) { stop_requested = 1; } // async-signal-safe: a store4 5int main() {6 struct sigaction sa{};7 sa.sa_handler = on_term;8 sigaction(SIGTERM, &sa, nullptr);9 sigaction(SIGINT, &sa, nullptr);10 signal(SIGPIPE, SIG_IGN); // a client hanging up must not kill us11 while (!stop_requested) {12 serve_one_request(); // blocking calls return EINTR on delivery13 }14 drain_and_close(); // graceful path: not in the handler15}The graceful shutdown pattern
Every orchestrator speaks the same protocol: send SIGTERM, wait a grace period, send SIGKILL. docker stop waits 10 seconds by default; Kubernetes waits terminationGracePeriodSeconds (30 s by default) after also removing the pod from Service endpoints; systemd waits TimeoutStopSec (90 s). Your process has that window to stop accepting new connections, finish in-flight requests, flush logs and buffers, and exit 0. If it ignores SIGTERM it will be killed at the deadline and every in-flight request will be dropped mid-response.
The most common way to *accidentally* ignore SIGTERM is to be PID 1 in a container (Process Isolation: One Kernel, Many PID 1s). The kernel never applies a default action to PID 1 — init is not allowed to die by accident — so a process that did not install a handler simply does not receive the terminate, and docker stop always takes the full 10 seconds and ends with SIGKILL. Run the server behind a tiny init (tini, dumb-init, docker run --init) or make sure the runtime installs a handler; Node and Go do by default only if you register one.
Reaping matters too. When a child exits, the parent gets SIGCHLD and must call wait() to collect the exit status; until it does, the child is a zombie — an entry in the process table with no memory, only an exit code (Process States). A parent that ignores SIGCHLD and never waits accumulates zombies until the PID space runs out; PID 1 in a container inherits every orphan and must reap them too, which is the other job tini does.
SIGTERM= please stop.SIGKILL= you are stopped.SIGHUP= reload (nginx, PostgreSQL, sshd honour this).- Exit status
128 + nmeans killed by signal n: 137 isSIGKILL(often the OOM killer, see Memory Pressure, Swap and the OOM Killer), 139 isSIGSEGV, 143 isSIGTERM. - A blocking system call interrupted by a handled signal returns
EINTRunlessSA_RESTARTwas set; loops that do not check for it spuriously fail.
Windows: the labelled difference
Windows has no signal mechanism. The C runtime exposes signal() with a handful of constants for portability, but underneath, Ctrl-C in a console generates a console control event (CTRL_C_EVENT, CTRL_BREAK_EVENT, CTRL_CLOSE_EVENT) delivered by starting a *new thread* in the process that runs the handler registered with SetConsoleCtrlHandler — so a Windows “handler” runs concurrently with the main thread rather than interrupting it, and the async-signal-safety rules are replaced by ordinary thread-safety rules. There is no SIGTERM: a service is asked to stop through the Service Control Manager, and an arbitrary process is ended with TerminateProcess, which is as uncatchable as SIGKILL and gives no grace period.
Memory faults are reported through structured exception handling rather than SIGSEGV; a child’s exit is observed by waiting on its process handle rather than by SIGCHLD; “write to a closed pipe” returns an error code rather than killing the writer. Cross-platform runtimes paper over this — Python raises KeyboardInterrupt on both, Node emits SIGINT on both — which is convenient right up until you need the grace-period behaviour of SIGTERM in a Windows container and discover it is a different protocol.
Key points
- A signal is a number and a pending bit; delivery happens when the target next returns to user mode, and standard signals do not queue.
- Ctrl-C is the tty driver sending
SIGINTto the foreground process group — the whole pipeline. SIGKILLandSIGSTOPcannot be caught; everything else can, includingSIGSEGV(with an alternate stack).- Handlers may only call async-signal-safe functions: set a flag or write a byte, and let the main loop do the work.
- Graceful shutdown: handle
SIGTERM, stop accepting, drain, exit before the orchestrator’sSIGKILLdeadline. - PID 1 gets no default actions and must reap orphans — use
tinior install handlers. - Windows uses console control events on a new thread and
TerminateProcess; the grace-period protocol is different.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why interrupt the process instead of leaving a message it can poll?
Because the interesting events — a crash, an operator’s stop request — happen when the process is not polling, possibly when it is stuck. Asynchronous delivery is the only way to reach code that is not listening.
▸Why are `SIGKILL` and `SIGSTOP` uncatchable?
So that an administrator always has a last resort. If a process could veto termination, a runaway or malicious process could never be stopped.
▸Why are handlers so restricted?
The handler runs in the middle of arbitrary code that may hold locks. Anything that takes a lock can deadlock with the interrupted code; anything that touches shared state can corrupt it.
▸Why send SIGTERM first and SIGKILL later?
SIGTERM gives the process a chance to preserve invariants — finish transactions, flush logs, close connections cleanly. SIGKILL guarantees the outcome if it does not cooperate in time.
Signals simulator
| Signal | From | Default | Disposition | Mask | |
|---|---|---|---|---|---|
| SIGINT (2) | Ctrl-C from the terminal | terminate | |||
| SIGTERM (15) | `kill`, systemd stop, docker stop, k8s pod deletion | terminate | |||
| SIGKILL (9) | `kill -9`, OOM killer, docker after the grace period | kill | cannot be caught | cannot be blocked | |
| SIGSEGV (11) | the CPU: a page fault the kernel cannot satisfy | core | |||
| SIGPIPE (13) | write() to a pipe/socket with no reader | terminate | |||
| SIGCHLD (17) | a child process exited | ignore |
How it fails
What the failure looks like from inside real software.
- A container’s main process is PID 1 without a handler:
docker stophangs for 10 s then kills it, dropping every in-flight request — exit code 137. - A handler calls
printformallocand the process deadlocks once in a thousand shutdowns; the hang is inmallocwith the handler frame on the stack. - The server never ignored
SIGPIPE: a client disconnect during a response kills the entire process with status 141 and no log line. - Zombies accumulate because the parent ignores
SIGCHLDand never waits;psshows hundreds of<defunct>entries and eventuallyfork()fails withEAGAIN. - A
SIGSEGVhandler withoutsigaltstackruns on the overflowed stack and faults again — no core, no report, just a dead process. - A blocking
read()returnsEINTRafter a handled signal and the caller treats it as an I/O error.