Computing History · Unix Lineage

Signals as Deferred Interrupts and the Async-Signal-Safe Subset

How a signal handler interrupts an arbitrary instruction, why most library calls are unsafe inside one, and what the small permitted subset actually guarantees.

A signal is delivered by interrupting a thread at whatever instruction it happens to be executing, saving a machine context, and running a handler in that thread with the interrupted state still live underneath. The handler is not a callback in a queue and not a separate thread. It runs on the same stack, between two instructions of the interrupted code, and it can therefore interrupt a function in the middle of updating a data structure. That single fact dictates everything about what a handler may safely do.

The interrupt, not the event

Most event mechanisms in computing are polled: the program asks whether something happened, and the answer is delivered at a point the program chose. Signals are the opposite. The kernel selects a thread and forces it to run the handler, which means the handler executes at an arbitrary point in the program’s control flow, including inside a malloc call, inside a printf, and between a load and a store that were meant to be atomic together.

The disposition of each signal is a property of the process: default action, ignore, or catch. The default actions are not uniform. SIGCHLD, SIGURG and SIGWINCH are ignored by default, and SIGCONT resumes a stopped process. SIGSTOP, SIGTSTP, SIGTTIN and SIGTTOU stop the process. SIGSEGV, SIGBUS, SIGFPE, SIGILL and SIGABRT terminate, some with a core dump. SIGTERM, SIGINT, SIGQUIT, SIGHUP, SIGPIPE, SIGALRM, SIGUSR1 and SIGUSR2 also terminate or dump. SIGKILL and SIGSTOP cannot be caught, blocked, or ignored at all, which is what makes them usable as an administrative last resort.

When a handler is installed with sigaction and the SA_RESTART flag is not set, a blocking system call interrupted by the signal returns EINTR with no data transferred. This is the interface’s way of letting the program decide whether to retry, and it is why every robust loop around read, write, accept, wait and nanosleep checks for EINTR explicitly. SA_RESTART makes the kernel restart certain calls automatically, but the set of restartable calls is not all of them, and some calls such as poll with a timeout have restart semantics that are specified in terms of the remaining time. A program that installs a handler and never checks for EINTR will fail intermittently under load, when a signal arrives during a slow system call.

Why the handler is a hostile execution environment

The handler runs with the interrupted thread’s stack. It may be invoked while the thread holds a lock inside malloc, while it is in the middle of a stdio operation with a FILE structure half-updated, or while it is inside the dynamic linker resolving a symbol. Any function the handler calls that touches the same state will observe it inconsistent, and the result is not a clean error but corruption or deadlock.

malloc is the canonical example. Its free lists and arena metadata are protected by a lock, and the handler may run in the thread that holds it. Calling malloc from the handler then blocks forever on a lock that cannot be released, because the code that would release it is suspended beneath the handler. Even without threads, malloc updates its metadata across several stores, and a handler that allocates can leave the arena inconsistent when it returns.

stdio has the same shape. printf locks the stream, copies into a buffer, and updates the buffer’s write pointer. A handler that calls printf on the same stream can interleave with the interrupted operation, and because FILE locks are recursive in some implementations and not in others, the behaviour differs by platform. The standard does not define what happens.

The POSIX answer is a list. write, read, open, close, _exit, fork, wait, waitpid, kill, raise, sigaction, sigprocmask, alarm, pause, pipe, dup, dup2, fcntl, lseek, unlink, mkdir, rmdir, chdir, chmod, chown, stat, fstat, umask, getpid, getuid, geteuid, getgid, getegid, time, uname, sysconf and a number of others are async-signal-safe. The list is a guarantee, not an enumeration of every function that happens to work on a given system, and it excludes some that look equally primitive: malloc and printf are not on it, and neither is ttyname, which returns a pointer into a static buffer that a second call would overwrite.

The list is short for a reason: each entry is a thin wrapper over a system call that either completes in the kernel without touching process-global user-space state, or is explicitly specified to be reentrant. read and write are safe because the kernel does the work; the C library wrapper adds no shared state. malloc is not on the list because the library implementation cannot make that guarantee.

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

static volatile sig_atomic_t got_term = 0;

static void on_term(int sig) {
    (void)sig;
    got_term = 1;             /* the only portable write in a handler */
    const char msg[] = "caught\n";
    write(STDERR_FILENO, msg, sizeof msg - 1);  /* async-signal-safe */
}

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: let blocking calls return EINTR */
    if (sigaction(SIGTERM, &sa, NULL) < 0) return 1;

    while (!got_term) {
        /* do work; check the flag at safe points */
    }
    return 0;
}

sig_atomic_t is the type the standard provides for this purpose. It is an integer type that can be read and written atomically with respect to signal delivery on the target, and it is the only type whose access from a handler is guaranteed. A volatile int is not sufficient by the standard, though it works on most current machines; a volatile long long may not be written atomically at all, and a handler’s store can be torn by the interrupted code.

The mask, and why delivery order matters

When a handler runs, the signal being handled is blocked for its duration, and the sa_mask field adds further signals to block. The block is per-thread and is implemented by the kernel’s signal mask, which sigprocmask (per-thread pthread_sigmask in threaded programs) manipulates.

The mask is what prevents a handler from being re-entered by the same signal. Without it, a second SIGINT arriving while the first handler runs would nest, and a handler that is not reentrant with respect to itself would corrupt whatever it touched. The default of blocking the delivered signal is therefore essential, and the ability to add others to sa_mask lets a handler that touches shared state also exclude the signals whose handlers touch the same state.

The cost is that signals can be lost. Standard signals are not queued: if SIGCHLD arrives while SIGCHLD is blocked, the kernel sets a pending bit and delivers one instance when unblocked, regardless of how many events occurred. A handler that calls waitpid once per delivery will miss children when several exit during the blocked interval. The correct pattern is to loop waitpid with WNOHANG until it returns zero, which drains all exited children regardless of how many signals were coalesced. Real-time signals, a later addition, are queued and carry a value, and they are the mechanism for counting events rather than merely noting them.

SIGCHLD also illustrates the race that the mask cannot fix. Between the time a child exits and the time the parent calls waitpid, the signal may be delivered and handled; between the handler’s return and the parent’s next blocking call, the parent may decide to wait. If the handler already reaped the child, the subsequent waitpid finds nothing and blocks forever, because the signal that would have woken it has already been consumed. The fix is to make the wait loop condition based on the child’s state rather than on the signal count, or to use sigsuspend to unblock the signal atomically with entering the wait.

Self-pipes and the deferred-work pattern

Because so little can be done in a handler, the common design moves the work out of it. A handler writes a byte to a pipe whose read end the main loop polls, and the main loop performs the actual handling in ordinary context, where every function is available. The pipe is created with pipe, both ends are set non-blocking, and the write end is what the handler touches; a full pipe means the handler’s write fails with EAGAIN, which is acceptable because a pending byte already signals that work is due. The choice of a pipe rather than a queue is forced by the constraints: the write is async-signal-safe and the buffer semantics are those described in the pipe article.

This pattern is also the portable way to combine signals with select or poll: a signal arriving during a blocking poll causes EINTR, and the loop then reads the self-pipe to find out what happened. It converts an asynchronous interruption into a synchronous event on a descriptor, which is the only kind of event the rest of the program’s structure knows how to handle.

The pattern’s failure modes are its own. The pipe can fill if the handler signals faster than the loop drains, and the byte count then under-reports the events; the loop must treat any readable state as “check everything” rather than counting bytes. And the self-pipe does not help with signals whose default action is to terminate, such as SIGSEGV, because a handler for those runs on a corrupted stack and cannot reliably do anything.

What the model cannot express

Signals carry no payload beyond the signal number and, for the sigqueue interface, one integer or pointer value. They identify an event class, not an instance. A handler cannot tell which of five children exited, which of several timers fired, or what data arrived; it can only note that the class occurred and let the main loop discover the details through the ordinary interfaces. This is why SIGIO and SIGPOLL are rarely the right tool for I/O readiness even though they exist: the handler has to find out which descriptor is ready by asking each one.

Delivery is also not ordered with respect to anything else. A signal sent by another process may arrive before or after a write that process performed, and a signal generated by the kernel for a resource event may be delivered after the condition has been resolved. The interface provides no ordering guarantee, so a program that uses signals as a synchronisation mechanism between processes is relying on timing that the specification does not provide. The mechanisms that do provide ordering are the ones with kernel-maintained queues: pipes, sockets, and the process-group and session structures described in the job control article.

References

  1. The Open Group Base Specifications Issue 7, System Interfaces: signal — POSIX.1-2017, IEEE Std 1003.1
  2. Linux man-pages: signal-safety(7) — Michael Kerrisk, man-pages project
  3. The UNIX Time-Sharing System — Ritchie & Thompson, Communications of the ACM, 1974
  4. Advanced Programming in the UNIX Environment — W. Richard Stevens and Stephen A. Rago; Addison-Wesley