Signals, how the OS interrupts a process
Exit codes from lesson 3-3 are a dying process's last message to its parent. Signals go the other direction: they are how the OS, or another process, interrupts a running process right now.
Every Ctrl-C pressed to stop a runaway script, every clean server shutdown during a deploy, and the kill command from lesson 3-2 work through signals. This is the standard mechanism for stopping anything on macOS or Linux, and you will use it within your first week of running real software.
A signal is a small numbered message the kernel delivers to a process. The process does not check for it, the kernel interrupts the process mid-run. The process can register a handler, a function of its own that runs when a given signal arrives, or accept the default effect.
| Signal | Number | Usually sent by | Default effect |
|---|---|---|---|
SIGINT | 2 | Ctrl-C in the terminal | terminate |
SIGTERM | 15 | kill <pid>, deploy tools | terminate, but a handler may clean up first |
SIGKILL | 9 | kill -9 <pid> | terminate immediately, cannot be caught or ignored |
Now the two kill commands from lesson 3-2 read precisely. kill 4021 sends SIGTERM, a request the program may handle to finish cleanly. kill -9 4021 sends SIGKILL, and the kernel simply erases the process with no handler, no cleanup, and no goodbye.
Catching a signal a process sends to itself
A handler registered for SIGTERM turns a fatal default into a printed line.
import signal import os def handler(signum, frame): print("got signal", signum) signal.signal(signal.SIGTERM, handler) os.kill(os.getpid(), signal.SIGTERM) print("still running")
Output
got signal 15
still runningsignal.signal registers a handler function for a given signal, and os.kill sends one. Despite the name, os.kill can send any signal rather than only fatal ones, and here the process signals itself.
The number 15 is SIGTERM's number from the table above. Without the handler the default effect would have terminated the process before the last print ever ran, which is the entire difference a handler makes.
Terminating a child and reading the negative code
The parent starts a child that would sleep for 60 seconds, then stops it early.
import subprocess import sys import time proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) time.sleep(0.5) proc.terminate() code = proc.wait() print("exit code:", code) print("negative means killed by a signal:", code < 0) print("signal number:", -code)
Output
exit code: -15 negative means killed by a signal: True signal number: 15
proc.terminate() sends SIGTERM. When a signal kills a child, subprocess reports the exit code as the negative of the signal number, so the parent can tell "exited with an error" apart from "was killed".
The child never got to finish its sleep, because the kernel delivered SIGTERM and, with no handler registered, applied the default effect. Calling proc.kill() instead would send SIGKILL, number 9, and the reported code would be -9.
The graceful-shutdown pattern
SIGTERM allows a handler while SIGKILL does not, because both needs are real.
Production servers register a SIGTERM handler that stops accepting new work, finishes the requests already in flight, and saves state before exiting. Killing a database mid-write without that step is how data gets corrupted.
But if a process is stuck or malicious, the system still needs a way to remove it that the process cannot veto. That is SIGKILL, enforced by the kernel alone.
| Signal | Catchable | Purpose |
|---|---|---|
| SIGTERM | yes | ask politely, allow cleanup |
| SIGKILL | no | guarantee removal |
Deploy systems bake this in. Docker and Kubernetes stop a container by sending SIGTERM, waiting a grace period of roughly 10 to 30 seconds, and only then sending SIGKILL.
If a service you ship ignores SIGTERM, every deploy ends with it being shot mid-request. Handling SIGTERM is not optional polish, it is expected of real services.
A graceful-shutdown skeleton
The handler sets a flag rather than doing the work, and the main flow acts on the flag.
import signal import os shutting_down = False def handler(signum, frame): global shutting_down shutting_down = True signal.signal(signal.SIGTERM, handler) os.kill(os.getpid(), signal.SIGTERM) if shutting_down: print("finishing current work, then exiting cleanly")
Output
finishing current work, then exiting cleanly
The handler needs global shutting_down before assigning, otherwise Python creates a local variable and the flag never changes. That single missing line is the most common reason a shutdown handler appears to do nothing.
Setting a flag is the recommended shape for a real service. A handler runs at an arbitrary point mid-instruction, so doing heavy work inside it is risky, while flipping a boolean and letting the normal loop notice is safe.
Why deploys use SIGTERM before SIGKILL
A deploy tool that sends SIGTERM, waits 15 seconds, and only then sends SIGKILL is buying the best of both behaviors.
SIGTERM gives the process a chance to finish in-flight work and save state through its handler. SIGKILL is the fallback that cannot be vetoed, for processes that do not exit.
SIGTERM is catchable precisely so services can shut down cleanly: stop taking requests, finish current ones, flush and save. SIGKILL exists because the system must retain a way to remove any process regardless of what its code does, and the kernel enforces it with no handler running at all.
| Phase | Signal | Outcome for a well-behaved service |
|---|---|---|
| immediately | SIGTERM | begins draining, stops new work |
| within the grace period | none | finishes and exits on its own |
| after the grace period | SIGKILL | never reached |
The grace period is the contract between the two. Sending SIGKILL first would guarantee removal but also guarantee dropped requests, and sending only SIGTERM would leave a hung process alive forever.