Tools: Signals: The Kernel's Text Messages (2026)

Tools: Signals: The Kernel's Text Messages (2026)

Signals: The Kernel's Text Messages

kill -9 Isn't What You Think It Is

What a Signal Actually Is

The Signals You Already Know (And What's Actually Happening)

SIGTERM (15)

SIGKILL (9)

SIGINT (2)

SIGPIPE

The Signals You Probably Don't Know

SIGTSTP (20): Ctrl-Z Does This

SIGWINCH: Your Terminal Is Resizing

SIGCHLD: Your Child Died (Or Did Something Else)

SIGALRM: How Timeouts Work

SIGHUP: The Signal That Means Two Different Things

Process Groups and Why Signals Go Sideways

Signals and the PTY

The Mental Model

Quick Recap

Further Reading Reading time: ~10 minutes You've been saying "force kill" for years. You type kill -9 1234 when a process won't die, and you picture the operating system reaching in with a fist and crushing it. That's not what happens. What happens is the kernel sends the process a message. The message contains exactly one piece of information: the number 9. That's it. A number. The process gets a signal, and the signal is SIGKILL — signal 9. For a running process, termination is essentially immediate. But there's a case where even SIGKILL can't immediately kill a process: uninterruptible sleep — when a process is blocked in kernel code that cannot be safely interrupted. That delay, and what causes it, turns out to matter enormously. Signals are one of the oldest IPC mechanisms in Unix — older than sockets, older than most of the other things you'd reach for when you want two processes to communicate. They're asynchronous, they arrive at arbitrary times, they can be caught or ignored or blocked, and some of them mean three different things depending on context. They're worth understanding. Let's look at the machinery. A signal is not a byte written to a pipe. It's not a network packet. It's a tiny notification the kernel delivers to a process, completely out of band from whatever the process is currently doing. When a signal arrives, the kernel interrupts the process mid-execution — whatever instruction it was running — and delivers the signal. The process then does one of three things, depending on how it has configured its signal disposition: The disposition model is per-process, per-signal. A process can catch SIGTERM but ignore SIGUSR1 but take the default for everything else. Most processes don't configure most signals — they just take whatever the defaults are. Think back to the number of times you've intentionally installed a signal handler, I bet it was rare and idiosyncratic, that's been my experience. And two signals can never be caught or ignored: SIGKILL (9) and SIGSTOP (19). The kernel handles them unconditionally. No signal handler, no SIG_IGN, no blocking mask. The process cannot intercept these — but as you'll see below, that doesn't mean SIGKILL takes effect instantly in every case. Let's start with the familiar ones and fill in the gaps. kill <pid> with no flag sends SIGTERM. This is the polite version. The default action is termination, but the process can catch it — and well-behaved servers do. They use the SIGTERM handler to flush logs, close database connections, finish in-flight requests, and exit cleanly. When systemd stops a service, it sends SIGTERM first. Waits a few seconds. Then, if the process is still running, sends SIGKILL. That's the TimeoutStopSec setting you've probably seen in unit files. The grace period between the two is intentional: give the process a chance to clean up before the kernel pulls the plug. kill -9 or kill -SIGKILL. Not caught. Not ignored. Not blocked. The moment the kernel delivers SIGKILL to a running process, it's marked for immediate termination. The kernel doesn't call any signal handler, doesn't run atexit functions, doesn't flush stdio buffers. This is why kill -9 leaves zombie processes, half-written files, and unreleased locks behind. It's not "force kill" — it's "unconditional termination, no cleanup allowed." That's why kill -9 sometimes can't kill a process: uninterruptible sleep, shown in ps as state D. This is a process that's blocked in a kernel code path that cannot be safely interrupted — usually waiting on a disk I/O that can't be cancelled, or an NFS mount that's gone stale. The kernel won't deliver SIGKILL until the process wakes from that sleep. Sometimes it never wakes. That's how you get unkillable processes, and the only fix is rebooting or waiting for the I/O to resolve. You press Ctrl-C. SIGINT happens. The default action is termination. That's why Ctrl-C kills a whole pipeline. The kernel's terminal driver sends SIGINT to the entire foreground process group — not a single process. Every process in the group gets it simultaneously. When you run cat huge_file | grep pattern | wc -l, all three processes are in the same foreground process group. They all get SIGINT. They all die. The pipeline collapses cleanly. I explain why it hits the whole group — and what a process group even is — in Sessions and Process Groups. Run python script.py | head -n 10 and sometimes you'll see BrokenPipeError. Here's what happened: head read its 10 lines and exited. The pipe closed. The Python script was still writing. The kernel detected that the write end of a pipe has no readers, and sent SIGPIPE to the still-writing process. The default action is termination. That's why BrokenPipeError exists — it's a caught SIGPIPE converted to an exception. Programs that ignore SIGPIPE (signal(SIGPIPE, SIG_IGN)) will instead get an error return from write(). Either way, the kernel is telling you: nobody's reading anymore, stop writing. You press Ctrl-Z. The shell reports [1]+ Stopped. The process is still alive, just suspended. What happened: the terminal driver sent SIGTSTP to the foreground process group. The default action is to stop the process — it gets parked in the kernel's run queue, not scheduled for CPU time, not consuming memory (well, its pages stay allocated, but it's not actively running). It's frozen mid-execution. fg sends SIGCONT to resume it. bg also sends SIGCONT, but tells the shell to let it run in the background. Programs can catch SIGTSTP. Vim does this. When you Ctrl-Z out of vim, it saves its terminal state, restores the original terminal settings, then stops itself. When you fg back in, it gets SIGCONT, re-enters raw mode, and redraws the screen. That's not magic — it's a signal handler. The difference between SIGTSTP and SIGSTOP: SIGTSTP can be caught — a process can intercept it and do cleanup before stopping. SIGSTOP cannot — the kernel stops it unconditionally. The full job control cycle — &, fg, bg, and how the shell orchestrates it — is in Sessions and Process Groups. Every time you drag the corner of your terminal window, a signal fires. SIGWINCH — "window change" — is delivered to the foreground process group when the terminal dimensions change. The kernel's terminal driver detects that the master side has reported a new size, and sends the signal. Let that sink in for a moment. Window resizing is a signal event. The kernel is involved. The process catches SIGWINCH, calls ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) to get the new dimensions, and redraws its UI. This is how vim reflows when you resize the window. How htop re-renders its columns. How your shell prompt re-wraps. If you're writing a terminal application, you need to catch SIGWINCH or your UI will look wrong after any resize. When a child process changes state — exits, is stopped, is resumed — the kernel sends SIGCHLD to its parent. The default action is to ignore it. Most processes don't care. But shells care deeply. When bash gets SIGCHLD, it checks which child changed state, updates its job table, and prints [1]+ Done sleep 10 or whatever. When a daemon forks children to handle requests, it catches SIGCHLD so it can call waitpid() and reap the zombies before they accumulate. That's why zombie processes happen. When a process exits, it doesn't fully disappear — it leaves a small record in the kernel's process table, holding the exit code. The parent is expected to call wait() or waitpid() to retrieve that exit code, at which point the zombie is cleaned up. If the parent never calls wait, the zombie just sits there. If the parent exits, zombies get reparented to init (PID 1), which calls wait in a loop. SIGCHLD is the kernel radioing your base camp: "One of your people turned." You can go collect what's left — the exit code — by calling waitpid(). If you don't, they shamble around the process table indefinitely. The kernel literally calls them zombies. State Z in ps. No cure. No kill signal works — they're already dead. The only way to clear them is to collect the exit code, or let the parent die too, at which point init adopts the orphans and reaps them. Rick Grimes would call that a mercy kill. The kernel calls it wait(). alarm(30) sets a timer. In 30 seconds, the kernel delivers SIGALRM to the process. The default action is termination, but programs catch it to implement timeouts. That's how shell scripts time out operations. The crude bash version: Or the cleaner way, using the timeout command (coreutils): Under the hood, timeout does exactly what alarm() does — sets a timer, catches the signal, kills the child. The real pattern in C is alarm() with a signal handler that cancels or interrupts the operation. Many C programs use this to implement read timeouts, connection timeouts, and watchdog behavior. This one deserves its own section because it's genuinely confusing until you understand the history. SIGHUP — "hangup" — originally meant that your physical serial terminal had disconnected. The modem connection dropped. The carrier signal was gone. The line was dead. When that happened, the kernel sent SIGHUP to the session leader (usually the shell) of the terminal session. The shell would then die, taking its job-controlled children with it. This was the correct behavior: if your terminal is gone, there's no point keeping the session running. SIGHUP means "your terminal disconnected." The default action is termination. There's a second meaning, and it came from necessity. By the mid-1980s, Unix system administrators had noticed: SIGHUP is a signal you can catch. And servers don't have physical terminals to disconnect. So SIGHUP became the conventional signal to tell a daemon "reload your configuration without restarting." nginx, sshd, Apache, rsyslog — they all catch SIGHUP and re-read their config files. You'll see this in documentation all the time: That's why nginx -s reload works — the nginx binary locates the master process PID, sends SIGHUP, and exits. The master process catches SIGHUP, re-reads its config, and gracefully replaces its workers. It's not a special protocol. It's just a signal. The same signal means "your terminal died, please exit" to an interactive process, and "please reload your config" to a daemon. Context entirely determines the correct behavior. It's terrible API design, but it's been working for 50 years. Signals don't always go where you expect. When Ctrl-C kills an entire pipeline but not your shell, that's not the signal mechanism — that's process groups, and the kernel's rules about who receives what. I cover the full process group and session architecture in Sessions and Process Groups. You've probably used nohup to keep a process alive after logout. How it actually works — and why tmux is better — is in Sessions and Process Groups. Signals and file descriptors are not separate systems. They interlock at the terminal level. When a PTY supervisor — a process that holds the master end of a PTY and manages a child's terminal session — wants to send Ctrl-C to the child, there are two approaches. The wrong one: kill(child_pid, SIGINT). The right one: write the byte 0x03 (the byte Ctrl-C generates) to the PTY master. The kernel's line discipline, running inside the PTY, processes that byte and generates SIGINT for the foreground process group inside the PTY's session. The child sees a real Ctrl-C — one that came through the terminal, the way Ctrl-C is supposed to arrive. Job control works correctly. Signal handlers see the right source. When the supervisor wants to stop the whole session cleanly, it sends SIGTERM to the child's process group (using the negative PGID), waits for exit by catching SIGCHLD, then cleans up resources. SIGKILL is the fallback if the process ignores SIGTERM and the timeout expires. That's why well-written supervisors write control bytes to the PTY master instead of sending signals directly — it respects the terminal abstraction that the child process expects. Signals are the kernel's notification system for processes. Asynchronous, lightweight, and limited — they carry one piece of information, the signal number, and optionally a few extra bytes in the siginfo_t structure. They're not designed for data transfer. They're designed for control: "stop what you're doing," "your child just exited," "your window changed size," "someone is asking you to reload your config." The disposition model — default, catch, ignore — gives each process control over how it responds. Except for SIGKILL and SIGSTOP, which the kernel reserves for itself. Those two bypass the disposition system entirely. Process groups determine the blast radius — which processes a signal actually reaches. That mechanism, along with session architecture, is in Sessions and Process Groups. And SIGHUP is historically weird. Accept it and move on. Here's what we've covered: I'm writing a book about what makes developers irreplaceable in the age of AI. Join the early access list → Naz Quadri occasionally sends SIGTERM to processes that deserve a chance to clean up first. He blogs at nazquadri.dev. Rabbit holes all the way down 🐇🕳️. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

# Kill myself after 30 seconds if still running (sleep 30 && kill $$) & long-running-command # Kill myself after 30 seconds if still running (sleep 30 && kill $$) & long-running-command # Kill myself after 30 seconds if still running (sleep 30 && kill $$) & long-running-command timeout 30 long-running-command timeout 30 long-running-command timeout 30 long-running-command kill -HUP $(cat /var/run/nginx.pid) # reload nginx config # or, in modern times: -weight: 500;">systemctl reload nginx # which does the same thing kill -HUP $(cat /var/run/nginx.pid) # reload nginx config # or, in modern times: -weight: 500;">systemctl reload nginx # which does the same thing kill -HUP $(cat /var/run/nginx.pid) # reload nginx config # or, in modern times: -weight: 500;">systemctl reload nginx # which does the same thing - Default action. The kernel handles it. Most signals kill the process; some -weight: 500;">stop it; one does nothing by default. The process doesn't have a say. - Catch it. The process has installed a signal handler — a function. The kernel calls that function instead. When the handler returns, the process resumes whatever it was doing. - Ignore it. The process has said "I don't care about this signal." The kernel checks, shrugs, and moves on. - A signal is a small out-of-band notification delivered by the kernel to a process. - Disposition is per-process, per-signal: default action, catch with a handler, or ignore. - SIGKILL (9) and SIGSTOP (19) cannot be caught or ignored — the kernel handles them unconditionally. - kill -9 can fail to immediately kill a process in uninterruptible sleep (D state). - The terminal driver sends SIGINT on Ctrl-C and SIGTSTP on Ctrl-Z to the entire foreground process group. - SIGPIPE fires when you write to a pipe with no readers — that's the BrokenPipeError you've seen. - SIGWINCH fires whenever the terminal is resized — programs catch it to redraw their UI. - SIGCHLD notifies a parent when a child changes state; catching it is how you avoid zombie processes. - SIGHUP means "terminal disconnected" to interactive programs, and "reload config" to daemons. Both are real. - Process groups, nohup, session architecture, and setsid() are covered in the next post: Sessions and Process Groups. - man 7 signal — the complete Linux signal reference. Every signal, its default action, whether it can be caught. - man 2 sigaction — the correct way to -weight: 500;">install a signal handler (not the older signal(2), which has subtle portability problems). - man 2 kill, man 2 waitpid — the syscalls for sending signals and reaping children. - Advanced Programming in the UNIX Environment — Stevens and Rago, Chapter 10 on signals. Still the best treatment of the full signal model in print.