Async-Signal Safety and Reentrancy in C Handlers
Asynchronous signal handlers in the C programming language operate under strict execution constraints because they can interrupt a program at virtually any machine instruction. This article examines the constraints governing these handlers, focusing on why standard non-reentrant functions are unsafe, the concept of async-signal safety defined by POSIX, the dangers of internal state mutation and deadlocks, and the precise rules for safely managing data and system calls within a signal-handling context.
When an operating system delivers an asynchronous signal (such as
SIGINT, SIGTERM, or SIGALRM), the
CPU suspends regular thread execution and transfers control directly to
the registered signal handler. Because the interrupted thread may have
been in the middle of executing a critical routine, modifying data, or
holding a synchronization primitive, the signal handler inherits an
unpredictable execution environment.
The Reentrancy Requirement
A function is reentrant if multiple executions can run concurrently or nested within the same thread without producing incorrect results or corrupting data. In the context of signal handlers, reentrancy requires that if a function is interrupted by a signal, calling that same function inside the signal handler will not corrupt the state of the original execution.
Non-reentrant functions generally exhibit one or more of the following characteristics:
- Static or Global Variables: Functions that maintain
state across calls via internal static buffers (such as
strtok(),asctime(), orgethostbyname()) are fundamentally unsafe. If the main program is interrupted while executing such a function, calling it within the signal handler overwrites the active static buffer, leading to data corruption once the signal handler returns. - Dynamic Memory Management: Standard memory
allocation functions, including
malloc(),calloc(),realloc(), andfree(), maintain shared internal data structures (heap metadata) protected by mutexes. If an allocation routine is interrupted while modifying these structures, calling an allocation function inside the signal handler risks either corrupting heap metadata or triggering an immediate, unrecoverable deadlock. - Standard I/O Operations: High-level I/O functions
such as
printf(),puts(), andfopen()operate onFILEpointers that encapsulate internal buffers and locking mechanisms. Interrupted buffered I/O can produce corrupted output or cause the handler to hang indefinitely waiting for an internal lock held by the interrupted thread.
Deadlocks and Synchronization Primitives
Signal handlers run within the context of the thread they interrupt. Consequently, traditional synchronization mechanisms cannot be used:
- Mutexes: A handler must never attempt to acquire a standard, non-recursive mutex that might already be held by the interrupted code. Doing so causes an immediate self-deadlock, as the lock owner cannot proceed to release the mutex until the signal handler exits.
- Condition Variables and Semaphores: Similar constraints apply to condition variables and semaphores. Blocking inside a signal handler is strictly forbidden because the handler cannot rely on the interrupted thread to execute the code necessary to wake it.
POSIX Async-Signal-Safe Functions
To mitigate these issues, POSIX defines a explicit set of async-signal-safe functions. An async-signal-safe function is guaranteed to be reentrant or non-interruptible with respect to signals.
Safe operations generally consist of raw, low-level system calls that delegate synchronization to the kernel, such as:
- Direct I/O via file descriptors:
read(),write() - Signal management:
sigaction(),sigprocmask() - Process termination:
_exit()(in contrast toexit(), which flushes standard I/O streams and runsatexithandlers)
If a function does not appear on the POSIX list of async-signal-safe functions, invoking it from an asynchronous signal handler triggers undefined behavior.
Safe Access to Global State
When an asynchronous signal handler needs to communicate with the rest of the application, it cannot read or modify arbitrary global variables. Instead, it must adhere to the following rules:
volatile sig_atomic_t: C provides the integer typesig_atomic_t, which guarantees atomic read and write operations that cannot be interrupted mid-access. The variable must also be qualified withvolatileto prevent the compiler from optimizing reads into CPU registers, ensuring that changes made by the handler are immediately visible to the main program loop.- C11 Atomic Types: Under C11 and later, lock-free
atomic operations (
stdatomic.h) marked as address-free and lock-free can be safely modified within a signal handler. If an atomic operation relies internally on a lock, it is not async-signal-safe.
Preserving errno
Many async-signal-safe system calls alter the global
errno variable on failure. If the main program is
interrupted between a system call and its subsequent check of
errno, any changes to errno caused by the
signal handler will silently mask errors in the interrupted code.
Therefore, if a signal handler calls any function that may modify
errno, it must save the current value of errno
upon entering the handler and restore it before returning.
Summary of Best Practices
The most reliable approach to signal handler design in C is to
minimize the work performed inside the handler. A typical pattern
involves having the signal handler perform a single action: setting a
volatile sig_atomic_t flag, or writing a single byte to a
self-pipe or eventfd to wake up an event loop. The main
execution flow then detects this signal notification outside of the
asynchronous handler context and safely processes the necessary logic
using normal, non-reentrant C library functions.