Bash Process Management
Run background jobs, manage processes with jobs/fg/bg, send signals with kill, use trap for cleanup, and understand subshells.
Running Commands in the Background
By default, Bash waits for each command to finish before running the next one. Appending & runs a command asynchronously, freeing the shell to continue immediately. This is the foundation of parallelism in Bash — you launch multiple tasks at once and then collect their results when they are all done, rather than waiting for each one sequentially.
# Run a command in the background — shell continues immediately
sleep 10 &
echo "Sleep started, PID: $!" # $! holds the PID of the last background job
# Run multiple independent tasks in parallel
./task_a.sh &
./task_b.sh &
./task_c.sh &
# Wait for all background jobs to finish before continuing
wait
echo "All tasks done"
# Wait for a specific PID and capture its exit code
long_task &
pid=$!
wait "$pid"
echo "Task exited with $?"
jobs, fg, bg
Bash tracks background jobs with job numbers, independent of PIDs. Job control lets you suspend a foreground process, move it to the background, and bring background jobs back to the foreground. This is essential for interactive use and for scripts that need to manage multiple long-running processes.
# Start some background jobs
sleep 100 & # Job [1]
sleep 200 & # Job [2]
sleep 300 & # Job [3]
# List all current jobs with their status
jobs
# [1] Running sleep 100 &
# [2] Running sleep 200 &
# [3]- Running sleep 300 &
# Bring a job to the foreground (use %N for job number)
fg %1 # bring job 1 to the foreground
fg %2 # bring job 2
# Send a running foreground job to the background
# Press Ctrl+Z to suspend the current foreground job
bg %1 # resume job 1 in the background
# Kill a job by job number
kill %2 # send SIGTERM to job 2
kill -9 %3 # force kill job 3
Parallel Execution with wait
Parallel execution with wait is the standard Bash pattern for running independent tasks concurrently and checking whether each one succeeded. It is useful for health checks, test suites, or any workload that can be split into independent units.
#!/usr/bin/env bash
set -euo pipefail
declare -A pids
declare -A results
check_host() {
local host="$1"
# Return "UP" or "DOWN" based on whether the host responds to ping
if ping -c 1 -W 2 "$host" &>/dev/null; then
echo "UP"
else
echo "DOWN"
fi
}
hosts=("web01" "web02" "db01" "cache01")
# Launch all checks in parallel — each writes its result to a temp file
for host in "${hosts[@]}"; do
check_host "$host" > "/tmp/check_${host}.result" &
pids[$host]=$!
done
# Collect results after all jobs finish
for host in "${hosts[@]}"; do
wait "${pids[$host]}"
results[$host]=$(< "/tmp/check_${host}.result")
rm -f "/tmp/check_${host}.result"
done
# Report
for host in "${hosts[@]}"; do
printf "%-15s %s\n" "$host" "${results[$host]}"
done
kill — Sending Signals
Signals are the mechanism for communicating with running processes. kill sends a signal to a process by PID. Despite its name, kill can send any signal — not just termination signals. Understanding the different signals helps you interact with processes correctly: request a graceful shutdown, force a kill, reload configuration, or pause and resume.
# Send SIGTERM — request graceful shutdown (the default)
kill 12345
kill -TERM 12345
kill -15 12345
# Send SIGKILL — immediate termination, cannot be caught or ignored
kill -9 12345
kill -KILL 12345
# Send SIGHUP — reload configuration in many daemons
kill -HUP "$(cat /var/run/nginx.pid)"
# Send SIGSTOP / SIGCONT — pause and resume a process
kill -STOP 12345
kill -CONT 12345
# Kill by process name instead of PID
pkill nginx # kill all processes named nginx
pkill -f "python worker.py" # match against the full command line
killall -HUP sshd
# Check if a PID is alive without sending an actual signal
if kill -0 "$pid" 2>/dev/null; then
echo "Process $pid is running"
fi
Signal reference:
| Signal | Number | Default action | Common use |
|---|---|---|---|
| SIGHUP | 1 | Terminate | Reload config |
| SIGINT | 2 | Terminate | Ctrl+C |
| SIGTERM | 15 | Terminate | Graceful shutdown |
| SIGKILL | 9 | Terminate (forced) | Force kill |
| SIGSTOP | 19 | Pause | Suspend process |
| SIGCONT | 18 | Resume | Resume process |
trap — Catching Signals and Events
trap registers a handler that runs when a specific signal or shell event occurs. Its most important use is cleanup — ensuring that temporary files, lock files, and other resources are removed even when a script exits unexpectedly due to an error or Ctrl+C.
#!/usr/bin/env bash
cleanup() {
echo "Cleaning up..."
rm -f /tmp/myapp.lock
rm -rf "$TMPDIR"
}
# EXIT fires on any exit — success, failure, or signal
# This is the most reliable way to guarantee cleanup
trap cleanup EXIT
# Handle Ctrl+C gracefully with a message
trap 'echo "Interrupted — cleaning up..."; exit 130' INT
# Create resources AFTER registering the trap
TMPDIR=$(mktemp -d)
touch /tmp/myapp.lock
echo "Working..."
sleep 30 # If user presses Ctrl+C here, cleanup runs automatically
Multiple traps and re-raising signals:
#!/usr/bin/env bash
set -euo pipefail
TMPFILE=$(mktemp)
# Trap EXIT for guaranteed cleanup regardless of how the script ends
trap 'rm -f "$TMPFILE"' EXIT
# Trap ERR to log which command failed and on which line
trap 'echo "ERROR: command failed at line $LINENO" >&2' ERR
# Trap TERM and INT — clean up and exit with the conventional signal exit code
trap 'echo "Received SIGTERM"; exit 143' TERM
trap 'echo "Received SIGINT"; exit 130' INT
echo "Running..." > "$TMPFILE"
# Do real work here
Convention: exit code = 128 + signal number. SIGTERM=15 → exit 143. SIGINT=2 → exit 130.
Subshells
A subshell is a child copy of the current shell. Changes made inside a subshell — variable assignments, directory changes, shell option changes — do not affect the parent. This isolation is sometimes a bug (when you expect changes to persist) and sometimes a feature (when you want to sandbox options or directory changes).
# Subshell with ( ) — explicit subshell
x=10
(
x=99
echo "Inside subshell: x=$x" # 99
)
echo "Outside subshell: x=$x" # 10 — parent is unchanged
# Subshell via command substitution $()
result=$(
cd /tmp
echo "Current dir: $PWD"
)
echo "$result"
echo "Parent dir unchanged: $PWD" # original directory
# Subshell to isolate options for a risky block
(
set +e # disable errexit for this block only
risky_command
echo "Exit: $?"
)
echo "errexit still active here" # parent's options are unchanged
Process Substitution
Process substitution <(command) feeds the output of a command as if it were a file. This enables two commands to compare their outputs directly, and — most importantly — allows while loops to read command output without a subshell.
# Compare the /etc/hosts files on two servers
diff <(ssh server1 "cat /etc/hosts") <(ssh server2 "cat /etc/hosts")
# Read command output in a while loop — variable changes persist after the loop
total=0
while IFS= read -r line; do
(( total++ ))
done < <(grep "ERROR" /var/log/app.log)
echo "Total errors: $total" # correct — not lost to a subshell
Wait with Exit Code Checking
When running tasks in parallel, you need to know which ones failed. wait with a PID returns that process’s exit code, letting you check each task individually.
#!/usr/bin/env bash
set -euo pipefail
run_and_check() {
local name="$1"
local cmd=("${@:2}")
"${cmd[@]}" &
local pid=$!
if wait "$pid"; then
echo "PASS: $name"
else
echo "FAIL: $name (exit $?)" >&2
return 1
fi
}
# Run tests in parallel
run_and_check "unit tests" ./run_unit_tests.sh &
run_and_check "integration tests" ./run_integration_tests.sh &
run_and_check "lint" shellcheck ./**/*.sh &
# Wait for all and count failures
failures=0
for pid in $(jobs -p); do
wait "$pid" || (( failures++ ))
done
if (( failures > 0 )); then
echo "ERROR: $failures task(s) failed" >&2
exit 1
fi
echo "All tasks passed"
Limiting Parallelism
Running too many background jobs simultaneously can overwhelm a system. This pattern caps the number of concurrent jobs at a configurable maximum.
#!/usr/bin/env bash
# Process files in parallel with at most MAX_JOBS running at once
MAX_JOBS=4
active=0
process_file() {
local file="$1"
echo "Processing: $file"
sleep 1 # simulate work
}
for file in /var/data/*.csv; do
process_file "$file" &
(( active++ ))
if (( active >= MAX_JOBS )); then
wait -n 2>/dev/null || true # wait for any one job to finish (bash 4.3+)
(( active-- ))
fi
done
wait # finish remaining jobs
echo "Done"
What’s Next
The next tutorial covers error handling — set -euo pipefail, trap ERR, exit codes, and writing scripts that fail safely.