Processes and Signals¶
What You'll Learn¶
- What a process is, how processes are created, and what each process state means
- How to inspect processes with
ps,pgrep, and/proc - How signals work, and why graceful shutdown depends on handling
SIGTERM - How to deal with zombies, stuck processes, priorities, open files, and limits
Mental Model¶
A process is a running program with its own memory, open files, environment, and a numeric PID. Every process except PID 1 has a parent. The kernel schedules processes, delivers signals to them, and records their exit status for the parent to collect.
flowchart TD
S["systemd (PID 1)"] --> SSH["sshd"]
S --> N["nginx master"]
N --> W1["nginx worker"]
N --> W2["nginx worker"]
SSH --> B["bash (your session)"]
B --> P["python deploy.py"]
New processes are created by fork() (copy the parent) and usually exec() (replace the copy with a new program). That's why a child inherits its parent's environment variables, working directory, and open file descriptors.
Inspecting Processes¶
ps aux --sort=-%cpu | head # top CPU consumers
ps -eo pid,ppid,user,stat,%mem,etime,cmd --forest | less # tree with elapsed time
pgrep -a nginx # PIDs and command lines by name
pstree -p $(pgrep -o nginx) # tree under the oldest nginx
top -o %MEM # interactive, sorted by memory
Process states¶
The STAT column tells you what a process is doing:
| State | Meaning | What to think |
|---|---|---|
R |
Running or runnable | Using or waiting for CPU |
S |
Interruptible sleep | Waiting for an event — normal for idle services |
D |
Uninterruptible sleep | Waiting on I/O, usually disk or NFS; can't be killed until the I/O returns |
T |
Stopped | Paused by a signal or debugger |
Z |
Zombie | Exited, but the parent hasn't collected its exit status |
Many processes stuck in D point at storage or network filesystem problems, and they push up the load average without using CPU.
/proc: The Live View¶
Every process has a directory at /proc/<pid>:
PID=$(pgrep -o nginx)
cat /proc/$PID/cmdline | tr '\0' ' '; echo # exact command line
sudo cat /proc/$PID/environ | tr '\0' '\n' # environment it started with
sudo ls -l /proc/$PID/cwd # working directory
sudo ls -l /proc/$PID/fd | wc -l # number of open file descriptors
cat /proc/$PID/limits # effective resource limits
grep -E 'VmRSS|Threads' /proc/$PID/status # resident memory and threads
This is invaluable when a service behaves differently from your shell: its environment and limits are often not what you expect.
Signals¶
A signal is an asynchronous notification to a process.
| Signal | Number | Default action | Typical use |
|---|---|---|---|
SIGTERM |
15 | Terminate | Polite shutdown request — the default for kill, systemctl stop, and docker stop |
SIGINT |
2 | Terminate | Ctrl+C in a terminal |
SIGHUP |
1 | Terminate | Many daemons reload configuration on it |
SIGKILL |
9 | Terminate, can't be caught | Last resort |
SIGSTOP / SIGCONT |
19 / 18 | Pause / resume | Freeze a runaway process while you investigate |
SIGUSR1, SIGUSR2 |
10, 12 | Terminate | Application-defined, such as reopening log files |
kill <pid> # SIGTERM
kill -HUP $(pgrep -o nginx) # ask nginx to reload
pkill -f 'python worker.py' # match against the full command line
kill -9 <pid> # only after SIGTERM and a wait
Why SIGTERM first¶
SIGKILL stops a process instantly: no flushing buffers, no finishing in-flight requests, no removing lock files. Well-behaved services catch SIGTERM, stop accepting new work, finish what's in progress, and exit. Kubernetes and Docker rely on exactly this — they send SIGTERM, wait for a grace period, then send SIGKILL.
import signal, sys, time
running = True
def handle_term(signum, frame):
global running
print("SIGTERM received, finishing current job...", flush=True)
running = False
signal.signal(signal.SIGTERM, handle_term)
while running:
time.sleep(1) # do one unit of work
print("clean exit", flush=True)
sys.exit(0)
Shell wrappers swallow signals
If a container or unit runs sh -c "python app.py", the signal goes to sh, which may not forward it. Use exec python app.py in wrapper scripts so the application replaces the shell and receives signals directly.
Reading an exit status¶
When a process ends, its parent learns either an exit code (0–255) or the signal that killed it. Shells fold both into one number: 128 + the signal number for a signal death, so 137 is SIGKILL (9) and 143 is SIGTERM (15). Python's subprocess reports the same death as -9. Decode any number here:
Exit status decoder (needs JavaScript). Without it: a status above 128 usually means "killed by signal (status − 128)"; 126 means found but not executable, and 127 means command not found.
Zombies and Orphans¶
- A zombie has exited and holds only a process-table entry until its parent calls
wait(). You can't kill a zombie — it's already dead. Fix or restart the parent. - An orphan is a running process whose parent exited. It's re-parented to PID 1 (or a subreaper), which reaps it later.
A handful of zombies is harmless. Thousands mean a buggy parent that never reaps children, and can eventually exhaust the PID limit.
Priorities: nice and ionice¶
nice -n 10 tar czf /backup/etc.tgz /etc # start with lower CPU priority
renice -n 15 -p <pid> # lower an existing process
ionice -c 3 -p <pid> # idle I/O class: only when the disk is free
Nice values range from -20 (highest priority) to 19 (lowest). Lowering priority for backups and batch jobs keeps them from starving user-facing services.
Open Files and Limits¶
sudo lsof -p <pid> | wc -l # files and sockets a process holds
sudo lsof -i :8080 # who has port 8080 open
sudo lsof +L1 # deleted files still held open (see the storage chapter)
ulimit -n # your shell's open-file limit
"Too many open files" means the process hit its nofile limit. Raise it for the service, not your shell — with LimitNOFILE= in the systemd unit — and also check for descriptor leaks. Resource Limits explains where each limit comes from.
Tracing a Stuck Process¶
sudo strace -f -p <pid> -e trace=network,file # which syscalls it's making or blocked on
sudo cat /proc/<pid>/stack # kernel stack for a process in D state
sudo cat /proc/<pid>/wchan; echo # the kernel function it's waiting in
strace slows the traced process considerably. Attach briefly on production systems.
What Most Engineers Miss¶
- PID 1 in a container ignores signals it hasn't handled. The kernel doesn't apply the default action (terminate) for signals sent to a PID namespace's init from inside it. An app running as PID 1 without a SIGTERM handler ignores
docker stop, waits out the grace period, and is SIGKILLed: exit 137 on every deploy. Handle SIGTERM, or run a tiny init withdocker run --initortini. - PID 1 must also reap zombies. Orphans are re-parented to PID 1. If that's your application, and it never calls
wait(), zombies accumulate until the PID limit is hit. The same--initfixes it. kill -0 <pid>sends nothing. It only checks that the process exists and that you may signal it, which makes it the cheapest liveness check in a script.- Exit statuses wrap at 256.
exit 256reports success, andexit -1reports 255. Keep your own codes between 0 and 125, because 126 and above have shell meanings. SIGPIPEexit 141 is often harmless. Inproducer | head -1,headexits after one line and the producer dies writing to the closed pipe. Withset -o pipefailthat 141 fails the whole pipeline.- systemd raises the PID limit. The kernel default
pid_maxis 32,768, but systemd shipskernel.pid_max = 4194304on 64-bit systems, so PIDs above 100,000 are normal and not a sign of a leak. Checksysctl kernel.pid_max.
Common Mistakes¶
- Reaching for
kill -9first, and corrupting data or leaving stale lock and PID files behind. - Trying to kill zombies instead of fixing or restarting their parent.
- Running an application through
sh -cin a container so it never receivesSIGTERM, and every shutdown waits for the grace period and then kills it. - Raising
ulimit -nin a shell and expecting a systemd service to inherit it. - Assuming a high load average means high CPU usage, when processes are actually stuck in
Dstate waiting on I/O.
Check Yourself¶
Exit 137 on every deploy
A container exits with 137 every time Kubernetes rolls it, but memory use is low and Reason isn't OOMKilled. What's the most likely cause?
- The node is out of memory
- The app ignores SIGTERM (often because it's PID 1 behind
sh -c), so it's SIGKILLed after the grace period - The liveness probe timeout is too long
137 is 128 + 9, SIGKILL. Without an OOM reason, it's the kubelet's SIGKILL after terminationGracePeriodSeconds, because SIGTERM didn't stop the process.
A Python child killed by the OOM killer
A Python tool runs a command with subprocess.run(). The kernel OOM killer kills that command. What is returncode?
- 137
- -9
- 1
subprocess reports death by signal N as -N. Only a shell turns it into 128 + N.
Interview Questions¶
- What's the difference between
SIGTERMandSIGKILL, and why does it matter for deployments? - What is a zombie process, and how do you get rid of one?
- A process is in state
Dand ignoreskill -9. Why, and what do you investigate? - How do you find which process is listening on port 443?
- Why might a service see different environment variables or limits than your login shell?
Next¶
Continue to systemd and journald.