Systems Programming · OS Interfaces

Signals: Delivery, Interruption, and the Async-Signal-Safe Set

A signal handler runs on the thread it interrupted, with that thread half-finished, which is why only a small set of functions may be called from one. Delivery, masking, and the self-pipe pattern follow from the handler running on an interrupted thread.

A signal is the oldest form of asynchronous notification in the Unix interface, and the only one whose handler runs in an execution context the program never created. The handler is entered on a thread that was in the middle of something else, with that thread’s stack and its library state exactly as they were at the moment of interruption. Nothing about the interface guarantees that the interrupted code was at a convenient point. That single fact determines almost every rule about what a handler may do.

Delivery picks a thread, not a process

A signal directed at a process is delivered to one thread of that process that does not have it blocked. Which thread is chosen is not specified. Linux picks the first eligible thread in the process’s thread list, which in practice means the main thread tends to receive signals when it has not blocked them. A signal directed at a specific thread with pthread_kill or tgkill goes to that thread only.

This matters because the handler runs with that thread’s state. If the thread was inside malloc, the allocator’s lock may be held. If it was inside printf, the stream lock is held. A handler that calls malloc or printf can deadlock against the thread it interrupted, and the deadlock is not visible in any stack trace that a debugger would show, because the interrupted frame is still on the stack and the handler frame is above it.

The portable way to control which thread handles a signal is to block the signal in every thread before creating them, then unblock it in the one thread that will handle it. Since pthread_create inherits the creating thread’s signal mask, blocking before the first thread is created gives every thread the mask, and the dedicated thread then unblocks what it wants. Without this discipline, a process with ten threads has an unpredictable handler context.

Masks, pending, and the window between

Each thread has a signal mask: the set of signals whose delivery is deferred. A blocked signal that arrives is marked pending, and is delivered when the mask is changed to allow it. The mask is per-thread; the pending set has a per-thread part and a process-wide part, and sigpending reports the union.

The mask is also the basis of the critical-section pattern. To protect a short region from interruption, a thread blocks the signal, does the work, and unblocks. Unblocking delivers any signal that arrived during the region, so the handler runs immediately after the unblock, which is what makes the pattern correct: the handler sees a consistent state.

The pattern has a failure mode in the gap between testing a condition and entering a wait. A program that checks a flag set by a handler, then calls read to block until the next event, loses the race if the signal arrives between the two. sigsuspend closes it by taking a mask, installing it, and suspending in one operation; the handler runs before the call returns. pselect and ppoll do the same for a descriptor wait and are described in readiness notification.

What “async-signal-safe” excludes

POSIX defines a list of functions that may be called from a signal handler, and the list is short. It includes write, read, open, close, _exit, kill, waitpid, sigaction, sigprocmask, and a handful of others. It excludes malloc and free, all of stdio, pthread_mutex_lock, and anything that parses a format string.

The exclusions have a common cause: those functions touch state shared with the interrupted code, and they do not expect to be re-entered. malloc maintains free lists and a lock; re-entering it from a handler that interrupted malloc corrupts the heap or deadlocks. printf locks the stream and maintains a buffer; re-entering it can deadlock and can interleave partial output. Even strerror is excluded on some platforms because it may return a pointer to a static buffer that another thread is writing.

#include <signal.h>
#include <string.h>
#include <unistd.h>

static volatile sig_atomic_t stop_requested = 0;

static void on_term(int sig) {
    (void)sig;
    stop_requested = 1;      /* the only write the handler performs */
}

int main(void) {
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = on_term;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;         /* no SA_RESTART: blocking calls return EINTR */
    sigaction(SIGTERM, &sa, NULL);

    while (!stop_requested) {
        /* blocking work that may return EINTR */
    }
    return 0;
}

The variable is volatile sig_atomic_t because it is written by the handler and read by the main flow. The type is the only one the standard guarantees can be accessed atomically in this way. A pointer, a structure, or a long long written by a handler is a data race in the formal sense, and the compiler is free to reorder or cache the main-flow read.

volatile sig_atomic_t is also not sufficient for coordination between threads. It gives atomicity of access, not ordering with respect to other memory. A handler that sets a flag which another thread then reads to decide whether to look at a buffer needs the buffer write to happen before the flag write, which requires a release/acquire pair rather than a volatile variable. Signals delivered to the same thread are ordered with respect to that thread’s execution, but a handler on thread A communicating with thread B needs the full machinery.

SA_RESTART and the interrupted call

sigaction accepts flags that change delivery. SA_RESTART makes the kernel restart certain interrupted system calls automatically instead of returning EINTR. The set of restartable calls is not uniform: read and write on slow objects are restartable, poll and select are not, nanosleep is not, and accept is restartable on Linux but has historically varied. A program that relies on SA_RESTART to avoid writing retry loops will find that some calls still return EINTR.

The opposite choice is more common in servers: install the handler without SA_RESTART, treat EINTR as a signal to re-examine state, and restart the operation explicitly. That gives the program a chance to notice the flag the handler set, which is the point of the interruption. A loop that retries blindly on EINTR throws away the notification and can starve the condition the handler was reporting.

SA_NODEFER allows the same signal to be delivered again while its handler runs, which is rarely wanted. By default the signal is blocked during its own handler, which prevents unbounded recursion. SA_RESETHAND restores the default disposition before entering the handler, so a second delivery terminates the process; this was the historical signal behaviour and is the reason early programs lost their handlers after one delivery.

Real-time signals and queuing

Standard signals are numbered and not queued. If the same signal is generated several times while blocked, it is delivered once. SIGCHLD is the canonical case, and a handler for it must loop on waitpid with WNOHANG to reap every child, as described in exit status and zombies.

Real-time signals, SIGRTMIN through SIGRTMAX, are queued and carry a payload: an integer or a pointer, passed through sigqueue and retrieved by sigaction handlers installed with SA_SIGINFO. Ordering among them is by signal number, then by arrival. The range is small. The kernel reserves the lowest real-time numbers for its threading library, so the SIGRTMIN that a program sees is a value the C library computes at run time rather than a constant, and on glibc it is 34 with SIGRTMAX at 64. A program that allocates numbers from the range must use the macro and treat the two ends as a shared pool, because a literal number taken from a header may not match what the library reports.

Queuing is bounded. The system has a limit on queued signals per user, RLIMIT_SIGPENDING, and exceeding it makes sigqueue fail with EAGAIN. A design that sends one real-time signal per work item therefore has a backpressure problem that standard signals do not, because standard signals coalesce rather than fail.

The self-pipe and its modern replacements

A handler cannot wait on a descriptor, so a program that wants signals to participate in an event loop must convert the signal into readability. The classic mechanism is a pipe: the handler writes one byte and the loop reads from the read end. The pipe must be non-blocking, or the handler blocks when the pipe is full, which happens if signals arrive faster than the loop drains them. Writing one byte and ignoring the result of a full pipe is the standard behaviour, because a full pipe already means the loop has a pending wakeup.

The pattern works because write is async-signal-safe, and because a pipe is an object the readiness interfaces understand. Its cost is two descriptors and a wakeup per signal.

Linux provides a direct replacement: signalfd returns a descriptor that becomes readable when a signal in its mask is pending, and reading from it yields a structure identifying the signal and its sender. The descriptor can be registered with epoll alongside sockets, so the event loop has one wait and one uniform event type. The mask is applied to the calling thread, which means the signals must be blocked before the descriptor is created and read from the thread that will consume them; otherwise the default disposition runs instead.

The equivalent on other systems is a kernel event queue that carries signal events as one of its filter types, with the same shape: the program blocks the signal, registers interest, and receives an event rather than a handler invocation. The common property is that the notification arrives in the program’s own control flow rather than in an interrupted thread, which removes the entire async-signal-safety constraint from everything downstream of the descriptor.

Dispositions that cannot be caught

SIGKILL and SIGSTOP cannot be caught, blocked, or ignored. The first is the only reliable way to terminate a process that has corrupted its own state; the second is the only reliable way to stop one. A program cannot install a handler for either, and sigaction returns EINVAL if it tries.

Two other signals terminate by default and are frequently mistaken for catchable failures. SIGSEGV and SIGBUS can be caught, but a handler for them runs after the fault, on the same stack, with the faulting instruction still to be resumed if the handler returns. Returning from such a handler re-executes the fault and delivers the signal again, so a handler must either fix the mapping or terminate the process. A longjmp out of the handler is possible but leaves the library state inconsistent. In practice the useful action is to write a diagnostic with async-signal-safe calls and call _exit.

Dispositions are inherited across exec only for signals set to ignore; handlers are reset to the default, because the address of the handler function is meaningless in the new image. That asymmetry is why a program that ignores SIGPIPE before spawning a child finds the child also ignoring it, while a program with a handler finds the child with the default. The interaction with fork and exec is part of the wider inheritance rules covered in fork, exec, and descriptor inheritance.

References

  1. signal(7) — Linux manual page — man7.org
  2. The Open Group Base Specifications Issue 7, 2018 edition — System Interfaces — The Open Group
  3. The Linux Programming Interface — Michael Kerrisk, No Starch Press, 2010
  4. Advanced Programming in the UNIX Environment — W. Richard Stevens and Stephen A. Rago, Addison-Wesley, 3rd edition, 2013