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(), or gethostbyname()) 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(), and free(), 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(), and fopen() operate on FILE pointers 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 to exit(), which flushes standard I/O streams and runs atexit handlers)

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:

  1. volatile sig_atomic_t: C provides the integer type sig_atomic_t, which guarantees atomic read and write operations that cannot be interrupted mid-access. The variable must also be qualified with volatile to prevent the compiler from optimizing reads into CPU registers, ensuring that changes made by the handler are immediately visible to the main program loop.
  2. 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.