POSIX Signal Handling: Asynchronous Contexts and Reentrancy
Unpacking asynchronous execution contexts, async-signal-safe functions, and the hidden dangers of reentrancy that make printf and malloc unsafe inside a signal handler.
If you write systems software long enough, you will eventually need to handle POSIX signals. Perhaps you need to gracefully shut down when the user hits Ctrl+C (SIGINT), or maybe you want to dump a custom stack trace when your program crashes (SIGSEGV).
Signals seem straightforward at first glance. You register a callback, the OS interrupts your program, and your function runs. Simple, right?
Not exactly. Signal handlers introduce asynchronous execution contexts into your single-threaded program. This creates a minefield of potential deadlocks, race conditions, and undefined behavior, primarily due to the violation of reentrancy.
Let’s unpack what an asynchronous context actually is, why calling printf or malloc inside a signal handler is a terrible idea, and how to correctly use sigaction.
The Asynchronous Execution Context
When a process receives a signal, the kernel forcefully interrupts the current thread of execution. The kernel saves the CPU context (registers, instruction pointer), injects a stack frame for the signal handler, and forcefully redirects execution to your registered signal handler function.
Why is this dangerous? Because the interruption can happen between any two assembly instructions.
Your program might have been in the middle of updating a linked list, acquiring a mutex, or allocating memory. If your signal handler accesses the same global state, or attempts to acquire the same mutex, it will deadlock or corrupt the state.
This is fundamentally different from a multithreaded context. In a standard multithreaded context, threads run concurrently and you use synchronization primitives (like mutexes) to protect shared data. In an asynchronous signal context, the interrupted thread is the thread running the signal handler. If the interrupted thread held a mutex, and the signal handler tries to lock it, the thread will wait forever for itself to release the lock.
Moreover, if your program is multithreaded, signals introduce even more chaos:
- A signal is delivered to any thread that doesn’t block it—you don’t get to choose, absent
pthread_sigmask. - Signal dispositions (the registered handlers) are process-wide, while the signal mask is per-thread.
- The standard pattern for multithreaded programs is to block all signals in every thread, and run a dedicated
sigwait()orsignalfd()thread. This turns async signals into a synchronous queue and sidesteps async-signal-safety entirely.
Reentrancy and Async-Signal-Safety
A function is reentrant if it can be safely interrupted in the middle of its execution and then safely called again (“re-entered”) before its previous invocations complete execution.
A function is async-signal-safe if it can be safely called from within a signal handler. All async-signal-safe functions are reentrant, but not all reentrant functions are async-signal-safe.
The printf and malloc Hazards
The most common mistake I see is developers calling printf or malloc inside a signal handler.
Neither printf nor malloc are async-signal-safe. Why? Because they both rely on locks or complex internal state.
malloc(in mostlibcimplementations) uses an arena mutex to prevent multiple threads from corrupting the free list.printfbuffers output and often locks theFILEstream (e.g.,stdout).
Imagine this scenario:
- Your main program calls
printf("Hello... "). - Inside
printf,libcacquires the internal lock forstdoutand begins modifying the buffer. - BAM! The kernel delivers
SIGINTright beforeprintfcompletes. - Your signal handler starts executing.
- Your signal handler calls
printf(" Caught SIGINT!\n"). - The second
printfre-enters stdio while the interrupted call is mid-update.
Depending on the implementation, you get one of two failures: a hard deadlock, where the stream lock is not recursive; or—on glibc, whose stream locks are recursive per thread—silently corrupted output, because the handler mutates a FILE buffer the interrupted call was halfway through writing. The second is worse: it passes your tests.
The malloc hazard is similar but probabilistic. If the signal interrupts the narrow window where malloc holds the arena lock, and the handler calls malloc, it can self-deadlock. It’s a genuine hazard that fails rarely and catastrophically.
What Functions Are Safe?
POSIX defines async-signal-safety by enumeration. If a function is not on the POSIX list, assume it’s unsafe—the list is not derivable from first principles. For example, abort() is on the list and does a great deal; snprintf is reentrant in practice but is not on the list.
Some notable safe functions:
write(),read()_exit()(but notexit(), which flushesstdiobuffers and callsatexithandlers)kill()sigprocmask()
To print from a signal handler safely, you must use the raw write system call.
Setting Up sigaction
Historically, programmers used the signal() function to register handlers. signal()’s semantics are implementation-defined—the System V flavour resets the disposition to SIG_DFL on delivery and doesn’t block the signal during the handler, while BSD/glibc does neither.
Instead, always use sigaction(). It lets you state what you want instead of inheriting a historical accident.
Correctly Handling SIGINT
Here is how we properly set up a handler for SIGINT (Ctrl+C). Notice how we use a volatile sig_atomic_t flag to communicate with the main loop, rather than doing complex work inside the handler.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <string.h>
#include <errno.h>
// sig_atomic_t guarantees atomicity with respect to signal delivery on the SAME thread.
// It is NOT a thread-safe atomic (for cross-thread, use C11 atomic_int).
// volatile prevents the compiler from optimizing away the loop condition.
volatile sig_atomic_t g_keep_running = 1;
// The signal handler
void sigint_handler(int signum) {
(void)signum;
// We only set a flag. No printf, no malloc, no complex logic.
g_keep_running = 0;
// If we absolutely MUST print something, we use write()
const char msg[] = "
[Signal] SIGINT caught. Initiating graceful shutdown...
";
// write() is async-signal-safe. Note: A robust implementation loops on short writes.
if (write(STDOUT_FILENO, msg, sizeof(msg) - 1) < 0) {
// Ignore errors in the handler
}
}
int main(void) {
struct sigaction sa;
// Clear the struct to prevent undefined behavior
memset(&sa, 0, sizeof(sa));
sa.sa_handler = sigint_handler;
// Block all other signals while the handler is running
sigfillset(&sa.sa_mask);
// SA_RESTART: Automatically restart interruptible system calls (like read/write)
sa.sa_flags = SA_RESTART;
if (sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction failed");
return EXIT_FAILURE;
}
printf("Running. Press Ctrl+C to stop.
");
while (g_keep_running) {
// Do some work...
// Note: SA_RESTART does not restart sleep() - it will return early on a signal.
sleep(1);
}
printf("Successfully cleaned up and exited.
");
return EXIT_SUCCESS;
} Handling Synchronous Signals: SIGSEGV
Signals like SIGINT or SIGTERM are asynchronous; they arrive from outside the process.
Signals like SIGSEGV (Segmentation Fault), SIGFPE (Floating Point Exception), or SIGILL (Illegal Instruction) are synchronous. They are generated directly by the CPU executing your thread as a result of a fault.
When you catch a SIGSEGV, the state of your program is fundamentally compromised. You cannot simply return from the handler, because the CPU will re-attempt the faulting instruction, causing an infinite loop of segmentation faults.
A SIGSEGV handler is typically only useful for logging the crash or generating a stack trace before deliberately terminating the process.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <string.h>
#include <stdint.h>
// Convert pointer to hex string (async-signal-safe)
void print_ptr_hex(void *ptr) {
char buf[20];
uintptr_t val = (uintptr_t)ptr;
int i = sizeof(buf) - 2;
buf[sizeof(buf) - 1] = '
';
if (val == 0) {
buf[i--] = '0';
} else {
while (val > 0 && i >= 0) {
int digit = val % 16;
buf[i--] = (digit < 10) ? ('0' + digit) : ('a' + digit - 10);
val /= 16;
}
}
buf[i--] = 'x';
buf[i] = '0';
/* start at buf[i] — i already points at the '0' of the "0x" prefix */
if (write(STDERR_FILENO, &buf[i], sizeof(buf) - i) < 0) {}
}
void sigsegv_handler(int signum, siginfo_t *info, void *context) {
(void)signum; (void)context;
const char msg[] = "
CRITICAL ERROR: Segmentation Fault at address: ";
if (write(STDERR_FILENO, msg, sizeof(msg) - 1) < 0) {}
// Print the faulting address using our async-signal-safe formatter
print_ptr_hex(info->si_addr);
// We must forcibly terminate the process.
// We use _exit() which is async-signal-safe, unlike exit().
_exit(EXIT_FAILURE);
}
int main(void) {
// A SIGSEGV is often caused by a stack overflow. If there's no stack left,
// the kernel can't push the handler frame, and our handler will never run.
// We must provide an alternate signal stack.
/* SIGSTKSZ is not a compile-time constant on glibc >= 2.34 (it expands to
sysconf(_SC_SIGSTKSZ) under _GNU_SOURCE), so size this with a literal. */
static char altstack[65536];
stack_t ss = { .ss_sp = altstack, .ss_size = sizeof altstack, .ss_flags = 0 };
if (sigaltstack(&ss, NULL) == -1) {
perror("sigaltstack");
return EXIT_FAILURE;
}
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
// Use sa_sigaction instead of sa_handler to receive siginfo_t
sa.sa_sigaction = sigsegv_handler;
sigemptyset(&sa.sa_mask);
// SA_SIGINFO enables the 3-argument handler signature.
// SA_ONSTACK tells the kernel to run this handler on our alternate stack.
sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
if (sigaction(SIGSEGV, &sa, NULL) == -1) {
perror("sigaction");
return EXIT_FAILURE;
}
printf("Triggering a deliberate segfault...
");
// Deliberate segfault
int *bad_ptr = NULL;
*bad_ptr = 42;
// This line will never execute
return EXIT_SUCCESS;
} By adhering to the strict rules of async-signal-safety and relying on sigaction, you prevent elusive deadlocks and undefined behavior when your programs interact with the POSIX signal facility.