JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Signals and Signal Handlers

Pipes and message queues carry bytes between processes. A signal carries almost nothing — just a number that interrupts a process mid-stride and says 'deal with this now'. Here is how that tiny, jarring kind of message works, why writing a signal handler is one of the easiest places in all of C to write a bug, and the one disciplined way to do it right.

A different shape of message entirely

The earlier guides in this rung gave you channels for bytes. An anonymous pipe or a named pipe is a stream you read() and write() like a file; a message queue hands you whole messages with boundaries. All of them share one shape: a sender deposits data, a receiver picks it up when it is ready, and nobody is forced to stop what they are doing. A signal is the opposite shape. It is a form of inter-process communication that carries no real payload at all — just a small integer naming which signal — and instead of waiting politely in a buffer, it reaches in and interrupts the target process between two ordinary instructions.

Think of it as the difference between mailing a letter and tapping someone hard on the shoulder. The pipe is the letter: it sits in the mailbox until the reader chooses to open it. The signal is the tap: whatever you were in the middle of, you stop, turn around, and respond now. That asynchrony — arriving at a moment you did not choose — is the whole personality of a signal, and it is the source of both its usefulness and nearly every difficulty in handling one correctly. You have actually met signals already without naming them: pressing Ctrl-C at a shell, or a program dying on a segmentation fault, are both signals being delivered.

The cast of common signals

There are a few dozen signals, each with a name like SIGINT and a fixed small number, but you only need a handful to start. Among the common signals: SIGINT (number 2) is what Ctrl-C sends — 'interrupt, the user wants you to stop'. SIGTERM (15) is the polite 'please terminate', the default of the kill command and the standard way to ask a server to shut down. SIGKILL (9) is the un-ignorable 'die now', which the process never even sees. SIGSEGV (11) is delivered when you dereference a bad pointer. SIGCHLD arrives when one of your child processes changes state, which ties straight back to the wait-and-reaping you saw in the process rung. And SIGPIPE shows up when you write to a pipe whose reader is gone.

Every signal arrives with a built-in default action that happens if you do nothing about it. For most signals the default is to terminate the process — that is why an un-handled Ctrl-C just kills your program. Some defaults additionally dump core (SIGSEGV, SIGABRT) so a core dump is left for the debugger; a few signals are ignored by default (SIGCHLD); and SIGSTOP simply freezes the process. The reason an un-handled program dies on Ctrl-C or on a bad pointer is not magic — it is the default action of SIGINT and SIGSEGV quietly running because you never asked to do anything else.

Sending a signal, and what the kernel does with it

Sending a signal is a system call, so it crosses into the kernel exactly the way the OS-interface rung described. The basic call for sending a signal is kill(pid, sig) — confusingly named, because despite 'kill' it sends any signal, not only deadly ones; kill(pid, SIGTERM) asks a process to quit, while a process can even signal itself with raise(sig). The shell command kill is just a thin wrapper over this syscall, and Ctrl-C is the terminal driver calling it for you. Crucially, the kernel checks permissions first: you cannot signal arbitrary processes, only ones you own (or as the superuser). That check is what stops any program from killing any other.

Once the kernel accepts the signal, delivery is a two-step affair that is worth picturing precisely. First the signal becomes pending: the kernel sets a bit in the target process's record saying 'a SIGINT is waiting'. Nothing visible happens yet — the bit just sits there. Then, at the next safe moment the kernel runs that process (typically as it returns from a syscall or interrupt back to user mode), it notices the pending bit and delivers the signal: it diverts the process to run whatever action is configured before letting ordinary execution resume. That gap between pending and delivered is why a signal sent while the process is busy in the kernel does not corrupt anything — it waits for a clean seam.

One sharp consequence of using a single pending bit per signal: standard signals do not queue. If three SIGINTs arrive while one is still pending and undelivered, the bit is already set, so you may see only one delivery, not three. This is a real and often surprising limitation — you cannot use a counter of plain signals to count events reliably. (POSIX real-time signals do queue, at extra cost, but the everyday ones like SIGINT do not.) Treat a signal as 'at least one of these happened', never as an exact tally.

Catching one: the signal handler

Instead of letting the default action run, you can install your own signal handler — a function of yours the kernel will jump to when the signal is delivered. A signal handler is registered with sigaction() (or the older, more portable-but-quirky signal()); you give it the signal number and a function pointer, and from then on, when that signal arrives, the kernel pauses your normal code, calls your handler, and resumes the normal code right where it left off afterwards. This is how Ctrl-C becomes 'save my work and exit cleanly' instead of an abrupt death: you catch SIGINT and do the cleanup yourself.

Now the hard truth, stated plainly because hiding it makes dangerous programmers: a signal handler runs in a genuinely treacherous context, and most code you would naively put in one is wrong, even if it appears to work. The handler can fire at literally any instruction boundary in your program — including in the middle of malloc() rearranging the heap's internal lists, or in the middle of printf() updating a stdio buffer. If your handler then calls malloc() or printf() itself, it re-enters a function that was halfway through mutating shared state, and the result is corruption, a deadlock, or a crash that appears only once in a thousand runs. This is the asynchrony from the first section coming back to bite.

Two keywords in that flag declaration earn their place and connect to ideas from earlier rungs. The `volatile` qualifier from const and volatile tells the compiler that the variable can change behind its back — without it, the optimizer may cache the flag in a register and your main loop will never see the handler's update, a bug that famously vanishes at -O0 and reappears at -O2. The sig_atomic_t type guarantees that reading or writing the flag is a single, indivisible operation, so the main loop can never catch it half-updated. Honest naming: this gives you exactly one safe bit of communication out of a handler, which is why the set-a-flag pattern is so spare.

volatile sig_atomic_t got_sigint = 0;

void on_sigint(int signo) {
    got_sigint = 1;          /* the ONLY safe thing to do here */
}

int main(void) {
    struct sigaction sa = {0};
    sa.sa_handler = on_sigint;
    if (sigaction(SIGINT, &sa, NULL) == -1) {   /* check the syscall! */
        perror("sigaction");
        return 1;
    }
    while (!got_sigint) {
        /* ... real work runs out here, where printf/malloc ARE safe ... */
    }
    /* clean shutdown happens here, in normal context */
    return 0;
}
The set-a-flag pattern: the handler only sets a volatile sig_atomic_t; all real work, and all unsafe calls, happen back in the main loop.

The signal you will trip over: SIGPIPE

There is one signal worth meeting in detail right now, because it grows straight out of the pipes from guide 1 of this rung and surprises nearly everyone. When you write() to a pipe, FIFO, or socket whose reader has closed its end — there is no one left to receive — the kernel sends your process SIGPIPE, the broken-pipe signal. Its default action is to terminate the process. So a program happily writing to a pipeline can vanish without a word the instant the downstream reader exits, and the first time it happens it looks like an inexplicable crash with no error message at all.

This is exactly the mechanism behind a shell pipeline like `producer | head`: when head has read enough lines and exits, the next write() in producer triggers SIGPIPE, and producer dies — which is precisely the behaviour you want, so the pipeline tears down cleanly. But inside your own program you usually want to handle the broken connection yourself rather than die. The standard fix is to ignore SIGPIPE by setting its action to SIG_IGN. Then write() no longer kills you; instead it returns -1 and sets errno to EPIPE, turning a sudden death into an ordinary error you can check and recover from with the error-handling discipline of the earlier rung.