Linux System Programming · intermediate · ~12 min

signal() vs sigaction() — and why you should always use sigaction()

- By the end you can explain why `signal()` is unreliable across platforms and name the specific race it can open. - By the end you can install a signal handler with `sigaction()` and fill out a `struct sigaction` correctly (handler, mask, flags). - By the end you can choose between the 1-argument (`sa_handler`) and 3-argument (`sa_sigaction`, via `SA_SIGINFO`) handler forms. - By the end you can use `SA_RESTART`, `SA_NOCLDSTOP`, and `sa_mask` deliberately instead of accepting whatever a libc default gives you. - By the end you can save and restore a previous handler using the `oldact` argument.

Overview

You already met the standard signals in the previous lesson — SIGINT from Ctrl+C, SIGTERM from kill, SIGCHLD when a child exits, and so on — and you know a signal is an asynchronous notification the kernel delivers to your process. This lesson is about the mechanism for reacting to them: how you tell the kernel "when signal N arrives, call this function of mine." There are two APIs for that job. signal() is the ancient one from the original C standard; sigaction() is the POSIX one that replaced it.

They look interchangeable in a five-line demo, and that is exactly the trap. signal()'s behaviour was never nailed down precisely, so the same call does subtly different things on different systems — sometimes even a different thing on the same system depending on which compatibility mode libc is in. sigaction() makes every one of those behaviours an explicit flag you set yourself. Building on your knowledge of what signals are, this lesson is about installing handlers for them correctly and portably.

Why it matters

A signal handler is the one piece of your program that can run at literally any instruction boundary, so getting its installation wrong produces bugs that only appear under load or on a customer's machine. The classic signal() footgun — the handler resetting to the default (which for most signals is "terminate the process") after firing once — means a server that catches SIGTERM to shut down cleanly can be killed outright by a second SIGTERM that races in before it re-arms. Long-running daemons, network servers, and any program that must flush buffers or release locks before exiting depend on handlers that stay installed and don't corrupt in-flight system calls. Using sigaction() with explicit flags is how you get deterministic, auditable behaviour instead of "works on my box."

Core concepts

The problem with signal()

signal() has one job — install a handler — but its contract is famously underspecified. Two behaviours were never standardised:

  1. Handler reset ("one-shot" semantics). In the original System V behaviour, once your handler runs, the disposition is reset to SIG_DFL (the default action). Your handler catches the signal once, then the next occurrence does the default thing — usually killing the process.
  2. System-call interruption. Whether a blocking call like read() or accept() is silently restarted or returns the error EINTR after a signal is also platform-dependent.

The reset behaviour forces a horrible workaround: re-install the handler as the first thing inside the handler.

  Time ->
  signal arrives  --> handler runs
                        |  (disposition already reset to SIG_DFL here!)
                        |
   *** 2nd signal *** --+--> DEFAULT ACTION = process killed
                        |
                        v  signal() called to re-arm (too late)

That gap between "handler entered" and "handler re-armed" is a real, exploitable race window. On glibc, signal() happens to give you the safer BSD semantics (persistent handler, restarted calls) — but you cannot rely on that if your code ever moves to another libc or another OS.

sigaction(): everything is explicit

sigaction() replaces the guesswork with a struct you fill in yourself. Nothing is implied; every behaviour is a flag.

struct sigaction {
    void     (*sa_handler)(int);                        /* simple handler   */
    void     (*sa_sigaction)(int, siginfo_t *, void *); /* rich handler     */
    sigset_t   sa_mask;    /* extra signals blocked while handler runs      */
    int        sa_flags;   /* SA_RESTART, SA_SIGINFO, SA_NOCLDSTOP, ...      */
};

sa_handler and sa_sigaction overlap in a union — you use one of them. Which one is selected by whether SA_SIGINFO is set in sa_flags. The handler stays installed across deliveries by default (no one-shot reset), so the entire re-arm race disappears.

The flags that matter

Flag Effect Typical use
SA_RESTART Auto-restart interruptible slow syscalls instead of failing with EINTR You don't want read()/accept() to spuriously fail
SA_SIGINFO Use the 3-arg sa_sigaction handler; deliver a siginfo_t You need the sender's PID, the faulting address, si_code, etc.
SA_NOCLDSTOP For SIGCHLD, don't deliver when a child merely stops/continues You only care about children that actually exit
SA_NOCLDWAIT For SIGCHLD, don't turn dead children into zombies Fire-and-forget children
SA_RESETHAND Deliberately opt in to one-shot reset You genuinely want catch-once behaviour
SA_NODEFER Don't auto-block the signal being handled during the handler Rare; usually a bug magnet

Note SA_RESTART vs the old default cuts both ways. Sometimes you want EINTR — for example, a handler that sets a "please quit" flag needs the blocking read() to return so your loop can notice. Choosing the flag is a design decision, which is precisely why you want it visible.

Knowledge check: You install a handler with signal() on a plain SysV-style system and your program keeps running after the first signal but dies on the second. What happened, and what one flag would you set under sigaction() if — unusually — you actually wanted that die-on-second behaviour?

The SysV signal() reset the disposition to SIG_DFL after the first delivery, so the second signal took the default action (terminate). To reproduce that reset intentionally under sigaction() you'd set SA_RESETHAND. To avoid it (the normal case), you use sigaction() with the flag unset — the handler then persists automatically.

sa_mask: what's blocked while the handler runs

While your handler executes, the kernel automatically blocks the signal being handled (unless SA_NODEFER), so a SIGINT handler won't be re-entered by another SIGINT. sa_mask lets you additionally block other signals for the duration — useful when two handlers touch the same data and you don't want one interrupting the other. You must initialise it with sigemptyset(&sa.sa_mask) (start empty) or sigfillset (block everything), then optionally sigaddset specific signals. Leaving it uninitialised means blocking a random set of signals.

The oldact out-parameter

sigaction()'s third argument, if non-NULL, receives the previous disposition. This lets a library install a handler and restore the caller's on the way out — good manners, and essential for code that must not clobber an application's own handlers.

The rule

Never use signal() in new code. Use sigaction(), zero the struct, initialise sa_mask, and set your flags deliberately.

Syntax notes

#include <signal.h>

int sigaction(int signum,
              const struct sigaction *act,
              struct sigaction *oldact);
  • signum — the signal to configure (e.g. SIGINT). You cannot catch or ignore SIGKILL or SIGSTOP; sigaction() returns -1/EINVAL for those.
  • act — the new disposition to install. If NULL, nothing is changed (query-only).
  • oldact — if non-NULL, filled with the previous disposition so you can restore it later. Safe to pass NULL if you don't care.
  • Returns 0 on success, -1 on error with errno set (check EINVAL, EFAULT). Always check the return value.
struct sigaction {
    void     (*sa_handler)(int signo);
    void     (*sa_sigaction)(int signo, siginfo_t *info, void *ucontext);
    sigset_t   sa_mask;
    int        sa_flags;
    void     (*sa_restorer)(void); /* obsolete; do not use */
};
  • sa_handler — 1-arg handler, OR the constants SIG_DFL (default) / SIG_IGN (ignore). Used when SA_SIGINFO is not set.
  • sa_sigaction — 3-arg handler used when SA_SIGINFO is set. sa_handler and sa_sigaction share storage (a union) — set only one.
  • sa_mask — extra signals blocked during the handler. Must be initialised with sigemptyset/sigfillset before use; never assume it starts empty.
  • sa_flags — bitwise-OR of SA_RESTART, SA_SIGINFO, SA_NOCLDSTOP, etc.

Helpers for signal sets — all return 0/-1:

int sigemptyset(sigset_t *set);            /* clear all */
int sigfillset(sigset_t *set);             /* set all   */
int sigaddset(sigset_t *set, int signo);   /* add one   */
int sigdelset(sigset_t *set, int signo);   /* remove one */

No dynamic memory is involved — nothing to free, no fd to close. The struct is caller-owned; the kernel copies what it needs.

Lesson

The legacy way: signal()

The old way to catch a signal is signal(signo, handler).

It works, but it has a serious trap. On some older Unix systems, the handler is reset to the default action after it fires once.

To keep catching the signal, you would have to re-install the handler from inside the handler itself. That creates a race window: if a second signal arrives before you re-install, it escapes your handler and may kill the program.

The safe way: sigaction()

POSIX (the portable Unix standard) defines a safer replacement: sigaction().

It takes a struct sigaction filled with explicit flags, so you control the behavior precisely.

Flags you'll meet

  • SA_RESTART — automatically restart blocking system calls (read, accept, and similar) instead of returning the EINTR ("interrupted") error.
  • SA_NOCLDSTOP — for SIGCHLD, deliver the signal only when a child exits, not when it stops or continues.
  • SA_SIGINFO — use the 3-argument sa_sigaction handler form, which receives a siginfo_t with extra detail (the sending process ID, the faulting address, and more).

The rule

Never use signal() in new code. Always use sigaction().

Code examples

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

/* Only touched from the handler and the main loop: must be volatile
   sig_atomic_t so reads/writes are atomic w.r.t. signal delivery. */
static volatile sig_atomic_t hits = 0;
static volatile sig_atomic_t last_sender = 0;

/* 3-argument handler form, selected by SA_SIGINFO. siginfo_t carries
   who sent the signal (si_pid) and why (si_code). */
static void on_usr1(int sig, siginfo_t *info, void *ucontext) {
    (void)sig; (void)ucontext;
    hits++;
    last_sender = (info != NULL) ? (sig_atomic_t)info->si_pid : -1;
}

int main(void) {
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);      /* zero every field, portably */
    sa.sa_sigaction = on_usr1;      /* use the 3-arg handler ... */
    sa.sa_flags = SA_SIGINFO | SA_RESTART;  /* ... and restart slow syscalls */
    sigemptyset(&sa.sa_mask);       /* block no extra signals during handler */

    if (sigaction(SIGUSR1, &sa, NULL) != 0) {
        perror("sigaction");
        return 1;
    }

    pid_t me = getpid();
    printf("pid=%d installing SIGUSR1 handler via sigaction\n", (int)me);

    /* Deliver the same signal three times. With sigaction the handler
       stays installed for every one of them -- no re-arming needed. */
    for (int i = 0; i < 3; i++) {
        if (raise(SIGUSR1) != 0) { perror("raise"); return 1; }
    }

    printf("handler fired %d time(s); last sender pid=%d\n",
           (int)hits, (int)last_sender);
    puts(hits == 3 ? "OK: handler survived repeated delivery" : "FAIL");
    return 0;
}

Line by line

  • static volatile sig_atomic_t hits / last_sender — the only variables shared between the handler and main. sig_atomic_t guarantees a read or write is a single, uninterruptible operation, and volatile stops the compiler caching the value in a register (otherwise main might never see the handler's update). This is the correct type for handler↔mainline communication.
  • static void on_usr1(int sig, siginfo_t *info, void *ucontext) — the 3-argument handler shape required by SA_SIGINFO. info gives structured detail; ucontext is the machine context at interruption (rarely used).
  • (void)sig; (void)ucontext; — silence unused-parameter warnings without changing behaviour.
  • hits++; — the whole body is intentionally tiny; the handler does no I/O and calls nothing async-signal-unsafe.
  • last_sender = info->si_pid — reads the sending process's PID straight out of siginfo_t, the payload you only get with SA_SIGINFO.
  • memset(&sa, 0, sizeof sa) — zeroes the whole struct portably before touching individual fields, so no field is left indeterminate.
  • sa.sa_sigaction = on_usr1 — installs the 3-arg handler. Because sa_handler and sa_sigaction share storage, we set only this one.
  • sa.sa_flags = SA_SIGINFO | SA_RESTART — SA_SIGINFO selects the 3-arg form (must match the field we set); SA_RESTART means any interrupted slow syscall resumes instead of failing with EINTR.
  • sigemptyset(&sa.sa_mask) — start the extra-block set empty. Mandatory: an uninitialised sa_mask blocks a random signal set.
  • if (sigaction(SIGUSR1, &sa, NULL) != 0) — install for SIGUSR1; NULL oldact because we don't need the previous disposition. The return value is checked, as it always must be.
  • raise(SIGUSR1) in a loop — sends the signal to our own process three times. The point of the demo: with sigaction, all three are caught (hits == 3) because the handler is never reset — under one-shot signal() semantics you'd catch one and die.
  • Final printf/puts — observable proof: the count is 3 and the sender PID equals our own PID.

Common mistakes

1. Relying on signal() staying installed.

signal(SIGTERM, cleanup);   /* WRONG in portable code */

Why it breaks: on SysV-style systems the disposition resets to SIG_DFL after the first delivery, so a second SIGTERM kills the process before it can clean up — a race with a fatal outcome.

struct sigaction sa; memset(&sa, 0, sizeof sa);
sa.sa_handler = cleanup; sigemptyset(&sa.sa_mask);
sigaction(SIGTERM, &sa, NULL);   /* stays installed */

2. Forgetting to initialise sa_mask.

struct sigaction sa;         /* garbage on the stack */
sa.sa_handler = h;
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL); /* WRONG: sa_mask uninitialised */

Why it breaks: the indeterminate sa_mask blocks an unpredictable set of signals whenever the handler runs, causing signals to be mysteriously delayed or lost.

struct sigaction sa; memset(&sa, 0, sizeof sa);
sa.sa_handler = h;
sigemptyset(&sa.sa_mask);     /* explicit */
sigaction(SIGINT, &sa, NULL);

3. Setting the wrong handler field for the flag.

sa.sa_flags = SA_SIGINFO;    /* promises 3-arg form */
sa.sa_handler = on_int;      /* WRONG: set the 1-arg field */

Why it breaks: the union means you've stored a 1-arg pointer where the kernel will call a 3-arg signature — undefined behaviour, often a crash. Match the field to the flag:

sa.sa_flags = SA_SIGINFO;
sa.sa_sigaction = on_int;    /* 3-arg field */

4. Doing real work inside the handler.

void on_int(int s){ (void)s; printf("bye\n"); free(buf); } /* WRONG */

Why it breaks: printf/malloc/free are not async-signal-safe; if the signal interrupts one of them mid-update, the handler re-enters it and corrupts internal state or deadlocks. Set a flag and act in the main loop:

volatile sig_atomic_t quit = 0;
void on_int(int s){ (void)s; quit = 1; }
/* main loop: if (quit) { printf("bye\n"); free(buf); } */

Debugging tips

  • Confirm the handler is even installed: on Linux, cat /proc/<pid>/status and read the SigCgt (caught) bitmask — bit n-1 set means signal n is being caught. SigBlk shows currently blocked signals.
  • Watch delivery live: strace -e trace=signal ./prog prints each rt_sigaction, and each --- SIGxxx --- as signals arrive, so you can see whether your handler ran and whether syscalls returned EINTR.
  • Diagnose spurious EINTR: if a blocking read()/accept() returns -1 with errno == EINTR, either add SA_RESTART, or wrap the call in a retry loop (while ((n=read(...))<0 && errno==EINTR);). Decide which on purpose.
  • In gdb: handle SIGUSR1 nostop pass lets the signal reach your program while you break inside the handler; set a breakpoint on the handler function to inspect siginfo_t.
  • Check your return values: a common silent failure is sigaction returning -1 for SIGKILL/SIGSTOP or a bad signum; perror("sigaction") immediately reveals it.
  • printf-debugging a handler is itself unsafe; if you must trace from inside, use write(2, msg, len) with a fixed string — write is async-signal-safe, printf is not.

Memory safety

  • Async-signal-safety is the master rule. A handler can interrupt the main thread at any instruction, including mid-way through malloc/free/printf. Calling those from a handler risks heap corruption and deadlock. POSIX defines a small list of safe functions (e.g. write, _exit, signal, sigaction, most raw syscalls). Keep handlers to setting a volatile sig_atomic_t flag and, at most, async-signal-safe I/O.
  • Use volatile sig_atomic_t for shared flags. Any plain variable shared between handler and mainline is a data race: volatile forces re-reads from memory and sig_atomic_t guarantees the access is a single atomic unit. Larger types can be torn (partially updated) when a signal lands mid-write.
  • Initialise the whole struct. memset(&sa, 0, sizeof sa) plus sigemptyset(&sa.sa_mask) avoids acting on indeterminate sa_flags/sa_mask bits — reading an uninitialised object is undefined behaviour.
  • errno is shared state. If your handler calls anything that might set errno, save and restore it (int saved = errno; ... ; errno = saved;) or the main thread will observe a corrupted errno.
  • Threads: signal dispositions are process-wide, but the mask is per-thread. In a multithreaded program a signal is delivered to some arbitrary thread that hasn't blocked it — usually you block signals in all threads and dedicate one thread to sigwait() rather than relying on handlers.

Real-world uses

  • Graceful shutdown of daemons and servers. nginx, redis, postgres and countless services catch SIGTERM/SIGINT with sigaction to flush data, close listening sockets, and exit cleanly; the persistent-handler guarantee means a repeated signal won't kill them mid-flush.
  • Config reload without restart. Catching SIGHUP to re-read a config file is a Unix convention; sigaction ensures the handler survives the first reload.
  • Child reaping. Servers that fork() workers catch SIGCHLD (often with SA_NOCLDSTOP) and waitpid() in a loop to reap zombies.
  • Crash diagnostics. Handlers for SIGSEGV/SIGBUS installed with SA_SIGINFO read si_addr (the faulting address) to log a minidump before exiting — used by crash reporters like Breakpad.
  • Best practice: always sigaction, never signal; keep handlers trivial (set a flag); prefer the self-pipe trick or signalfd/sigwait to turn async signals into synchronous, easily-handled events in your main loop; and in threaded code, block signals everywhere and handle them in one dedicated thread.

Practice tasks

  1. Rewrite a signal(SIGINT, handler) call as an equivalent sigaction() call that produces a persistent handler. Verify with strace -e trace=signal that rt_sigaction is issued and the handler survives two Ctrl+C presses.

  2. Install a SIGINT handler that sets volatile sig_atomic_t quit = 1;, then run a loop that blocks in read(STDIN_FILENO, ...). Install it once with SA_RESTART and once without, and describe how the read() behaves differently (restart vs EINTR) in each case.

  3. Convert a 1-argument handler to the 3-argument SA_SIGINFO form and print info->si_pid and info->si_code. Send the signal from another terminal with kill -USR1 <pid> and confirm the reported sender PID matches that shell.

  4. Use the oldact parameter to save the current SIGTERM disposition, install your own handler, do some work, then restore the original disposition with a second sigaction() call. Prove the restore worked by checking behaviour before and after.

  5. Deliberately reproduce the one-shot signal() bug: install a handler with sigaction using SA_RESETHAND, raise the signal twice, and observe the process taking the default action on the second delivery. Then remove SA_RESETHAND and show both deliveries are caught.

Summary

  • signal() is underspecified: on some systems it resets the handler to the default after one delivery, opening a race where a second signal kills the process. Its syscall-interruption behaviour is also platform-dependent. Avoid it in new code.
  • sigaction() is the POSIX standard: every behaviour is an explicit flag, the handler persists by default, and it exposes richer information.
  • Fill the struct carefully: memset it to zero, set exactly one of sa_handler/sa_sigaction (matching SA_SIGINFO), and always sigemptyset(&sa.sa_mask).
  • Key flags: SA_RESTART (restart slow syscalls vs EINTR), SA_SIGINFO (3-arg handler + siginfo_t), SA_NOCLDSTOP (SIGCHLD on exit only), SA_RESETHAND (opt in to one-shot).
  • Keep handlers tiny and async-signal-safe: set a volatile sig_atomic_t flag and do the real work in the main loop; save/restore errno if needed.
  • Use oldact to restore a previous handler. Always check the return value.

Practice with these exercises