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.
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).
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:
| Signal | No. on Linux x86 | Typical source | Default action |
|---|---|---|---|
SIGINT | 2 | Ctrl+C in the terminal | terminate |
SIGQUIT | 3 | Ctrl+\ in the terminal | terminate + core dump |
SIGKILL | 9 | kill -9 pid | terminate – cannot be caught or ignored |
SIGUSR1 | 10 | your own program | terminate |
SIGSEGV | 11 | invalid memory access | terminate + core dump |
SIGUSR2 | 12 | your own program | terminate |
SIGPIPE | 13 | writing to a pipe nobody reads | terminate |
SIGALRM | 14 | alarm() | terminate |
SIGTERM | 15 | plain kill pid | terminate |
SIGCHLD | 17 | a child stopped or exited | ignore |
SIGSTOP | 19 | kill -STOP pid | stop – cannot be caught or ignored |
SIGTSTP | 20 | Ctrl+Z in the terminal | stop |
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 immediatelyThe 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.
- 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-upSIGTERM 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);
}| Field | Meaning |
|---|---|
sa_handler | The function to run, or SIG_DFL / SIG_IGN. |
sa_mask | Extra signals to block while the handler runs. The signal being handled is blocked anyway, so the same handler is never nested. |
sa_flags | Options. 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).
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.
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.
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:
readyis a plainint. With optimisation the compiler may read it once and spin forever. It must bevolatile sig_atomic_t.- Busy waiting: the empty loop burns a whole CPU core while it waits.
- Both children send
SIGUSR1. If the second one arrives while the first is still pending, they merge (Fig. 4),readystops 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).
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.
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:
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 actiontrap - 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+\ isSIGQUIT, Ctrl+Z isSIGTSTP; Ctrl+D is end of input, not a signal.SIGKILLandSIGSTOPcannot 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_tflag or callwrite(). Noprintf, nomalloc. - 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 asigsuspendloop.pause()has a race. - Collect every child with
waitpid, retry onEINTR, decode withWIFEXITED/WEXITSTATUS;WNOHANGandSIGCHLDlet you reap without blocking.