Skip to content

7.2 POSIX Signals and Asynchronous Safety

The Earth Control Console needs to handle interrupts, timeouts, and child process termination without restarting the process, signals come into focus as a solution.

Exceptions and System Calls describes the kernel entry point. Signal is another form of exception control flow exposed by Unix to user processes: the kernel schedules a user-space handler, or executes a default disposition.

A signal is not a regular callback

When you press Ctrl+C in the console, the terminal driver typically sends SIGINT to the foreground process group. If the process has installed a handler, the kernel modifies the execution context at an appropriate user-space return boundary, making the handler appear as if it were asynchronously inserted into the original program.

A handler can interrupt the main flow at almost any point in user code. It's not an ordinary function queued in order within the event loop, and you can't assume that the library function being interrupted is in a consistent, reentrant state.

Common dispositions of signal include:

  • Default actions: ignore, terminate, terminate and core dump, stop, or continue;
  • Explicitly ignore;
  • Calls the handler installed by the calling process.

SIGKILL and SIGSTOP cannot be caught, blocked, or ignored, they are part of the kernel's reserved control capabilities. Whether other signals can be caught and their default actions must be checked in the platform documentation.

generated, pending, blocked, delivered

A signal's lifecycle can be divided into the following stages:

text
generated
   -> pending
       -> If blocked, continue waiting
       -> If deliverable, execute disposition / handler

A mask determines which signals the current thread temporarily blocks. Blocking does not discard the signal: the signal remains in pending state until the mask is lifted or the process terminates.

Traditional standard signals typically do not queue when in pending state; multiple occurrences of the same signal may be merged into a single pending state. POSIX real-time signals can queue and carry values, but are subject to resource limits. It is not feasible to precisely count external events using a standard handler due to the number of times it is invoked.

Signals can be process-directed or thread-directed. In a multithreaded process, process-directed signals select a suitable thread without a current mask to deliver the signal; signals generated by synchronous hardware exceptions typically target the thread that caused the fault. Each thread maintains its own mask, while disposition settings generally apply to the entire process.

Install minimal handler using sigaction

The following POSIX program blocks SIGINT, installs a handler, then atomically temporarily swaps in a mask that allows SIGINT using sigsuspend. This avoids a race condition where a signal arrives between checking the flag and calling pause, leading to permanent sleep.

c
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>

static volatile sig_atomic_t stop_requested = 0;

static void handle_sigint(int signal_number) {
    (void)signal_number;
    stop_requested = 1;
}

int main(void) {
    sigset_t blocked;
    sigset_t original_mask;
    sigset_t wait_mask;

    if (sigemptyset(&blocked) != 0 || sigaddset(&blocked, SIGINT) != 0) {
        perror("build signal set");
        return 1;
    }
    if (sigprocmask(SIG_BLOCK, &blocked, &original_mask) != 0) {
        perror("sigprocmask");
        return 1;
    }

    struct sigaction action;
    memset(&action, 0, sizeof action);
    action.sa_handler = handle_sigint;
    if (sigemptyset(&action.sa_mask) != 0
            || sigaction(SIGINT, &action, NULL) != 0) {
        perror("sigaction");
        return 1;
    }

    wait_mask = original_mask;
    if (sigdelset(&wait_mask, SIGINT) != 0) {
        perror("sigdelset");
        return 1;
    }

    puts("press Ctrl+C once");
    while (!stop_requested) {
        if (sigsuspend(&wait_mask) == -1 && errno != EINTR) {
            perror("sigsuspend");
            return 1;
        }
    }

    if (sigprocmask(SIG_SETMASK, &original_mask, NULL) != 0) {
        perror("restore signal mask");
        return 1;
    }
    puts("SIGINT observed in normal control flow");
    return 0;
}

sig_atomic_t guarantees simple access to the object won't be split in signal context; volatile prevents ordinary control flow from permanently caching outdated observation values. It does not provide complete C11 thread atomic synchronization semantics, nor can it support complex data structures.

The example handler sets only a flag. puts, error handling, and exit occur as part of normal control flow.

Why printf and malloc Cannot Be Placed in Handler

Handlers may be inserted during the main flow when executing malloc, interacting with stdio, or holding internal mutexes. If a handler then calls the same non-reentrant implementation, it risks deadlock or corrupting the allocator or stream state.

POSIX defines a list of async-signal-safe functions; only operations explicitly guaranteed by the standard may be called from a handler. Commonly available functions include _exit and write used in their specified ways, along with a few signal operations. However, the full list and constraints should be consulted in the target platform's signal-safety(7) or POSIX documentation.

Even when calling write, caution is required:

  • File descriptors may be nonblocking, leading to write failures or partial writes;
  • Modifying errno within a handler can interfere with the interrupted code path and should be avoided unless necessary, values must be saved and restored appropriately;
  • Multiple handlers or threads writing to the same stream output may result in interleaved output;
  • A handler blocking due to a full pipe may cause the entire program to hang.

Thus, "printing a line inside a handler" may serve as a minimal diagnostic tool, but it is not a reliable logging system. A safer approach is to simply set a flag, or use a well-designed nonblocking self-pipe or eventfd to notify the main loop.

Self-Pipe: Routing a Sudden Alarm Back to the Duty Queue

A signal handler, like a bell that rings at any moment, isn't suited for on-the-spot complex repairs. A safer approach is to simply set an async-signal-safe flag and let the normal event loop handle the rest in its own controlled context.

The self-pipe trick works like this:

text
signal handler --write one byte--> nonblocking pipe
                                      |
main loop -------- poll/select -------+

The handler executes only async-signal-safe operations, such as write. The main event loop then reads from the pipe in a normal, non-async context, performing logging, memory allocation, or state transitions. The write end of the pipe must be nonblocking; if the pipe is full, the handler should set a flag in sig_atomic_t indicating that at least one event is pending. This prevents the handler from blocking.

On Linux, signalfd can convert masked signals into file-descriptor events, making it well-suited for Linux-only event loops. However, it's still essential to correctly set each thread’s signal mask. Portable POSIX programs typically use self-pipe or a dedicated sigwait thread.

In multithreaded services, it's common to mask the target signal before creating worker threads, and then have a dedicated thread call sigwait and sigwaitinfo to synchronously receive the signal. This way, complex processing occurs in a normal thread context, outside the constraints of async-signal-safe operations.

How Does a Signal Affect a Blocked System Call?

When a signal is delivered while a system call is blocked, the behavior may be one of the following:

  • The system call is automatically restarted;
  • The call returns -1 and sets errno=EINTR;
  • The call has already made partial progress and returns a positive byte or item count.

The exact outcome depends on the specific API, the signal disposition, SA_RESTART, and the work already completed. It is not safe to universally retry all failures, operations with timeouts, for instance, must recalculate remaining time, and non-idempotent calls may have already completed part of their work.

APIs such as pselect, ppoll, and sigsuspend combine the "temporarily modify the signal mask" and "enter waiting state" into a single atomic step, specifically avoiding the race condition where a check and a sleep occur out of sync, leading to lost wakeups.

Installing SA_RESTART does not mean "EINTR is no longer possible." Not all system calls are restartable, and platform-specific rules vary. Programs must still adhere to the error contracts defined by the APIs they invoke.

Synchronous Fault Signals Are Not a Universal Exception for Recovery Control Flow

SIGSEGV, SIGBUS, SIGFPE, and SIGILL may originate from synchronous exceptions during the current instruction. If a handler simply returns, the faulting instruction may be re-executed and fault again, creating an infinite loop.

Advanced runtimes, debuggers, JIT compilers, or crash reporters may use SA_SIGINFO, alternate signal stacks, and architectural context to perform controlled handling. However, typical applications should not call printf, malloc, unlock mutexes, or attempt to "skip the error and keep going" in an SIGSEGV handler. The process memory and business invariants may now be untrustworthy.

Secure crash handling typically collects only minimal, pre-arranged information and writes it to a reliable channel, then restarts the process using _exit or by restoring the default disposition. Core dumps or external supervisors are generally more trustworthy than complex recovery mechanisms built inside the process itself.

signal mask and critical section

In a single-threaded program, a signal can be temporarily blocked to protect the smallest shared state that might be accessed by a signal handler:

text
block signal
update shared state accessed by handler
restore original signal mask

This is not equivalent to a multithreaded mutex. Signal masking is per-thread; other threads may still receive process-directed signals. For shared data across threads, use pthread or C11 synchronization primitives, and explicitly design which thread is responsible for handling the signal.

Using the volatile sig_atomic_t flag within a signal handler is suitable only for simple communication between the handler and the interrupted execution flow. If regular worker threads also read or write this flag, additional thread-synchronization mechanisms are required. Do not treat volatile as atomic or as a memory fence.

Signal Disposition Across Process Operations

fork Child processes inherit the signal disposition and mask state of their parent, but the pending signal set has a well-defined, independent semantics. In multithreaded programs, a child process only retains the thread that invoked fork; lock states may originate from threads that have already terminated, so the set of functions that can safely be called before exec is strictly limited.

After exec succeeds, the disposition of signals that were previously caught typically reverts to default, while signals that were ignored generally remain ignored. The signal mask is preserved according to POSIX rules. If a daemon or supervisor incorrectly inherits a blocked signal mask, the new process may never receive the expected termination signal.

Changes in the child process's signal state can trigger SIGCHLD, but reliable cleanup requires a loop around waitpid, because standard signals can be merged. A single handler invocation does not guarantee that only one child process has exited.

Hands-on Verify Asynchronous Boundaries

  1. Remove the pre-existing blocking block from the example and write a signal timeline where the signal lands exactly within the window between the check and pause, capturing the lost-wakeup scenario.
  2. Set the self-pipe to nonblocking mode and design the pipe's full condition to preserve the semantic guarantee that "at least one event" is not lost.
  3. Compare the actual impact of the SA_RESTART switch on read and nanosleep, and document the target operating system.
  4. In a multithreaded program, have all worker threads block at SIGTERM, and initiate a graceful shutdown from the sigwait thread.
  5. Explain why a regular signal cannot precisely count 100 events, and compare the queue semantics of real-time signals.
  6. Analyze why the SIGCHLD handler must loop waitpid(-1, ..., WNOHANG).

The Next Step Is Saving Which Execution Context

System calls, faults, and signals all cause the kernel to take control of the current thread. The next chapter delves into process context, distinguishing between process, thread, address space, and file descriptor table, and tracks fork, exec, wait, and context switching.

Built with VitePress | Software Systems Atlas