Skip to main content
Bash intermediate Lesson 13 of 22

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:

SignalNumberDefault actionCommon use
SIGHUP1TerminateReload config
SIGINT2TerminateCtrl+C
SIGTERM15TerminateGraceful shutdown
SIGKILL9Terminate (forced)Force kill
SIGSTOP19PauseSuspend process
SIGCONT18ResumeResume 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.

Frequently Asked Questions

What is the difference between a subshell and a child process?
A subshell is a copy of the current shell (created by ( ), $( ), or pipes). A child process is any external command you run. Both inherit exported variables, but neither can modify the parent shell's state.
How do I run multiple tasks in parallel in Bash?
Launch tasks in the background with & and collect their exit codes with wait. For more sophisticated parallelism with concurrency limits, use GNU parallel or xargs -P.
What signals can I trap in Bash?
Common ones: EXIT (always runs on exit), INT (Ctrl+C), TERM (kill default), ERR (any command fails), HUP (terminal close), PIPE (broken pipe). Use trap 'handler' SIGNAL.