Skip to content

VOL 03 / CH 07 / LESSON 02

7.2 POSIX Signals and Asynchronous Safety ​

A Unix process must handle terminal interrupts, timeouts, and child-process exits without restarting. Signals are one mechanism the kernel uses to notify it of those asynchronous events.

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 a terminal, 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 signal dispositions 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, and 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: a signal normally remains pending until unblocked or consumed by a synchronous wait. Ignored-signal rules and other disposition changes can discard it; pending is not permanent storage.

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 that has not blocked that signal for delivery; 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 a Minimal Handler with sigaction ​

Save this single-threaded POSIX program as wait_sigint.c and compile with cc -std=c17 -Wall -Wextra -Werror wait_sigint.c -o wait_sigint. It 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");
    if (fflush(stdout) == EOF) {
        perror("fflush");
        return 1;
    }
    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. This does not make a compound operation such as flag++ indivisible or provide C11 thread-atomic synchronization. The example only assigns 0 or 1.

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

Why Handlers Cannot Call printf or malloc ​

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.

Use a Self-Pipe to Wake the Main Loop ​

A handler has interrupted someone else’s work; it should not start a long meeting. Leave a safe marker, wake the event loop, and do the complex work in normal control flow.

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. Save and restore errno in the handler and handle EINTR from write. EAGAIN means the pipe is full and already contains wakeup data. Coordinate signal masking while checking/clearing flags and restore the wait mask atomically to avoid a new lost-wakeup race. Disable the handler before closing its pipe to avoid a closed or reused descriptor. Linux eventfd is a different interface: it accepts an 8-byte counter value, not the pipe’s single-byte write.

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. Complex processing then runs in ordinary thread context, without handler-only restrictions. Use pthread_sigmask to adjust masks in multithreaded code, rather than copying the single-threaded sigprocmask call above.

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. For these hardware-generated fault signals, POSIX does not guarantee recovery by returning from the handler. On typical Linux paths the same instruction may fault repeatedly. This differs from returning after a signal of the same name was explicitly sent with raise or kill.

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 a 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 terminates via _exit, or restores the default disposition and raises the signal again. Restarting is a separate supervisor policy. Core dumps or external supervisors are generally more trustworthy than complex recovery mechanisms built inside the process itself.

Signal Masks and Critical Sections ​

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 ​

After fork, child processes inherit the signal disposition and mask state of their parent, but the child’s pending set starts empty. In multithreaded programs, a child process only retains the thread that invoked fork; lock states may originate from threads absent from the child, 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 and pending signals are 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.

Child exit, stop, or continue events can generate SIGCHLD, depending on disposition and flags, 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.

Test the 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 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 examines process context, distinguishing between process, thread, address space, and file descriptor table, and tracks fork, exec, wait, and context switching.

References ​

Built with VitePress | Software Systems Atlas