Linux Signals Explained: kill, pkill, and trap

Every Linux process runs until something stops it. That “something” is almost always a signal. Signals are how the kernel and user space communicate with running processes, and understanding them properly will save you from blindly running kill -9 on everything that misbehaves.

This guide covers what signals actually are, how to send them correctly, and how to trap them inside your own scripts so your code can clean up gracefully instead of dying mid-operation.

What Are Signals?

Terminal showing kill -TERM, kill -HUP, trap, and kill -KILL commands for managing Linux signals

A signal is a notification sent to a process. It interrupts whatever the process is doing and tells it to do something specific: stop, pause, reload config, or terminate. The process can handle it, ignore it, or let the kernel apply the default action.

Think of signals as software interrupts. They work asynchronously, meaning the process does not have to be in any particular state to receive one. The full list of standard Linux signals and their default actions is documented in the signal(7) man page.

You can list every signal your system supports with:

kill -l

Output will look something like this:

 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL
 5) SIGTRAP      6) SIGABRT      7) SIGBUS       8) SIGFPE
 9) SIGKILL     10) SIGUSR1     11) SIGSEGV     12) SIGUSR2
13) SIGPIPE     14) SIGALRM     15) SIGTERM     16) SIGSTKFLT
17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
...

You will reference signals by name (e.g., SIGTERM) or by number (e.g., 15). Both work. Names are easier to read in scripts. Note that some signal numbers differ on non-x86 architectures such as Alpha, MIPS, and SPARC, so always prefer names in portable code.

The Signals You Actually Need to Know

There are 30+ signals, but in day-to-day sysadmin work, a handful cover almost every situation.

SIGTERM (15)

This is the default signal sent by kill. It asks the process to terminate gracefully. A well-written program catches SIGTERM, finishes what it is doing, closes files, releases locks, and exits cleanly. Most daemons and services respond to this correctly.

SIGKILL (9)

SIGKILL cannot be caught, blocked, or ignored. The kernel kills the process immediately, no questions asked. Use this as a last resort, not a first one. A process killed with SIGKILL has no chance to clean up, which can leave lock files, temp files, or corrupted state behind. One edge case worth knowing: a process stuck in uninterruptible sleep (state D) will not die even from SIGKILL until it wakes up, which is one of the reasons a server can feel unkillably stuck. For the full picture on why that happens, see the guide on R, S, D, Z and T process states.

SIGHUP (1)

Originally meant “terminal hangup.” Today it is widely used to tell a daemon to reload its configuration without fully restarting. nginx, sshd, and many other services respond to SIGHUP this way. It is cleaner and faster than a full restart.

SIGINT (2)

This is what happens when you press Ctrl+C in a terminal. The kernel sends SIGINT to the foreground process. Most interactive programs treat it as “stop what you are doing.”

SIGSTOP (19) and SIGCONT (18)

SIGSTOP pauses a process. SIGCONT resumes it. Like SIGKILL, SIGSTOP cannot be caught, blocked, or ignored. Pressing Ctrl+Z in a terminal sends SIGTSTP (20), which is the stoppable, catchable version of SIGSTOP.

SIGUSR1 and SIGUSR2 (10, 12)

These are user-defined signals. Programs can use them for anything. For example, nginx uses SIGUSR1 to reopen log files after rotation, and SIGUSR2 to initiate an online upgrade. php-fpm uses SIGUSR2 for a graceful reload of all workers plus the fpm config and binary, which is exactly what systemctl reload php-fpm runs under the hood:

kill -USR2 $(pidof php-fpm)

Check the documentation for whichever daemon you are working with. The official nginx control signals reference is a good example of what to expect in well-documented daemons.

Using the kill Command

Despite the name, kill sends any signal to any process, not just termination signals. The basic syntax is:

kill [signal] PID

Send the default SIGTERM to process 4821:

kill 4821

Reload nginx config with SIGHUP:

kill -HUP $(cat /var/run/nginx.pid)

Force-kill a stuck process with SIGKILL:

kill -9 4821

You can also use the signal name with a dash prefix:

kill -SIGTERM 4821
kill -TERM 4821
kill -15 4821

All three are equivalent. The name form is clearest in scripts and documentation.

To send a signal to multiple PIDs at once:

kill -TERM 4821 4822 4823

Finding the PID First

You need a PID to use kill. The most common ways to find one:

# By process name
pgrep nginx

# With full details
ps aux | grep nginx

# Just the PID of the first match
pidof sshd

Note: pgrep is often the cleanest option. It returns only PIDs, which you can pipe directly into kill. For a visual alternative, pstree shows the parent-child relationships between processes, which is useful when you need to signal an entire process group.

pkill: Kill by Name Instead of PID

pkill is kill with built-in process matching. You skip the step of finding the PID manually.

# Send SIGTERM to all processes named "python3"
pkill python3

# Send SIGKILL
pkill -9 python3

# Match by full command line, not just process name
pkill -f "gunicorn myapp:app"

# Only kill processes owned by a specific user
pkill -u webuser php-fpm

The -f flag is the most useful one. Without it, pkill only matches against the process name (first 15 characters). With -f, it matches against the entire command string, so you can target specific scripts or arguments.

Be careful with pkill. It is easy to accidentally match more processes than you intended. Use pgrep first to confirm what you are targeting, then replace pgrep with pkill:

# Check first
pgrep -fl python3

# Then kill
pkill python3

The -l flag on pgrep prints both the PID and the process name so you can verify the match before committing.

killall

killall also kills by name, but it matches the exact process name rather than using regex patterns like pkill. On Linux, pkill is generally more flexible. On macOS, the behavior differs slightly, so be aware if you are writing cross-platform scripts.

killall nginx

Sending Signals to Processes the Right Way

The correct sequence when you need to stop a misbehaving process is not to immediately reach for kill -9. Try this order instead:

  1. Send SIGTERM first and wait a few seconds.
  2. If the process is still running, inspect it before escalating.
  3. Only use SIGKILL if the process is clearly stuck and not responding to SIGTERM.

A quick pattern you will see in init scripts and systemd unit files is a timed wait between SIGTERM and SIGKILL:

kill -TERM "$PID"
sleep 5
kill -0 "$PID" 2>/dev/null && kill -KILL "$PID"

kill -0 is a useful trick. Signal 0 does not actually send a signal but checks whether the process exists and whether you have permission to signal it. If it returns 0, the process is still running.

Reloading Services with SIGHUP

Config reloads are one of the most common real-world uses of signals. For services managed by systemd, you can use:

sudo systemctl reload nginx

Under the hood, systemctl reload runs whatever command is defined in the unit file’s ExecReload directive, which for most daemons sends SIGHUP. You can also send it directly:

sudo kill -HUP $(pidof nginx)

Same result, useful to know when you are not running systemd or working with a process that is not managed as a service.

Note: Not every program responds to SIGHUP as a reload trigger. Always check the documentation or the man page for the specific daemon.

Trapping Signals in Bash Scripts

This is where signals become genuinely useful in your own work. The trap command lets a Bash script catch incoming signals and run cleanup code before exiting.

Without a trap, if you send SIGINT or SIGTERM to a running script, it just dies. Any temp files it created, any locks it acquired, any partial operations it started: all left in whatever state they were in. This causes problems.

The syntax for trap is:

trap 'commands' SIGNAL [SIGNAL ...]

Basic Cleanup Example

The trap builtin is documented in full in the GNU Bash manual, which covers the exact signal-handling semantics.

#!/bin/bash

TMPFILE=$(mktemp /tmp/myprocess.XXXXXX)

cleanup() {
    echo "Caught signal, cleaning up..."
    rm -f "$TMPFILE"
    exit 1
}

trap cleanup INT TERM EXIT

echo "Working with temp file: $TMPFILE"
sleep 30
echo "Done."

Now if you press Ctrl+C or send SIGTERM to this script, the cleanup function runs first, the temp file gets removed, and the script exits with code 1.

Trapping EXIT is a good habit. EXIT is not technically a signal but a pseudo-signal that Bash fires whenever the script exits for any reason, including normal completion. It acts as a catch-all.

Trap with a Lock File

#!/bin/bash

LOCKFILE="/var/run/mybackup.lock"

if [ -e "$LOCKFILE" ]; then
    echo "Another instance is running. Exiting."
    exit 1
fi

touch "$LOCKFILE"

cleanup() {
    rm -f "$LOCKFILE"
    echo "Lock released."
}

trap cleanup EXIT

# Main work here
echo "Running backup..."
rsync -az /data/ /mnt/backup/
echo "Backup complete."

This pattern ensures the lock file is removed whether the script exits cleanly or gets interrupted. If you have ever seen a stale lock file left behind after a crashed script, this is the fix. For more on rsync, see the rsync command guide.

Ignoring a Signal

You can tell a script to ignore a signal entirely by trapping it with an empty string:

trap '' INT

Now Ctrl+C does nothing. Use this carefully. A script that ignores SIGINT can become annoying to stop if something goes wrong. Usually it is better to handle the signal than to ignore it.

Resetting a Trap

To restore the default behavior for a signal, use a hyphen:

trap - INT

This is useful if you want cleanup behavior during one phase of a script but want to allow normal interruption during another phase.

Trapping SIGUSR1 for Custom Behavior

You can use SIGUSR1 and SIGUSR2 to add custom behavior to long-running scripts. For example, a script that dumps its current status when you send it a signal:

#!/bin/bash

print_status() {
    echo "Still running. Processed $COUNT items so far."
}

trap print_status USR1

COUNT=0
while true; do
    COUNT=$((COUNT + 1))
    sleep 1
done

Run this in the background, then query its status:

./myscript.sh &
MYPID=$!

# Later...
kill -USR1 "$MYPID"

The script prints its current count without stopping. Handy for long-running batch jobs where you want visibility without adding a full logging system.

Practical Signal Reference

Here is a quick reference for the signals you will reach for most often:

  • SIGHUP (1): Reload config. Works with nginx, sshd, rsyslog, and many others.
  • SIGINT (2): Interrupt. Same as Ctrl+C.
  • SIGQUIT (3): Quit with core dump. Used for debugging.
  • SIGKILL (9): Force kill. Cannot be caught, blocked, or ignored. Last resort.
  • SIGTERM (15): Graceful termination. The default signal from kill.
  • SIGSTOP (19): Pause a process. Cannot be caught, blocked, or ignored.
  • SIGCONT (18): Resume a stopped process.
  • SIGUSR1 (10) / SIGUSR2 (12): User-defined. Behavior depends on the application.

You can pair these with the broader set of Linux commands sysadmins use daily to build a solid mental model of process management.

Common Mistakes

Using kill -9 as the default. It should be the last option, not the first. Most processes respond fine to SIGTERM if you give them a second.

Forgetting that pkill is broad. pkill python will kill every Python process on the system, including system services if they match. Always verify with pgrep -fl first.

Not trapping signals in scripts that create resources. Any script that creates temp files, acquires locks, or starts background jobs should trap at minimum EXIT and INT.

Sending SIGHUP to a process that does not handle it as a reload. Some older daemons will just exit on SIGHUP. Read the man page before relying on this in production.

Debugging Signal Handling

If you are not sure how a process is handling signals, strace can show you in real time:

strace -e trace=signal -p PID

This attaches to a running process and prints signal-related system calls as they happen. Useful when a daemon does not behave as expected after a SIGHUP or SIGTERM.

You can also check /proc/PID/status for the signal masks of a running process:

grep -E "Sig(Blk|Ign|Cgt)" /proc/$(pidof nginx)/status

The output shows bitmasks for blocked, ignored, and caught signals. It takes a bit of decoding (each bit corresponds to a signal number), but it tells you exactly what a process is doing with the signals it receives. Also worth monitoring overall process behavior with tools covered in the free Linux server monitoring guide.

Conclusion

Signals are a core part of how Linux manages processes, and knowing how to use them correctly separates careful sysadmin work from blunt-force troubleshooting. The short version:

  • Try SIGTERM first. Reach for SIGKILL only when the process is truly stuck.
  • Use pkill -f when you need to match by full command line, but always verify with pgrep -fl first.
  • Use kill -HUP to reload daemon configs without a full restart.
  • Trap EXIT, INT, and TERM in any Bash script that creates resources or needs to clean up.
  • Use SIGUSR1/SIGUSR2 for lightweight inter-process communication in your own scripts.

Once this becomes second nature, you will stop wrestling with stuck processes and start handling them cleanly every time.

Tags: , , ,

Ready to optimize your server performance?

Get expert Linux consulting or stay updated with our latest insights.

Contact me   Subscribe
Top ↑