Signals are the oldest IPC mechanism in Unix and the most casually misused.
Every long-running daemon handles SIGTERM; most tutorials show a handler that
calls printf and exit — and both calls are undefined behavior. The bugs
this breeds are the worst kind: deadlocks and corruption that strike once a
month under load, in code that reads perfectly naturally. The reason is one
brutal fact about when a handler runs.
Between Any Two Instructions
A signal handler doesn’t run “when convenient.” The kernel delivers it on the way back to userspace — which means your main thread could be anywhere:
main thread: malloc() → takes heap lock → updating free lists...
▲
SIGTERM arrives
│
handler runs NOW, on this stack, with the heap lock held
handler calls printf → printf calls malloc → malloc wants the lock
→ deadlock, single-threaded, no second thread requiredThat’s the whole problem in one picture. The handler is a forced function call
injected into a suspended computation whose invariants are mid-flight. Any
function that touches shared state — locks, the heap, stdio buffers, errno —
is a landmine. POSIX’s answer is the async-signal-safe list
(man 7 signal-safety): a short whitelist — write, _exit, kill,
sigaction, and friends — of functions guaranteed reentrant-or-atomic.
printf, malloc, syslog, and virtually all of your own code are not on
it. (Even errno must be saved and restored in a handler that makes
syscalls — the interrupted code was allowed to be mid-check.)
The Pattern That Works
The professional answer is to do nothing in the handler:
static volatile sig_atomic_t got_sigterm;
static void on_term(int sig) { got_sigterm = 1; } /* the whole handler */…and let the main loop notice the flag. volatile sig_atomic_t is the one
type the C standard blesses for this. But there’s a hole: if the main loop is
parked in poll() for the next hour, a flag nobody looks at is a shutdown
that never happens. Signal delivery does interrupt blocking syscalls
(EINTR — more on it below), but there’s a race: the signal can land just
before the poll call, flag set, poll blocks anyway.
Two standard escapes:
- The self-pipe trick (portable): handler does
write(pipe_wr, "x", 1)— async-signal-safe — and the pipe’s read end sits in your poll set. Signal becomes I/O; the race vanishes because the byte waits in the pipe. - signalfd / EVFILT_SIGNAL (Linux/BSD): skip handlers entirely — block the
signals, get an fd that reads
struct signalfd_siginfo. Signals become ordinary events in the loop, which is exactly where they belong in any event-driven program. (sigwaitinfoin a dedicated thread is the thread-pool flavor of the same move.)
EINTR and SA_RESTART, the Perennial Papercut
When a signal interrupts a blocking syscall, the call can fail with EINTR —
and code that doesn’t expect read to “fail for no reason” breaks in ways
that only manifest once handlers get installed (a classic: adding a harmless
SIGCHLD handler to a working program and watching unrelated I/O paths start
erroring). Installing with SA_RESTART makes most syscalls resume
automatically — but not all: poll, select, epoll_wait, and
sleep-family calls return EINTR regardless. Robust code wraps the syscall
in a retry loop and moves on with life; robust reviewers grep for bare
read( in daemons.
Two more field notes. SIGCHLD: children must be reaped (waitpid with
WNOHANG in a loop — signals don’t queue, five deaths may arrive as one
signal) or they accumulate as zombies; the loop matters more than the
handler. SIGKILL/SIGSTOP can’t be caught — by design, so kill -9 is a
contract, not a request; and a process in uninterruptible D-state (stuck NFS,
dead disk) will ignore even that until the I/O aborts, which is why kill -9
“not working” is a storage symptom, not a signals one.
Takeaways
- Handlers run between arbitrary instructions with your program’s invariants suspended — only async-signal-safe functions are legal; printf and malloc are not.
- The only sane handler bodies: set a
volatile sig_atomic_tflag, or write one byte to a self-pipe. signalfd removes the handler entirely. - Handle EINTR everywhere; SA_RESTART covers most calls but never poll/select /epoll_wait.
- Reap children in a WNOHANG loop — signals coalesce, one SIGCHLD may mean many exits.
- Uncatchable ≠ unignorable: D-state processes shrug off kill -9; that’s a storage bug wearing a signals costume.