Session 4 · Operating Systems

Signals

What a signal is and where it comes from, handlers with sigaction and what is safe inside them, blocking and sigsuspend instead of a racy pause, the parent–child “ready” handshake, and reaping children with waitpid, WNOHANG and SIGCHLD.

18 min readsigaction vs signalvolatile sig_atomic_tsigprocmask + sigsuspendReady handshakewaitpid and SIGCHLD

After fork and wait you can start processes and collect their exit codes. What you cannot do yet is tell a running process something. Signals are the simplest way: a small, numbered notification that the kernel delivers to a process, interrupting whatever it is doing. There is no data in a signal – only its number – but that is often enough: “stop”, “I am ready”, “a child has finished”.

Signals are also the first place where timing bites. A signal can arrive at any instruction, before you are ready for it, or twice so quickly that you see it once. Most of this session is about writing code that stays correct no matter when the signal comes – the same skill you need later for pipes and semaphores.

The model in one breath: a signal interrupts the process, runs a handler (or the default action), and the process continues. Block the signal until you are ready, wait for it with sigsuspend, and keep the handler tiny.

What a signal is

A signal is a software interrupt. Something happens – you press Ctrl+C, the program divides by zero, a child process exits, another process calls kill() – and the kernel marks a signal for the target process. The next time that process runs, the kernel delivers it (Fig. 1).

The terminal (keyboard)Ctrl+C → SIGINT · Ctrl+\ → SIGQUITThe kernelSIGSEGV · SIGFPE · SIGCHLD · SIGPIPEAnother processkill(pid, SIGUSR1) · kill -TERM pidTarget processdefault action · ignore · own handlerSIGKILL and SIGSTOP: always the default –they cannot be caught or ignored
Fig. 1 · Signals come from the terminal, from the kernel or from other processes. The target reacts in one of three ways.

Every signal has a number and a symbolic name. Use the names in code: the numbers of some signals (for example SIGUSR1, SIGCHLD) differ between architectures. kill -l lists them all. The ones you meet in this course:

SignalNo. on Linux x86Typical sourceDefault action
SIGINT2Ctrl+C in the terminalterminate
SIGQUIT3Ctrl+\ in the terminalterminate + core dump
SIGKILL9kill -9 pidterminate – cannot be caught or ignored
SIGUSR110your own programterminate
SIGSEGV11invalid memory accessterminate + core dump
SIGUSR212your own programterminate
SIGPIPE13writing to a pipe nobody readsterminate
SIGALRM14alarm()terminate
SIGTERM15plain kill pidterminate
SIGCHLD17a child stopped or exitedignore
SIGSTOP19kill -STOP pidstop – cannot be caught or ignored
SIGTSTP20Ctrl+Z in the terminalstop

From the shell, kill sends any signal, not only the deadly ones:

kill -l                 # every signal name and number on this system
kill -TERM 4711         # ask process 4711 to terminate (same as plain kill 4711)
kill -USR1 4711         # send a user-defined signal
kill -9 4711            # SIGKILL – cannot be caught, the process ends immediately

The important property is that delivery is asynchronous: the process does not ask for the signal, it is interrupted by it. The kernel saves where the program was, runs the handler, and resumes the program at the same point (Fig. 2). For the main program it looks as if nothing happened – except that a blocking call such as sleep() or read() may return early, and whatever the handler changed is now different.

main programsignal handlerkill -TERM pidi = 0, 1, 2 … (sleep)handler(SIGTERM)i = 3, 4 …pausedtimestate saved → handler runs → state restored: main continues where it was interrupted
Fig. 2 · The handler runs in the middle of the main program; afterwards main continues where it stopped.
signal
A numbered notification the kernel delivers to a process, interrupting it.
disposition
What a process does with a signal: the default action, ignore it, or run a handler.
handler
A function void f(int signum) that the kernel calls when the signal is delivered.
pending
Generated but not delivered yet – for example because the process blocks the signal.

Default, ignore or handle: signal()

Each signal has a disposition in each process: the default action, SIG_IGN (ignore) or a handler function. The oldest way to set it is signal(signum, handler). The first demo of the session installs a handler for SIGTERM, then tries the same for SIGKILL:

1_signal.c – catching SIGTERM, failing on SIGKILL

void handler(int signumber)
{
    printf("My signal handler %i\n", signumber);   /* demo only – see "What is safe in a handler" */
}

int main()
{
    signal(SIGTERM, handler);              /* SIGTERM now runs handler() instead of ending the program */

    if (signal(SIGKILL, handler) == SIG_ERR)
        perror("signal(SIGKILL)");         /* EINVAL: SIGKILL cannot be caught */

    for (int i = 0; ; i++) {
        printf("i = %i\n", i);
        sleep(1);
    }
}
$ ./signal_demo
signal(SIGKILL): Invalid argument
i = 0
i = 1
My signal handler 15        <- kill -TERM <pid> from another terminal
i = 2
i = 3
Killed                      <- kill -9 <pid>: no handler, no clean-up

SIGTERM is the polite request: a program may catch it, finish its work, close its files and exit. Here the handler only prints, so the loop simply continues – the program has chosen to ignore the request. Ctrl+C still ends it, because SIGINT keeps its default action. SIGKILL and SIGSTOP are reserved for the kernel: signal() refuses them with SIG_ERR and errno = EINVAL, so a process can always be killed or stopped.

sigaction() instead of signal()

signal() is old and its exact behaviour differs between Unix systems: on some, the handler is reset to the default after the first delivery, and interrupted system calls are restarted on some systems but not others. The man page itself recommends sigaction(), which spells everything out in a struct:

Installing a handler with sigaction

struct sigaction sa;
sa.sa_handler = handler;          /* the function to run */
sigemptyset(&sa.sa_mask);         /* signals to block WHILE the handler runs (none extra) */
sa.sa_flags = 0;                  /* no SA_RESTART: a blocking call is interrupted with EINTR */

if (sigaction(SIGUSR1, &sa, NULL) == -1) {   /* NULL: we don't need the old setting back */
    perror("sigaction");
    exit(EXIT_FAILURE);
}
FieldMeaning
sa_handlerThe function to run, or SIG_DFL / SIG_IGN.
sa_maskExtra signals to block while the handler runs. The signal being handled is blocked anyway, so the same handler is never nested.
sa_flagsOptions. SA_RESTART: restart a read(), write() or waitpid() the signal interrupted instead of failing with EINTR.

The third argument receives the previous setting if you want to restore it later; NULL means you don't. Always start sa_mask with sigemptyset() and set sa_flags explicitly – the struct is a local variable with garbage in it.

What is safe inside a handler

The handler can run between any two instructions of your program – including in the middle of printf() or malloc(). Those functions keep internal state (the stdout buffer, the heap lists). If the handler calls the same function while main is halfway through it, both work on half-updated state (Fig. 3).

unsafemain: printf(…)halfway through the bufferhandler: printf(…)runs in the middle of itstdout bufferone per processsafe insteadhandler: flag = 1main prints laterhandler: write()async-signal-safe
Fig. 3 · A handler that calls printf can meet main halfway through its own printf. Set a flag or use write() instead.

POSIX lists the functions that are async-signal-safe – safe to call from a handler. Among them: write(), read(), _exit(), kill(), waitpid(), sigprocmask(). Not on the list: printf(), malloc(), free(), exit() and everything in stdio.h. The teaching examples print from handlers to make the order of events visible; in a program you hand in, follow the rule below.

The rule: the handler sets a flag, main does the work. The flag must be declared volatile sig_atomic_t. sig_atomic_t is an integer type that is read and written in one step, so main never sees half a value. volatile tells the compiler that the variable can change behind its back: without it an optimised build may read the variable once, keep it in a register, and loop forever.

The flag pattern

volatile sig_atomic_t got_term = 0;    /* shared by the handler and main */

void on_term(int sig)
{
    (void)sig;
    got_term = 1;                        /* the only thing the handler does: set a flag */
}

/* in main(), after installing on_term for SIGTERM: */
while (!got_term) {
    do_one_step();
}
cleanup();                               /* printf, free, fclose … are fine here, outside the handler */

If the handler really has to print, use write() on the file descriptor directly:

void on_usr1(int sig)
{
    (void)sig;
    const char msg[] = "SIGUSR1 arrived\n";
    write(STDOUT_FILENO, msg, sizeof(msg) - 1);   /* write() is async-signal-safe, printf() is not */
}

Blocking, pending and waiting

Each process has a signal mask: the set of signals it blocks right now. A blocked signal is not lost – it becomes pending and is delivered the moment it is unblocked. sigprocmask() changes the mask: SIG_BLOCK adds signals, SIG_UNBLOCK removes them, SIG_SETMASK replaces the whole set. Signal sets are built with sigemptyset, sigfillset, sigaddset and sigdelset.

There is a catch. For the classic signals the kernel keeps one pending bit per signal, not a counter. If the same signal arrives twice while it is pending, the second one merges into the first (Fig. 4). Two children that both report with SIGUSR1 can therefore look like one child.

same signal twice: mergedchild 1: SIGUSR1child 2: SIGUSR1pending: SIGUSR1one bit – already sethandler runs onceready_count = 1two different signals: both keptchild 1: SIGUSR1child 2: SIGUSR2pending: SIGUSR1pending: SIGUSR2handler runs twiceready_count = 2
Fig. 4 · One pending bit per signal: two SIGUSR1s can merge; SIGUSR1 + SIGUSR2 are both delivered.

Sending: kill and raise

kill(pid, sig) sends a signal to another process – a child knows its parent from getppid(), the parent knows its children from the return value of fork(). raise(sig) sends a signal to the calling process itself. Neither waits for the signal to be handled.

Waiting: pause and its race

pause() sleeps until any signal is handled. The obvious code – “if the flag is not set yet, pause” – has a gap: if the signal arrives after the check but before pause(), the handler runs too early and pause() then waits for a signal that has already come (Fig. 5). It is rare, which makes it worse: the program hangs once in a hundred runs.

check, then pause() – the racechecks ready == 0about to sleepSIGUSR1 arriveshandler: ready = 1pause()waits for a new onesleeps forevernothing else comesblock, then sigsuspend() – no gapblock SIGUSR1before fork()checks ready == 0signal stays pendingsigsuspend(&old)atomic unblock + waithandler runsloop sees ready = 1The gap between the check and pause() is the race; sigsuspend() closes it.
Fig. 5 · pause() leaves a gap between the check and the sleep. Block first, then sigsuspend() – unblocking and sleeping are one step.

sigsuspend(&mask) closes the gap. It replaces the signal mask with mask and goes to sleep in one atomic step; when a handler has run, it puts the old mask back and returns (always -1 with EINTR). The pattern is: block the signal early, check your flag, and wait with a mask that unblocks it. A signal that came early is pending and is delivered the moment sigsuspend starts.

The sigaction.c example uses it to wait for exactly one signal. The mask passed to sigsuspend blocks everything except SIGTERM, so a SIGUSR1 that arrives meanwhile stays pending and does not wake the parent:

sigaction.c – wait only for SIGTERM

sigset_t sigset;
sigfillset(&sigset);            /* start with every signal blocked … */
sigdelset(&sigset, SIGTERM);    /* … except SIGTERM */

sigsuspend(&sigset);            /* sleep with this mask; only SIGTERM can wake us */
printf("The program comes back from suspending\n");

The program blocks SIGTERM before fork(), so even a child that sends it immediately cannot beat the parent to sigsuspend. When SIGTERM wakes the parent and the old mask comes back, the pending SIGUSR1 is delivered too – its handler runs right after sigsuspend returns.

The parent–child ready handshake

The most common use of signals in this course: the parent starts children and waits until each one says “I am ready” before it sends them work. It looks easy, and the first version is almost right:

2_signal.c – the naive version

int ready = 0;                 /* plain int – the compiler may keep it in a register */

void readyHandler(int sig) { if (sig == SIGUSR1) ready++; }

while (ready < 1)
    ;                          /* busy waiting: 100% CPU, and maybe never sees the change */
puts("Child1 ready!");

It works when you try it – and has three problems:

  1. ready is a plain int. With optimisation the compiler may read it once and spin forever. It must be volatile sig_atomic_t.
  2. Busy waiting: the empty loop burns a whole CPU core while it waits.
  3. Both children send SIGUSR1. If the second one arrives while the first is still pending, they merge (Fig. 4), ready stops at 1 and the parent never continues.

The improved version (2_signal_waitpid_better.c) fixes the first two: it blocks SIGUSR1 before fork(), installs the handler with sigaction() and sleeps in sigsuspend(). Its children still send the same signal, one and two seconds after starting – only the timing keeps the two signals apart. The final version (fork_sigaction.c) removes the timing assumption: child 1 sends SIGUSR1, child 2 sends SIGUSR2, and each has its own pending bit (Fig. 6).

ParentChild 1Child 2block SIGUSR1 + SIGUSR2install the handlerfork()reset signal maskfork()reset signal masksigsuspend(&old_mask)until ready_count == 2SIGUSR1exit(0)SIGUSR2exit(0)restore mask, waitpid × 2
Fig. 6 · The ready handshake: block, install, fork; children report with different signals; the parent sleeps in sigsuspend until both arrived.

The parent, step by step. First block both signals and remember the old mask:

Parent – before fork

sigset_t block_mask, old_mask;
sigemptyset(&block_mask);
sigaddset(&block_mask, SIGUSR1);
sigaddset(&block_mask, SIGUSR2);
sigprocmask(SIG_BLOCK, &block_mask, &old_mask);   /* from now on they only become pending */

/* install the handler with sigaction, then fork the children */
pid_t c1 = child1();
pid_t c2 = child2();

One handler serves both signals and counts. Its sa_mask blocks both, so if they arrive together the second waits until the first handler has finished its ready_count++:

One handler, both signals blocked while it runs

sa.sa_handler = signal_handler;        /* one handler for both signals: ready_count++ */
sigemptyset(&sa.sa_mask);
sigaddset(&sa.sa_mask, SIGUSR1);       /* while the handler runs, the other signal waits */
sigaddset(&sa.sa_mask, SIGUSR2);
sa.sa_flags = 0;
sigaction(SIGUSR1, &sa, NULL);
sigaction(SIGUSR2, &sa, NULL);

Then the parent sleeps until both have reported:

Parent – waiting without busy waiting

while (ready_count < 2)
    sigsuspend(&old_mask);     /* atomically: unblock, sleep, run the handler, block again */

sigprocmask(SIG_SETMASK, &old_mask, NULL);   /* back to the original mask */

The while loop matters: sigsuspend returns after any handled signal, so the parent checks the counter every time it wakes up. The child side is short. It inherited the blocked mask, so it clears it first (it does not need it, and a child that execs another program would otherwise pass it on), then it reports:

Child – report ready

pid_t child1(void)
{
    pid_t process = fork();
    if (process == -1)
        exit(EXIT_FAILURE);
    if (process > 0)
        return process;                          /* parent: back to main */

    sigset_t empty;                              /* the child inherited the blocked mask – reset it */
    sigemptyset(&empty);
    sigprocmask(SIG_SETMASK, &empty, NULL);

    kill(getppid(), SIGUSR1);                    /* "I am ready" (child2 sends SIGUSR2) */
    exit(EXIT_SUCCESS);
}

Children ending: waitpid and SIGCHLD

A child that has exited stays a zombie until its parent collects its exit status. You know wait() from the previous session; waitpid(pid, &status, options) waits for one specific child (or any child with pid = -1). With signal handlers around, a blocking waitpid can be interrupted and return -1 with errno == EINTR – retry in that case:

Waiting for one child and decoding the status

int status;
pid_t done;
do {
    done = waitpid(child_pid, &status, 0);       /* 0: block until this child ends */
} while (done == -1 && errno == EINTR);          /* a signal handler interrupted the wait: retry */

if (WIFEXITED(status))
    printf("exited with code %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status))
    printf("killed by signal %d\n", WTERMSIG(status));

status is not the exit code. It is an encoded number (Fig. 7), and printing it raw is confusing: exit(1) shows up as 256, exit(-1) as 65280. The exit code is only 8 bits, so -1 becomes 255. Always decode with WIFEXITED/WEXITSTATUS and WIFSIGNALED/WTERMSIG.

statusbits 15–8: exit codeWEXITSTATUS(status)bit 7corebits 6–0: signalWTERMSIG(status)exit(-1)1111 1111 = 2550000 0000 = 0= 0xFF00 = 65280 → WIFEXITED true, WEXITSTATUS 255kill -90000 00000000 1001 = 9= 9 → WIFSIGNALED true, WTERMSIG 9 (SIGKILL)
Fig. 7 · On Linux, the exit code sits in bits 8–15 of the status, the number of a fatal signal in bits 0–6.

Not waiting: WNOHANG

With the WNOHANG option waitpid never blocks. It returns the child's PID if that child has finished, 0 if it is still running, and -1 on error. That is how a parent can check on its children while it keeps working:

pid_t r = waitpid(child_pid, &status, WNOHANG);
if (r == 0)
    puts("still running");                       /* WNOHANG: don't wait, answer immediately */
else if (r == child_pid)
    puts("finished – status is valid");
else
    perror("waitpid");

SIGCHLD

Whenever a child exits (or stops), the kernel sends SIGCHLD to the parent. The default action is to ignore it, which is why you never noticed. A parent that starts many children and does not want to sit in waitpid can reap them from a SIGCHLD handler (Fig. 8). waitpid is async-signal-safe, so it may be called there – in a loop, because one SIGCHLD can stand for several finished children:

child: exit(0)the process endszombieexit status keptSIGCHLD → parentdefault: ignoredwaitpid(WNOHANG)in a loop, never blocksremoves the zombieTwo children ending close together can give one SIGCHLD – so the handler loops until waitpid returns 0.
Fig. 8 · A finished child is a zombie until waitpid collects it; a SIGCHLD handler can do that as soon as it happens.

Reaping children from a SIGCHLD handler

void on_child(int sig)
{
    (void)sig;
    int saved = errno;                           /* waitpid may change errno – restore it */
    while (waitpid(-1, NULL, WNOHANG) > 0)       /* reap EVERY finished child, never block */
        ;
    errno = saved;
}

/* in main(), before the first fork(): */
sa.sa_handler = on_child;
sa.sa_flags = SA_RESTART;                        /* interrupted read/write calls continue */
sigaction(SIGCHLD, &sa, NULL);

The handler saves and restores errno, because main may be just about to read errno from a failed call. SA_RESTART makes interrupted read()/write() calls continue, so the rest of the program does not have to handle EINTR after every child that ends.

Signals in the shell: trap

Shell scripts receive the same signals. trap sets what the script runs when one arrives – typically to delete temporary files before exiting. Use straight ASCII quotes: a command copied with typographic quotes (‘…’) from a document is a syntax error in the shell.

trap 'rm -f /tmp/work$$; exit' INT TERM   # clean up, then exit, on Ctrl+C or kill
trap 'echo Testing...' INT                 # just print, keep running
trap - INT                                 # back to the default action

trap - INT restores the default action (bash also accepts trap INT). Details and examples are in the Computer Systems session on trap.

Practice

Task 1

Stop politely

Write a program that prints a counter every second. On SIGTERM it must finish the current step, print how far it got and exit with code 0. kill -9 should still end it immediately – check that you cannot change that.

Approach

A volatile sig_atomic_t flag set by the handler, installed with sigaction. The loop checks the flag; all printing happens in main. sleep() returns early when a signal is handled.

volatile sig_atomic_t stop = 0;
void on_term(int sig) { (void)sig; stop = 1; }

while (!stop) {
    printf("working %d\n", i++);
    sleep(1);                    /* a signal cuts the sleep short – the loop checks stop at once */
}

Task 2

Wait for a ready child

The parent forks one child. The child prints its PID, sends SIGUSR1 to the parent and exits. The parent prints “child ready” only after the signal arrived, then waits for the child and prints its exit code with WEXITSTATUS. Then put a sleep(1) in the parent between fork() and the waiting, so the signal surely arrives early, and check that the parent still never hangs.

Approach

Block SIGUSR1 and install the handler before fork(); wait with a sigsuspend loop on the old mask; restore the mask afterwards.

/* parent, before fork(): */
sigprocmask(SIG_BLOCK, &usr1_only, &old_mask);

/* parent, after fork(): */
while (!child_ready)
    sigsuspend(&old_mask);

/* child: */
kill(getppid(), SIGUSR1);

Task 3

Three children, three signals

Extend the handshake to three children. Children 1 and 2 report with SIGUSR1 and SIGUSR2. What can child 3 use? Print which child is ready, in the order the signals arrive.

Approach

There are only two user signals, but the real-time signals SIGRTMIN … SIGRTMAX are free to use and – unlike the classic ones – are queued, so repeated ones are not merged. Another option: a separate handler per signal, each setting its own flag. Either way, block all of them before forking.

Task 4

Reap without waiting

Start five children that sleep a random 1–5 seconds and exit. The parent keeps printing a dot every half second and, when all five have finished, prints how many it collected. No child may stay a zombie.

Approach

Either poll with waitpid(-1, &status, WNOHANG) in the main loop, or reap in a SIGCHLD handler that only counts. Seed the random generator in each child differently (for example with its PID), otherwise all five sleep the same time.

while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
    reaped++;                    /* in the handler: count only, print later in main */
}

Summary

  • A signal is a numbered, data-less notification; it interrupts the process, runs the handler or the default action, and the process continues.
  • Ctrl+C is SIGINT, Ctrl+\ is SIGQUIT, Ctrl+Z is SIGTSTP; Ctrl+D is end of input, not a signal. SIGKILL and SIGSTOP cannot be caught.
  • Install handlers with sigaction() and check the return value; signal() behaves differently on different systems.
  • In a handler: set a volatile sig_atomic_t flag or call write(). No printf, no malloc.
  • A blocked signal is pending, not lost – but one pending bit per signal means identical signals can merge. Use different signals for different senders.
  • Block before fork(), wait with a sigsuspend loop. pause() has a race.
  • Collect every child with waitpid, retry on EINTR, decode with WIFEXITED/WEXITSTATUS; WNOHANG and SIGCHLD let you reap without blocking.