Async-Signal-Safety and Reentrant Handlers

A signal handler is a function the kernel makes your thread run out of nowhere — at almost any instruction boundary in your normal flow — and then resume whatever it interrupted. This is the single most dangerous corner of signal programming, because the handler shares the interrupted code’s entire process state: the same heap, the same stdio buffers, the same global locks. If the main flow is halfway through malloc() updating the heap’s free lists when a signal fires, and the handler also calls malloc(), the second call sees a half-mutated heap and corrupts it; if the main flow holds malloc()’s internal lock and the handler tries to take it, the thread deadlocks against itself. The defense is async-signal-safety: POSIX specifies an exact, finite list of library functions that are guaranteed safe to call inside a handler, and nothing else may be called. A function is async-signal-safe “either because it is reentrant or because it is atomic with respect to signals” (signal-safety(7)). The safe idioms that follow — set a volatile sig_atomic_t flag and return; the self-pipe trick; or use signalfd to leave handler context entirely — all exist to keep a handler’s body inside that safe envelope.

This note owns the safety contract of signal-handler code: why a handler can interrupt at any point, what “async-signal-safe” means precisely, the POSIX-guaranteed safe list, the errno-save rule, and the handful of safe idioms that real programs use. The registration of handlers (struct sigaction, SA_* flags) lives in sigaction and Signal Handlers; the delivery mechanism (how the kernel injects the handler frame on the return-to-userspace path) lives in Signal Delivery and the Return to Userspace. Read those for the layers around this one.

Mental Model

The way to think about a signal handler is as a second thread of control that shares everything with the first but can preempt it at an arbitrary instruction — yet, crucially, the handler is not a separate thread. It runs on the same kernel thread that was interrupted, so any lock the interrupted code holds is held by the very stack frame underneath the handler. A normal thread blocked on a contended lock would simply wait for the holder to release it; a signal handler that blocks on such a lock waits forever, because the holder is suspended below the handler on the same stack and cannot make progress until the handler returns. That is the difference between ordinary thread-safety and async-signal-safety, and it is why the safe list is so much shorter than the thread-safe list.

sequenceDiagram
    participant Main as Main flow (holds lock L)
    participant Kernel
    participant Handler as Signal handler
    Main->>Main: malloc() — takes heap lock L
    Note over Main: mid-update of free lists
    Kernel-->>Main: SIGALRM deliverable on syscall-return
    Kernel->>Handler: build frame, run handler
    Handler->>Handler: printf() -> malloc() -> wants lock L
    Note over Handler: L held by suspended frame below — DEADLOCK
    Handler--xMain: never returns; thread wedged forever

A self-deadlock from a non-async-signal-safe handler. What it shows: the handler and the interrupted malloc() run on the same kernel thread, so the heap lock taken by the main flow is not “held by another thread that will release it” — it is held by a frame frozen beneath the handler. When the handler’s own malloc() (reached via printf) tries to acquire that lock, nothing can ever release it. The insight to take: async-signal-safety is not the same problem as thread-safety; a function can be perfectly thread-safe and still catastrophically unsafe in a handler, because the “other thread” here cannot run until the handler finishes.

Why a Handler Can Interrupt at Almost Any Point

The asynchrony is structural, not incidental. Per signal(7), “Whenever there is a transition from kernel-mode to user-mode execution (e.g., on return from a system call or scheduling of a thread onto the CPU), the kernel checks whether there is a pending unblocked signal for which the process has established a signal handler.” Those transitions happen constantly — every system call return, every timer-interrupt-driven reschedule, every page fault resolution. From the program’s point of view, a signal can therefore be delivered between essentially any two machine instructions of normal code. The kernel “constructs a frame for the signal handler on the stack,” sets the program counter “to the first instruction of the signal handler function,” and arranges the return address to point at a signal trampoline that eventually calls sigreturn() to restore the pre-handler register state (signal(7)).

The consequence that matters here: the man page notes that “the kernel does not record any special state information indicating that the thread is currently executing inside a signal handler” — the handler is just ordinary user-space code running in the existing context. There is no privileged “handler mode” that magically makes the heap consistent. If the interrupted instruction was the third of five that malloc() needs to splice a chunk onto a free list, the data structure is observably broken for the entire duration of the handler. Any function the handler calls that reads or writes that same structure — directly or transitively — operates on corrupted state.

This is precisely the scenario the signal-safety(7) page spells out with stdio: “When performing buffered I/O on a file, the stdio functions must maintain a statically allocated data buffer along with associated counters and indexes (or pointers) that record the amount of data and the current position in the buffer.” Then: “Suppose that the main program is in the middle of a call to a stdio function such as printf(3) where the buffer and associated variables have been partially updated. If, at that moment, the program is interrupted by a signal handler that also calls printf(3), then the second call to printf(3) will operate on inconsistent data, with unpredictable results.” The conclusion is blunt: all of stdio is “not async-signal-safe.”

What “Async-Signal-Safe” Means

Per signal-safety(7), “An async-signal-safe function is one that can be safely called from within a signal handler. Many functions are not async-signal-safe. In particular, nonreentrant functions are generally unsafe to call from a signal handler.” The page gives the two distinct routes by which a function earns the label: “a function is async-signal-safe either because it is reentrant or because it is atomic with respect to signals (i.e., its execution can’t be interrupted by a signal handler).”

These two routes are worth separating because they fail differently:

  • Reentrant means the function holds no static/global state and takes no locks, so a fresh call can run correctly even while a prior call to the same function is suspended mid-flight on the same thread. strlen() reading a caller-supplied buffer is reentrant; printf() mutating a shared buffer is not. Note the asymmetry with thread-safety: a function made thread-safe by taking a lock is decidedly not reentrant, because a handler that re-enters it self-deadlocks on that very lock. Thread-safety via locking and async-signal-safety are, in this sense, opposed design choices.
  • Atomic with respect to signals means the function is a thin wrapper over a single system call (or otherwise executes as an indivisible unit from the handler’s perspective). write(2) is the canonical example: it traps directly into the kernel, the kernel does the work, and there is no userspace buffer to corrupt. The list of safe I/O functions — read, write, open, close, dup, fcntl, the socket calls — is exactly the set of direct syscall wrappers.

A subtle reentrancy hazard survives even within the safe list: errno. A handler that calls write() may clobber the global errno that the interrupted code was about to inspect. The man page addresses this directly: “Fetching and setting the value of errno is async-signal-safe provided that the signal handler saves errno on entry and restores its value before returning” (signal-safety(7)). So the iron rule for any handler that calls a syscall wrapper is: save errno first, restore it last.

The POSIX-Guaranteed Safe List

POSIX.1-2008 (and the TC1/TC2 corrigenda) define the functions that must be async-signal-safe; Linux/glibc adds a few more. The full list from signal-safety(7), grouped by purpose, includes:

  • Direct I/O and FS syscalls: read, write, open, openat, close, creat, dup, dup2, lseek, fcntl, fsync, fdatasync, ftruncate, stat/fstat/lstat, access/faccessat, chmod/fchmod/fchmodat, chown/fchown, mkdir/mkdirat, mkfifo/mkfifoat, mknod, link/linkat, unlink/unlinkat, rename/renameat, symlink/symlinkat, readlink/readlinkat, rmdir, pipe, poll, select/pselect, chdir/fchdir.
  • Sockets: accept, bind, connect, listen-family, recv/recvfrom/recvmsg, send/sendmsg/sendto, shutdown, socket, socketpair, sockatmark.
  • Process control and exec: _exit, _Exit, fork, _Fork, execl/execle/execv/execve/fexecve, wait, waitpid, setsid, setpgid, getpid, getppid, getuid/geteuid/getgid/getegid/getgroups, setuid/setgid, umask, alarm, pause, sleep.
  • Signal manipulation: kill, raise, pthread_kill, signal, sigaction, sigprocmask, pthread_sigmask, sigaddset/sigdelset/sigemptyset/sigfillset/sigismember, sigpending, sigsuspend, sigqueue, sigpause, siglongjmp/longjmp, sem_post, abort.
  • Time: clock_gettime, time, times, timer_gettime/timer_settime/timer_getoverrun.
  • Pure-computation string/memory functions that touch only caller-supplied buffers: memcpy, memmove, memset, memcmp, memchr, memccpy, strcpy/strncpy, strcat/strncat, strcmp/strncmp, strlen/strnlen, strchr/strrchr, strstr, strpbrk, strspn/strcspn, strtok_r (the reentrant variant — not strtok), plus their wide-character equivalents.
  • Byte-order helpers: htonl/htons/ntohl/ntohs, ffs.

The list is deliberately closed: anything not on it is presumed unsafe. The headline offenders that programmers reach for instinctively are all unsafe: printf/fprintf/puts/fwrite and the rest of stdio (shared buffers), malloc/free/calloc/realloc (heap locks and free-list mutation), syslog, getenv/setenv, localtime/strftime (static struct tm), exit (runs atexit handlers and flushes stdio — use _exit instead), and anything in a third-party library you cannot audit. Note strtok (static state) is unsafe while strtok_r is safe — the _r “reentrant” suffix is exactly the distinction the safe list cares about.

Uncertain

Verify: the precise membership and POSIX edition of a few entries above (e.g. whether siglongjmp/longjmp and sleep are listed in the current glibc-shipped [signal-safety(7)] at the 6.12/6.18 era, and whether glibc still flags any historical exceptions). Reason: the man page enumerates ~100 functions and editions add/remove a few; this note reproduces the categories from a single fetch of signal-safety(7), not a line-by-line diff against POSIX.1-2008/TC2. The man page itself records that “Before glibc 2.24, execl(3) and execle(3) employed realloc(3) internally and were consequently not async-signal-safe,” which shows the list does shift by glibc version. To resolve: diff the safe list in the man-pages git tag matching the deployed man-pages package against POSIX.1-2017. uncertain

The Safe Idioms

Because the safe list is so restrictive, robust programs almost never do real work inside a handler. They use one of three patterns to push the work back into the main flow, where the full library is available.

Idiom 1 — Set a flag and return

The minimal correct handler records that the signal happened and returns immediately, letting the main loop notice and react. The flag must be volatile sig_atomic_t: volatile so the compiler reloads it on every read in the main loop (it can change “spontaneously”), and sig_atomic_t because POSIX guarantees reads and writes of that type are atomic with respect to signal delivery — a half-written value can never be observed.

#include <signal.h>
#include <unistd.h>
 
static volatile sig_atomic_t got_sigterm = 0;   /* the only shared state */
 
static void on_sigterm(int signo) {
    int saved = errno;        /* (1) save errno — handler must restore it    */
    got_sigterm = 1;          /* (2) one atomic write, nothing else          */
    errno = saved;            /* (3) restore errno before returning          */
}
 
int main(void) {
    struct sigaction sa = { .sa_handler = on_sigterm };
    sigemptyset(&sa.sa_mask);
    sigaction(SIGTERM, &sa, NULL);
 
    while (!got_sigterm) {     /* (4) main loop polls the flag                */
        do_work();             /*     full libc available here                */
    }
    cleanup_and_exit();        /* (5) react in normal context, safely         */
}

Line (1)/(3): the save/restore pair is technically unnecessary here because the body touches no syscall, but it is the habit to build — the moment the handler grows a write(), it becomes mandatory. Line (2): a single write to a sig_atomic_t is the entire safe payload. Line (4): the loop condition reloads got_sigterm each iteration because it is volatile — drop the qualifier and an optimizing compiler may hoist the read out of the loop and spin forever. The weakness of this idiom is latency and the race window: if the program is blocked in do_work()’s syscall rather than spinning the loop, the flag is set but not seen until the syscall returns (often via EINTR, see SA_RESTART and Signal-Interrupted Syscalls).

Idiom 2 — The self-pipe trick

To make a signal wake a blocking multiplexer (select/poll/epoll), the handler writes one byte to the write-end of a pipe whose read-end is in the multiplexer’s watch set. Signal arrival thereby becomes an ordinary readable-fd event, and the event loop handles it synchronously between iterations.

static int self_pipe[2];
 
static void handler(int signo) {
    int saved = errno;
    char b = 0;
    write(self_pipe[1], &b, 1);   /* write() IS async-signal-safe; ignore short/EAGAIN */
    errno = saved;
}
 
/* setup */
pipe(self_pipe);
fcntl(self_pipe[0], F_SETFL, O_NONBLOCK);   /* read-end nonblocking  */
fcntl(self_pipe[1], F_SETFL, O_NONBLOCK);   /* write-end nonblocking — must NOT block in handler */
 
/* event loop: include self_pipe[0] in the epoll/select set;
   when it is readable, drain all bytes and act on the signal. */

The two design constraints both come straight from the safety rules. First, the only function the handler calls is write(), which is on the safe list. Second, the write-end is made non-blocking so that if the pipe buffer is full (many signals queued, loop behind), the write() fails with EAGAIN instead of blocking the handler — a handler that blocks inside a write() could deadlock the program, and one dropped wake byte is harmless because the read-end is drained in a loop and a single readable event already means “≥1 signal arrived.” The self-pipe trick was the standard pre-signalfd way to fold signals into an event loop; select_tut(2) discusses the underlying race it solves (a signal arriving in the gap between “check a flag” and “enter select”), for which the in-kernel atomic alternatives are pselect/ppoll (atomic signal-mask swap) and signalfd.

Uncertain

Verify: that the canonical self-pipe-trick write-up I am attributing to the man-pages ecosystem is sourced correctly. Reason: the self-pipe trick is universally documented (originating with D. J. Bernstein and popularized by Michael Kerrisk’s The Linux Programming Interface), but select_tut(2) as fetched here discusses the pselect race rather than naming the self-pipe trick explicitly. Reason: the specific man page I expected to enumerate the trick did not, in this fetch, spell out the non-blocking-write/drain detail verbatim. To resolve: cross-check against TLPI §63.5.2 and the signalfd(2)/epoll(7) notes. The mechanism (handler does only write(); nonblocking; drain read-end) is sound and follows directly from the safe-list rules. uncertain

Idiom 3 — Avoid handler context entirely with signalfd

The cleanest modern answer is to not run async handler code at all. With signalfd(2), “signalfd() creates a file descriptor that can be used to accept signals targeted at the caller,” and that descriptor “may be monitored by select(2), poll(2), and epoll(7).” The protocol is to first block the signals — “Normally, the set of signals to be received via the file descriptor should be blocked using sigprocmask(2), to prevent the signals being handled according to their default dispositions” — and then read() signalfd_siginfo records out of the fd inside the event loop. Because the signal is consumed by a read() in normal control flow, the async-signal-safe constraint evaporates: you may call malloc, printf, or anything else. The full machinery lives in signalfd; the point here is that signalfd is the safe idiom taken to its limit — it deletes the handler.

Failure Modes

  • Self-deadlock on an allocator or stdio lock. Symptom: the program hangs the instant a particular signal fires, with one thread parked in __lll_lock_wait or _int_malloc under the signal frame in a backtrace. Cause: the handler called malloc/free/printf/syslog while the interrupted code held the same internal lock. Diagnosis: gdb backtrace shows the handler frame stacked above a frame holding the lock; the fix is to remove the unsafe call.
  • Heap or buffer corruption with no deadlock. Symptom: intermittent crashes, “malloc(): corrupted top size”, or garbled output, minutes or hours after the offending signal, far from the cause. This is the more insidious failure: the handler’s unsafe call did not block (the lock happened to be free) but mutated a structure the main flow was mid-update on, leaving silent corruption that detonates later.
  • Lost errno. Symptom: a syscall in the main flow appears to “randomly” return a stale or wrong error. Cause: a handler called a syscall wrapper without saving/restoring errno, overwriting the value the main flow was about to read.
  • Compiler-hoisted flag. Symptom: the main loop never notices the flag and spins forever even though the handler clearly ran. Cause: the flag was not volatile, so the compiler cached it in a register. Fix: volatile sig_atomic_t.
  • Calling exit() instead of _exit() from a handler. Symptom: re-entrancy into stdio flushing or atexit handlers, hangs or double-frees during shutdown. exit(3) flushes streams and runs registered exit handlers — both unsafe; _exit(2) traps straight to the kernel and is on the safe list.

Alternatives and When to Choose Them

The three idioms above are not interchangeable; pick by what the program’s main flow is doing:

  • Flag-and-return suits a tight CPU-bound loop that checks the flag often — simplest, zero extra fds, but useless if the program blocks in syscalls.
  • Self-pipe suits an existing select/poll/epoll event loop on older kernels or portable code — it folds signals into the readiness model with one pipe and a one-line handler.
  • signalfd is the right default on Linux for event-loop programs: no handler at all, signals delivered as read()-able records, full libc available in the consumer. Its caveats (block the signals first; fork/exec interactions) are covered in signalfd.
  • pselect/ppoll solve the narrow race of “a signal between flag-check and blocking-call” by atomically swapping the signal mask for the duration of the wait — useful when you have a flag handler and just need the blocking wait to be race-free, without a pipe.

Production Notes

Real systems take async-signal-safety seriously precisely because the failures are rare, non-local, and brutal to debug. Crash-handler code — the SIGSEGV/SIGABRT handlers that print a backtrace — is the textbook minefield: the program is already corrupted (that is why it faulted), and the natural urge to printf the backtrace re-enters malloc on a wrecked heap. Production crash handlers therefore use only write() to a pre-opened fd, pre-format strings into stack buffers with hand-rolled integer-to-string code (no snprintf, which can allocate), and call _exit. Google’s abseil/glog failure-signal handlers and the kernel’s own oops path follow exactly this discipline. The general engineering rule that falls out of all of it: a signal handler should do the least possible — ideally one atomic store or one write() — and defer everything else to normal context, where the entire library is available again. Whenever the deferred-handling design is feasible (which is almost always), prefer signalfd and delete the handler outright.

See Also