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:

text
  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 required

That’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:

c
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. (sigwaitinfo in 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_t flag, 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.