User-space Signal Handling

On this page

Signals can express process control actions such as termination or suspension, and can also notify a thread of an event or exception. The key to correct usage lies in: who receives the signal, which thread delivers it, when it is blocked, and what the handler can safely do.

Disposition, Masking, and Pending

Signal disposition (default, ignore, or invoke handler) is a process-shared attribute, while the mask is a per-thread attribute. A signal sent to a process can be delivered to any thread that has it unblocked; thread-directed signals, such as those from hardware exceptions or pthread_kill, have a specific target.

Blocking is not ignoring: a signal may become pending until it is unblocked or consumed by a synchronous wait interface. SIGKILL and SIGSTOP cannot be caught, ignored, or blocked. Multithreaded programs use pthread_sigmask to coordinate masking; new threads inherit the creator's mask and do not automatically change if another thread modifies its mask later. signal(7)

Ordinary signals are typically not counted per send; multiple occurrences of the same signal while it is pending may be coalesced. Treating SIGUSR1 as a lossless task queue is an unreliable design; the actual task count should be stored in a queue or counter, with the signal serving only as a hint to check.

Why doesn't the number of sends equal the number of handles?

The key condition is whether the signal is still pending. If you keep it blocked and send it three times, only one instance remains for ordinary signals; if you allow the handler to consume one instance per send, you can handle three. Real-time signals preserve queued instances, but you must also check if the send succeeded.

Preparing the visual
Why doesn't the number of sends equal the number of handles?

Ordinary signals coalesce while pending; real-time signals can queue but have resource limits. Unblocking does not restore the coalesced count for ordinary signals.

sigaction and Execution Context

sigaction explicitly specifies the handler, the additional mask during execution, and behavior flags. sigset_t should be initialized via interfaces like sigemptyset/sigaddset; do not assume it is a portable field that can be directly assigned as an integer.

FlagMeaning
SA_SIGINFOUse a handler that takes siginfo_t and context arguments
SA_RESTARTRequest corresponding behavior for interfaces that support restart; does not cover all system calls
SA_NODEFERDo not automatically block the same signal while processing it, allowing more complex reentrancy
SA_RESETHANDRestore the default disposition upon entering the handler, affecting subsequent delivery behavior
SA_ONSTACKUse the alternate signal stack if one is available for the thread
handler executes in the interrupted thread and restores saved context upon return Normal Execution Thread's current registers and stack Execute Handler Kernel first constructs the user-space signal frame Resume Execution trampoline → rt_sigreturn Alternate signal stacks can be configured per thread; the handler is not a new thread, so you cannot assume the interrupted library state is reentrant.

The signal frame saves the context (registers, mask, etc.) needed to resume execution; its specific layout is determined by the architecture and ABI. Upon normal return from the handler, the relevant sigreturn path is called via the user-space trampoline. Do not assume an x86-64 stack layout is fixed for arm64, nor call sigreturn directly like a normal function.

Async-signal safety is stricter than thread safety

A handler may interrupt the current thread while malloc, printf, or a normal lock is modifying internal state; calling the same facility again will encounter inconsistency or self-deadlock. Ordinary "thread safety" guarantees do not replace async-signal-safe guarantees.

A common design is for the handler to only set a volatile sig_atomic_t flag, with the actual work handled by the normal control flow. This type is for restricted signal interaction and is not a substitute for arbitrary multithreaded shared protocols; complex counting, incrementing, and consistency across multiple fields still require separate analysis.

When you need to wake an event loop, you can use a pre-established non-blocking self-pipe and call the allowed write in the handler; if the pipe is full, you must accept that the notification is coalesced and recheck the state in the normal path. Although write is an async-signal-safe interface, it may still block on a blocking fd; safe does not mean zero latency. Handlers that use errno should save and restore it. signal-safety(7)

Use sigsuspend to avoid the race between check and wait

while (!seen) pause() has a window: the signal might be handled after the check, but then pause continues to wait for the next one. The following single-threaded example first blocks the signal, then uses sigsuspend to atomically replace the mask and wait.

#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <signal.h>
#include <unistd.h>

static volatile sig_atomic_t seen;
static void on_signal(int signo) {
    (void)signo;
    seen = 1;
}

int main(void) {
    sigset_t blocked, previous, waiting;
    sigemptyset(&blocked);
    sigaddset(&blocked, SIGUSR1);
    if (sigprocmask(SIG_BLOCK, &blocked, &previous) == -1) return 1;

    struct sigaction action = {0};
    action.sa_handler = on_signal;
    sigemptyset(&action.sa_mask);
    if (sigaction(SIGUSR1, &action, NULL) == -1) return 1;

    if (raise(SIGUSR1) != 0) return 1; // Enters pending at this point
    waiting = previous;
    sigdelset(&waiting, SIGUSR1);
    while (!seen) {
        if (sigsuspend(&waiting) == -1 && errno != EINTR) return 1;
    }
    if (sigprocmask(SIG_SETMASK, &previous, NULL) == -1) return 1;
    return 0;
}

During the wait, delivery of SIGUSR1 is allowed; after the handler returns, sigsuspend restores the original blocked mask and returns with EINTR. The loop rechecks seen to handle wakeups caused by other signals. The self-generated signal in the example is just to make the flow repeatable; in a real program, notifications can come from other authorized sources. sigsuspend(2)

pselect, ppoll, and epoll_pwait can also temporarily replace the mask while waiting for I/O, solving the same window issue, rather than manually unblocking and then calling a normal wait.

Block first, then check; atomically unblock and wait SIGUSR1 Blocked Check seen == 0 Signal Arrives Here Enters pending, handler not yet executed sigsuspend Temporarily unblock and wait Pending delivered → handler sets seen → wait returns EINTR After restoring the blocked mask, recheck seen; does not consume the notification and then sleep unconditionally The key fix is the atomic switch of the mask and wait, not replacing pause with a shorter polling interval.

Step-by-step comparison of error timing vs. correct timing

MomentCheck then pauseBlock + sigsuspend
1Reads seen=0Reads seen=0 while signal is blocked
2Handler runs, writes seen=1Signal arrives, recorded only as pending
3pause starts waiting for next signalAtomically switches mask, delivers existing pending
4If no new signal, continues sleepingHandler sets seen, rechecks after return

"The handler has already set the flag to 1" does not mean an entering wait will automatically see it. Wait system calls do not read the application's seen variable. This example also shows that the notification and the condition being waited for are two different things: the loop is responsible for confirming the condition, and the atomic wait is responsible for avoiding missing notifications corresponding to condition changes.

If the flag is already 1, why does the thread still sleep?

Try "check → send → wait": in the naive solution, seen is already 1, but the thread waits for the next signal; in the safe solution, the signal stays pending and is delivered upon entering sigsuspend. Try "check → wait → send"; both solutions return, showing that verifying only the normal order is insufficient to check the wait protocol.

Preparing the visual
If the flag is already 1, why does the thread still sleep?

pause does not read application flags. If the signal is handled after the check but before the wait, the wakeup may be lost; blocking and then using sigsuspend to atomically switch the mask can preserve the pending notification.

SA_RESTART and EINTR

On Linux, some slow device read/write, wait series, and socket operations meeting certain conditions can be affected by SA_RESTART; poll, select, epoll_wait, pause, sigsuspend, etc., will not automatically restart just because this flag is set. Socket timeout options, etc., will also change specific behaviors; you cannot rely solely on function names to create an unconditional binary table.

When a read/write has transferred partial data, it may return a positive short count; the application should preserve progress rather than retransmitting the entire message from scratch. If a timeout wait uses a relative duration, retrying after EINTR also requires deducting the elapsed time.

For synchronous faults like SIGSEGV, if the cause is not eliminated after the handler returns, the same faulting instruction may be executed again. Installing a handler does not turn arbitrary memory errors into recoverable exceptions.

Alternate signal stacks are per-thread resources

sigaltstack registers a still-valid memory region, and SA_ONSTACK makes the corresponding handler use it. This ensures there is still space to record minimal information when the normal stack is insufficient, but the alternate stack itself does not grow infinitely, nor does it make malloc, complex stack unwinding, or logging libraries suddenly async-signal-safe.

The size must consider architecture extension state, nested signals, and handler requirements; it should not be hardcoded as "usually 8 KiB is enough." Establish it in each relevant thread and manage its lifecycle correctly; do not replace or free it while it is still in use. sigaltstack(2)

Synchronous Consumption: sigwait and signalfd

A dedicated signal thread can block the target signal and then synchronously receive it using sigwaitinfo/sigwait; subsequent processing in the normal thread context is not subject to the same restrictions as an async handler. Linux signalfd integrates signal records into the fd event loop.

You should establish a consistent routing strategy across all relevant threads to avoid some signals going to the traditional handler. signalfd also cannot arbitrarily move hardware faults exclusive to another thread to the management thread. See epoll and Event-Driven Programming for configuration examples.

Ordinary signal coalescing can be deterministically reproduced within a single process

The following example first blocks SIGUSR1, sends it to itself three times in a row, and then consumes it synchronously. Since all three occur before delivery, only one ordinary signal instance remains in pending. Use sigtimedwait with a zero timeout to check if there is a second one, rather than waiting indefinitely.

#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <time.h>
#include <unistd.h>
int main(void) {
    sigset_t set, old;
    sigemptyset(&set);
    sigaddset(&set, SIGUSR1);
    if (sigprocmask(SIG_BLOCK, &set, &old) == -1) return 1;
    for (int i = 0; i < 3; ++i)
        if (kill(getpid(), SIGUSR1) == -1) return 1;
    struct timespec zero = {0, 0};
    siginfo_t info;
    int first = sigtimedwait(&set, &info, &zero);
    errno = 0;
    int second = sigtimedwait(&set, &info, &zero);
    int saved = errno;
    if (sigprocmask(SIG_SETMASK, &old, NULL) == -1) return 1;
    printf("one received=%d; next empty=%d\n",
           first == SIGUSR1, second == -1 && saved == EAGAIN);
    return !(first == SIGUSR1 && second == -1 && saved == EAGAIN);
}

Save as pending-demo.c, compile and run expecting one received=1; next empty=1. This single-threaded, self-generated signal experiment does not require installing a handler; sigwaitinfo/sigtimedwait synchronously takes it while the signal remains blocked. Zero timeout EAGAIN indicates there is no target signal to receive at this moment. sigtimedwait(2)

Do not generalize this result to "sending three times always results in processing only once." If the previous one is consumed between each send, you may observe three separate times. The behavior during pending determines coalescing; when you need a reliable task count, you should read the actual task queue.

Real-time Signals and timerfd

Real-time signals can carry sigval data and queue up, but the queue is resource-limited, sigqueue can fail, and you cannot promise zero loss. The queuing order for the same real-time signal and the delivery priority for different signal numbers are specified, which does not mean all numbers follow a unified FIFO based on send time. SIGRTMIN may also be adjusted by the thread library; use interface macros and verify the range.

timerfd notifications do not require a signal handler. A successful read usually returns sizeof(uint64_t) bytes; the integer read out is the accumulated expiration count, which may be greater than 1; do not misinterpret "return value is 8" as expiring 8 times. Monotonic clocks are suitable for measuring time intervals, and whether they include system suspend depends on the chosen clock.

Retrying waits must preserve the total time budget

Assume the wait budget is 100 ms. If interrupted by a signal at 30 ms, and then consuming another 40 ms later. If you wait 100 ms again for every EINTR, the total wait is extended; continuous signals may even keep pushing the completion time back. The application should calculate a fixed deadline using the selected monotonic clock, and then calculate the remaining budget based on the current time.

deadline_ms = 100
def remaining(now_ms):
    return max(0, deadline_ms - now_ms)
assert remaining(0) == 100
assert remaining(30) == 70
assert remaining(70) == 30
assert remaining(105) == 0

This is just an integer time model; actual C programs must handle timespec second/nanosecond normalization, clock read errors, and the timeout rules of specific wait interfaces. SA_RESTART cannot maintain the application-layer deadline for you. Even if a call can restart, you should not confuse "single blocking duration" with "total allowed duration for the entire operation."

What to observe during debugging

strace -e trace=rt_sigaction,rt_sigprocmask,rt_sigsuspend -e signal=all ./prog

SigBlk, SigIgn, SigCgt in /proc/PID/status describe masking and disposition; SigPnd and ShdPnd distinguish thread and process pending; for multithreaded issues, you also need to check the status of the corresponding tasks.

First confirm the signal target, mask, and pending state, then analyze whether the handler executed and whether the wait restarted. Do not use signal frequency to directly estimate the number of events, nor treat a single successful handler as proof that the entire program's synchronization protocol is correct.

Keywords: signal, sigaction, signal mask, pending, async-signal-safe, sigsuspend, SA_RESTART, sigaltstack, signalfd, real-time signal