Processes, Jobs and Signals · about 45 min

Signals and kill

Why this matters

Signals are how the world talks to a running process from the outside: Ctrl-C at the keyboard, kill from another shell, a container runtime stopping a container, systemd shutting down a service, a pipe whose reader went away. A script that ignores signals leaves half-written files and orphaned children when it is stopped. A script that handles them can stop cleanly. You met trap for cleanup in module 12; this lesson explains signals themselves, how they are delivered, and how to send them.

What a signal is

A signal is a small numbered notification that the kernel delivers to a process asynchronously, that is, at any moment, between any two instructions. Each process has, for each signal, a disposition: default action, ignore, or "call this handler". When a signal arrives the kernel interrupts the process and applies the disposition.

The ones you will use:

NumberNameTypical senderDefault action
1SIGHUPterminal closed; by convention "reload config" for daemonsterminate
2SIGINTCtrl-Cterminate
3SIGQUITCtrl-\\terminate and dump core
9SIGKILLkill -9, the out-of-memory killerterminate, cannot be caught
13SIGPIPEwriting to a pipe with no readerterminate
15SIGTERMkill, shutdowns, docker stopterminate
17SIGCHLDa child stopped or exitedignore
18SIGCONTfg, bg, kill -CONTcontinue if stopped
19SIGSTOPkill -STOPstop, cannot be caught
20SIGTSTPCtrl-Zstop
10, 12SIGUSR1, SIGUSR2your own programsterminate

kill -l lists them all with numbers. Numbers can differ between operating systems (these are for Linux on x86 and ARM), which is why scripts should use names.

Exit statuses from signals

When a process dies from a signal, the kernel tells the parent "killed by signal N" rather than an exit code; bash reports that in $? as 128 + N:

$ sleep 100 &
[1] 61
$ kill 61; wait 61; echo $?
143
[1]+  Terminated              sleep 100
$ sleep 100 &
[1] 62
$ kill -9 62; wait 62; echo $?
137
[1]+  Killed                  sleep 100

143 = 128 + 15 (TERM), 137 = 128 + 9 (KILL), 130 = Ctrl-C (INT). When you see 137 in a container log, something (often the out-of-memory killer) sent SIGKILL.

Sending signals: kill, pkill, killall

kill PID               # SIGTERM
kill -TERM PID         # the same, explicitly; also kill -s TERM PID, kill -15 PID
kill -INT PID          # as if Ctrl-C
kill -KILL PID         # last resort
kill -0 PID            # sends nothing: only checks the process exists and you may signal it
kill %1                # interactive: a job number
pkill -TERM -f 'python3 server.py'   # by name or full command line (-f)
pgrep -a sleep         # list matching PIDs and command lines first: a dry run for pkill

kill is a bash builtin (so it understands %1), and there is also /usr/bin/kill. You may signal only your own processes, unless you are root. Here the sandbox user cannot touch processes of other users; on a real system kill 1 as a normal user gives bash: kill: (1) - Operation not permitted.

kill -0 is handy in scripts: if kill -0 "$pid" 2>/dev/null; then echo running; fi.

TERM first, KILL last

SIGTERM asks a process to stop and gives it the chance to finish writing, delete temporary files, tell its children and exit with a sensible status. SIGKILL gives it no chance: open files are closed, half-written data stays half-written, temporary files stay, child processes are orphaned. The professional sequence, used by systemd and container runtimes:

kill -TERM "$pid"
for i in 1 2 3 4 5 6 7 8 9 10; do
  kill -0 "$pid" 2>/dev/null || break   # gone?
  sleep 1
done
kill -0 "$pid" 2>/dev/null && kill -KILL "$pid"

kill -9 as a first resort is a habit worth breaking.

How delivery works, and what bash does with it

The kernel marks the signal as pending for the process. The next time that process is about to run in user space, the kernel checks for pending signals and, for a caught signal, makes the process jump into its handler; for an uncaught terminating signal it ends the process. A process blocked in a system call (reading, waiting) is woken up so the signal can be handled.

bash's handlers are your trap strings, and bash runs them between commands. When bash is waiting for a foreground child, a trapped signal is remembered and the handler runs after the child exits. Two consequences:

trap 'echo "got TERM"; exit' TERM
sleep 60        # kill -TERM <script PID>: the handler runs only after sleep finishes!

If only the script receives the signal (not its child), the handler waits up to 60 seconds. The fix is to wait in a way that can be interrupted: run the long command in the background and use the wait builtin, which returns immediately when a trapped signal arrives:

trap 'echo "got TERM"; kill "$child" 2>/dev/null; exit 143' TERM
sleep 60 &
child=$!
wait "$child"

Or keep the foreground steps short, as in the exercises (sleep 0.1 per loop iteration).

Signals are not queued per occurrence: two SIGTERMs arriving before the first is handled count as one. And signals sent to a process do not automatically reach its children; Ctrl-C reaches all of a foreground job only because the terminal signals the whole process group (lesson 2). kill -TERM -- -PGID (a negative number) signals a whole process group yourself.

What children inherit

After fork, a child has the same dispositions as its parent. After exec, caught signals are reset to default (the handler code is gone, replaced by the new program), but ignored signals stay ignored. That is how nohup works: it sets SIGHUP to ignore and then execs your command. bash also resets traps in subshells, so a ( ... ) child starts without the parent's traps, which is why the exercise installs the trap inside the subshell.

SIGPIPE: the signal you meet every day

$ yes | head -n 2
y
y
$ echo "${PIPESTATUS[@]}"
141 0

yes prints y forever. head exits after two lines, closing the read end of the pipe. The next time yes writes, the kernel sends it SIGPIPE, and it dies with 141 (128 + 13). This is a feature: it is how producer | head stops a producer that would otherwise run forever. It becomes a nuisance under set -o pipefail (module 12), where the 141 fails the pipeline. A handler or trap '' PIPE (ignore) makes the write fail with an EPIPE error instead, and well-behaved programs then exit quietly.

Writing a graceful script

#!/usr/bin/env bash
set -euo pipefail

running=1
on_signal() { echo "stopping after current item" >&2; running=0; }
trap on_signal TERM INT

for item in queue/*; do
  [ "$running" = 1 ] || break
  process "$item"           # each item is finished before we check the flag
done
echo "processed up to $item" >&2

Setting a flag in the handler and checking it at safe points is the most robust pattern: the work is never interrupted in the middle of an item.

Common mistakes

Exercises

A quiz on signal facts, then two scripts. In the first you add a handler to a child process that its parent terminates; in the second the checker itself plays the part of systemd and sends your script SIGTERM.

Recap

Going further: man 7 signal lists every signal with its default action (it is installed in this sandbox). Try kill -STOP and kill -CONT on a sleep 100 & and watch its state in ps -o pid,stat,cmd.

Exercises

Exercise 1 · quiz

Signal facts

Which statements are true? Select all that apply.

Answer
  • kill PID with no signal option sends SIGTERM, which the process may catch to clean up.

  • A process killed by SIGTERM shows exit status 143 in the shell.

  • In yes | head -n 1, yes is ended by SIGPIPE (status 141) when it writes after head has exited.

kill sends SIGTERM (15) by default: a polite request that the process can handle. SIGKILL (9) and SIGSTOP can never be caught, blocked or ignored; the kernel acts directly. The shell reports "killed by signal N" as 128+N, so 143 for TERM, 137 for KILL, 130 for INT, 141 for PIPE. And despite its name, kill just sends signals: kill -STOP, kill -CONT, kill -HUP (often "reload your configuration"), kill -USR1 and so on.

Exercise 2 · write code

Teach the child to clean up

The starter starts a background subshell (the "child") that works forever, waits until the child signals it is ready by creating a file, then sends it SIGTERM and reports its exit status. Right now the child has no handler, so it is simply killed and the script prints child exited with 143.

Add a trap inside the child's subshell, before it creates the ready file, so that on SIGTERM the child prints child: got TERM, cleaning up and exits with status 0. The expected output is exactly:

child: got TERM, cleaning up
child exited with 0

Do not change the handshake or the kill/wait lines.

File: script.sh

Starting point

ready=$(mktemp -u /tmp/ready.XXXXXX)
(
  # set the TERM trap here, before announcing that we are ready
  touch "$ready"
  while :; do sleep 0.1; done
) &
child=$!
while [ ! -e "$ready" ]; do sleep 0.05; done
rm -f "$ready"
kill -TERM "$child"
wait "$child"
echo "child exited with $?"

Example

expected output

child: got TERM, cleaning up
child exited with 0
Hint 1

trap 'commands' TERM inside the parentheses installs a handler in the child only.

Hint 2

The handler needs two commands: the echo, and exit 0; otherwise the loop just continues.

Hint 3

trap 'echo "child: got TERM, cleaning up"; exit 0' TERM

Reference solution
ready=$(mktemp -u /tmp/ready.XXXXXX)
(
  trap 'echo "child: got TERM, cleaning up"; exit 0' TERM
  touch "$ready"
  while :; do sleep 0.1; done
) &
child=$!
while [ ! -e "$ready" ]; do sleep 0.05; done
rm -f "$ready"
kill -TERM "$child"
wait "$child"
echo "child exited with $?"

Exercise 3 · write code

A daemon that stops gracefully

Write a long-running script that shuts down cleanly when asked:

  1. At start, write its own PID ($$) into the file script.pid in the current directory.
  2. Then loop forever doing "work": sleep 0.1 per iteration.
  3. On SIGTERM or SIGINT: remove script.pid, print shutting down and exit with status 0.

The checker starts your script in the background, waits for script.pid to appear, sends SIGTERM to the PID it contains, then checks the output, the exit status and that script.pid is gone. (Your script would otherwise run forever, so it must not exit by itself.)

File: script.sh

Starting point

echo $$ > script.pid
while :; do
  sleep 0.1
done
Hint 1

Put the shutdown steps in a function and install it with trap shutdown TERM INT before the loop.

Hint 2

The handler must end with exit 0, otherwise the loop resumes after the handler returns.

Reference solution
shutdown() {
  rm -f script.pid
  echo "shutting down"
  exit 0
}
trap shutdown TERM INT

echo $$ > script.pid
while :; do
  sleep 0.1
done