| |

Bash Scripting 27 ๐Ÿง Process Management โ€” Managing job control, background tasks, and PIDs

โฑ๏ธ Estimated read: 12 min | |

A shell script that starts a background process must be able to find it again, wait for it, and terminate it when necessary. Bash provides three mechanisms for this: job control (&, jobs, fg, bg, kill %n), the process ID variables ($!, $$, $BASHPID), and the wait builtin. Each mechanism serves a different purpose, and confusing them produces scripts that lose track of their children, kill the wrong process, or exit while background work is still running.

This chapter covers the job control builtins that manage foreground and background execution, the PID variables that identify the shell, the current script, and the most recent background job, and the wait builtin that synchronizes with child processes and collects their exit statuses. It also covers the disown builtin that detaches a job from the shell’s job table, which is what prevents a background job from being terminated when the shell exits.

Key point: $! is the PID of the last background process. $$ is the PID of the shell or script, not the subshell. $BASHPID is the PID of the current Bash instance, which differs from $$ inside a subshell. Use $! to track background jobs and $BASHPID when you need the actual process ID inside a subshell.


Why process management has these distinctions

The job control problem. When a command runs in the foreground, the shell waits for it to finish before returning the prompt. When a command runs with &, the shell returns the prompt immediately and the command continues in the background. The jobs builtin lists the background and stopped jobs, fg brings a job to the foreground, and bg resumes a stopped job in the background . The job number (%1, %2) is a shell-level identifier, not a PID. It refers to a process group, which may contain multiple processes if the job is a pipeline .

The PID variable problem. $$ expands to the PID of the shell or script that is executing, not the PID of a subshell created with parentheses . A script that uses $$ inside ( ... ) to identify itself will get the parent shell’s PID, not the subshell’s PID. Bash 4.0 introduced $BASHPID, which is updated inside subshells to the subshell’s actual PID . The difference matters when a script uses a subshell to run work in parallel and needs to identify that work’s process.

The exit-synchronization problem. A script that starts background jobs and then exits does not automatically wait for them. If the script’s job is to run several tasks in parallel, it must call wait to block until they finish. Without wait, the script exits, the shell terminates, and depending on the shell’s configuration, the background jobs may receive SIGHUP and die . The wait builtin with no arguments waits for all background jobs; wait $pid waits for a specific one and returns its exit status .


a. Job control: &, jobs, fg, bg

The & operator starts a command in the background. The shell prints the job number and PID, and returns the prompt.

# Start a background job
sleep 300 &

# Output: [1] 12345
# [1] is the job number, 12345 is the PID

The jobs command lists the shell’s jobs. The -l flag adds the PID, -r shows only running jobs, and -s shows only stopped jobs .

# List jobs with PIDs
jobs -l

# Output:
# [1]- 12345 Running                 sleep 300 &
# [2]+ 12346 Running                 sleep 200 &

# The + marks the current job
# The - marks the previous job

The + and - markers identify the current and previous jobs. fg with no argument resumes the current job in the foreground. bg with no argument resumes the current job in the background. The %n syntax refers to a specific job number .

# Bring job 1 to the foreground
fg %1

# Resume job 2 in the background
bg %2

A foreground job can be stopped with Ctrl+Z, which sends SIGTSTP. The job moves to the stopped state and appears in jobs with the status Stopped. It can then be resumed with fg or bg .

b. PID variables: $!, $$, and $BASHPID

Three variables identify processes, and they are not interchangeable.

$! is the PID of the most recent background command. It is set when a command is started with & and is used to track or wait for that specific job .

sleep 300 &
pid=$!
echo "Background PID: $pid"

# Wait for it specifically
wait "$pid"

$$ is the PID of the shell or script. It does not change inside a subshell created with parentheses. A script that runs ( echo "$$" ) prints the parent script’s PID, not the subshell’s .

echo "Script PID: $$"
( echo "Inside subshell: $$" )  # same as the script PID

$BASHPID is the PID of the current Bash instance. In the main shell, it equals $$. Inside a subshell, it is updated to the subshell’s actual PID .

echo "Main: $$ / $BASHPID"          # both the same
( echo "Subshell: $$ / $BASHPID" )  # $$ unchanged, $BASHPID different

The practical rule is to use $! for background jobs, $$ for the script’s own PID (for temporary file names, for example), and $BASHPID when the code runs inside a subshell and needs the subshell’s actual PID.

c. wait and collecting exit statuses

The wait builtin blocks until a background job finishes. With no arguments, it waits for all background jobs. With a PID or job specification, it waits for that specific job .

# Start three jobs
sleep 2 &
pid1=$!
sleep 4 &
pid2=$!
sleep 1 &
pid3=$!

# Wait for each and collect status
wait "$pid3"
echo "Job 3 finished with status $?"

wait "$pid2"
echo "Job 2 finished with status $?"

wait "$pid1"
echo "Job 1 finished with status $?"

The exit status of wait is the exit status of the process it waited for. If the process was terminated by a signal, the status is 128 plus the signal number . This is how a script knows whether a background task succeeded.

A common pattern is to start multiple jobs, store their PIDs in an array, and wait for all of them:

pids=()
for i in 1 2 3; do
    ( sleep "$i"; exit "$i" ) &
    pids+=($!)
done

for pid in "${pids[@]}"; do
    wait "$pid"
    echo "PID $pid exited with $?"
done

The wait command with no arguments waits for all background jobs but does not report their individual statuses. To collect statuses, the script must wait for each PID individually .

d. disown and the SIGHUP problem

When an interactive shell exits, it sends SIGHUP to all its jobs. The default action of SIGHUP is to terminate the process. A script that starts a background job and then exits will cause that job to receive SIGHUP and die, unless the job is detached from the shell’s job table .

The disown builtin removes a job from the shell’s job table. The job continues running, but the shell no longer tracks it and will not send it SIGHUP on exit. The -h option marks the job so that it is not sent SIGHUP, but it remains in the job table .

# Start a background job and detach it
long_running_task &
disown

# The task continues after the shell exits

In a non-interactive script, the shell does not normally send SIGHUP to background jobs on exit, but the behavior depends on the shell and how it was invoked. Using nohup or disown is the reliable way to start a process that must outlive the script.


Complete Example Session

# ============================================
# PART 1: START A BACKGROUND JOB
# ============================================
sleep 300 &
# Output: [1] 12345

# The job number is 1, the PID is 12345.
# ============================================
# PART 2: LIST JOBS
# ============================================
jobs -l
# [1]+ 12345 Running                 sleep 300 &
# ============================================
# PART 3: THE PID VARIABLES
# ============================================
echo "Script PID ($$): $$"
sleep 10 &
bg_pid=$!
echo "Background PID (\$!): $bg_pid"

( echo "Subshell: \$\$=$$, \$BASHPID=$BASHPID" )
# $$ is the script PID; $BASHPID is the subshell PID
# ============================================
# PART 4: STOP AND RESUME
# ============================================
# Run a foreground command
sleep 100

# Press Ctrl+Z
# [1]+ Stopped sleep 100

# Resume in the background
bg %1

# Bring back to the foreground
fg %1
# ============================================
# PART 5: WAIT FOR A SPECIFIC PID
# ============================================
sleep 2 &
pid=$!
wait "$pid"
status=$?
echo "Exited with $status"
# ============================================
# PART 6: WAIT FOR MULTIPLE JOBS
# ============================================
pids=()
for i in 1 2 3; do
    ( sleep "$i"; exit "$i" ) &
    pids+=($!)
done

for pid in "${pids[@]}"; do
    wait "$pid"
    echo "PID $pid exited with $?"
done
# ============================================
# PART 7: JOB SPECIFICATIONS
# ============================================
sleep 300 &
sleep 400 &

# Kill job 1 (sends SIGTERM to the process group)
kill %1

# The job is terminated; jobs shows only job 2
# ============================================
# PART 8: DISOWN
# ============================================
long_task &
disown

# The job is removed from the job table.
# It continues running after the shell exits.
# ============================================
# PART 9: CHECK IF A PROCESS IS RUNNING
# ============================================
pid=$!
if kill -0 "$pid" 2>/dev/null; then
    echo "Process $pid is running"
else
    echo "Process $pid is not running"
fi
# ============================================
# PART 10: COMPLETE PATTERN
# ============================================
#!/bin/bash
set -euo pipefail

pids=()
cleanup() {
    for pid in "${pids[@]}"; do
        kill "$pid" 2>/dev/null || true
    done
}
trap cleanup EXIT

for i in 1 2 3; do
    ( sleep "$i"; echo "Job $i done" ) &
    pids+=($!)
done

for pid in "${pids[@]}"; do
    wait "$pid"
done

The ten parts covered starting background jobs, listing jobs, PID variables, stop and resume, waiting for a specific PID, waiting for multiple jobs, job specifications, disown, checking process liveness, and the complete cleanup pattern.


Quick Reference

Job control builtins

CommandEffect
command &Start in background
jobsList jobs
jobs -lList with PIDs
jobs -rRunning jobs only
jobs -sStopped jobs only
fg %nBring job to foreground
bg %nResume job in background
kill %nSend signal to job’s process group
disownRemove job from job table

PID variables

VariableValueChanges in subshell
$!Last background PIDN/A
$$Shell/script PIDNo
$BASHPIDCurrent Bash PIDYes

wait

CommandEffect
waitWait for all background jobs
wait $pidWait for a specific PID, return its status
wait %nWait for a job specification

Process liveness

CommandEffect
kill -0 $pidExit 0 if process exists, non-zero if not
kill $pidSend SIGTERM (default)
kill -9 $pidSend SIGKILL

Best Practices

โœ… Do This:

# Store the background PID immediately
sleep 300 &
pid=$!                                                   # โœ…
# Use wait to synchronize with background jobs
wait "$pid"                                              # โœ…
# Use $BASHPID inside subshells
( echo "$BASHPID" )                                      # โœ…
# Disown long-running jobs that must outlive the script
long_task &
disown                                                   # โœ…

โŒ Don’t Do This:

# Use $$ inside a subshell expecting the subshell PID
( echo "PID: $$" )                                       # โŒ (parent PID)
# Exit without waiting for background jobs
job &                                                    # โŒ (job may be killed)
# Kill by job number in a non-interactive script
kill %1                                                  # โŒ (job control may be off)
# Assume $! is still valid after a second background job
cmd1 &
cmd2 &
echo "$!"  # PID of cmd2, not cmd1                      # โŒ

Common Pitfalls

PitfallWhy It HappensFix
Background job dies when script exitsShell sends SIGHUP to jobsUse disown or nohup
$! is wrong PIDAnother background job startedStore $! immediately after each &
Subshell reports parent PID$$ does not change in subshellsUse $BASHPID
wait returns immediatelyNo background jobs existCheck that the job was started with &
kill %1 fails in a scriptJob control disabled in non-interactive shellsUse PID or process group ID
Exit status lostwait not calledCall wait "$pid" and capture $?

Real-World Examples

1. Start a background job

sleep 300 &

2. Store the PID

pid=$!

3. List jobs with PIDs

jobs -l

4. Bring a job to the foreground

fg %1

5. Resume in the background

bg %1

6. Wait for a specific PID

wait "$pid"

7. Wait for all jobs

wait

8. Check if a process is alive

kill -0 "$pid" 2>/dev/null

9. Disown a job

disown

10. Subshell PID

( echo "$BASHPID" )

Visual

Job control states

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  JOB STATES                                                  โ”‚
โ”‚                                                              โ”‚
โ”‚  Foreground โ”€โ”€Ctrl+Zโ”€โ”€โ–บ Stopped                             โ”‚
โ”‚       โ”‚                    โ”‚                                 โ”‚
โ”‚       โ”‚                    โ”œโ”€โ”€ fg %n โ”€โ”€โ–บ Foreground        โ”‚
โ”‚       โ”‚                    โ”‚                                 โ”‚
โ”‚       โ”‚                    โ””โ”€โ”€ bg %n โ”€โ”€โ–บ Running (bg)      โ”‚
โ”‚       โ”‚                                                      โ”‚
โ”‚       โ–ผ                                                      โ”‚
โ”‚  Running (foreground)                                        โ”‚
โ”‚                                                              โ”‚
โ”‚  command & โ”€โ”€โ–บ Running (background)                         โ”‚
โ”‚       โ”‚                                                      โ”‚
โ”‚       โ””โ”€โ”€ fg %n โ”€โ”€โ–บ Foreground                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

PID variables

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  PID VARIABLES                                               โ”‚
โ”‚                                                              โ”‚
โ”‚  $$                                                          โ”‚
โ”‚    Script PID                                                โ”‚
โ”‚    Does not change in subshell                               โ”‚
โ”‚                                                              โ”‚
โ”‚  $BASHPID                                                    โ”‚
โ”‚    Current Bash PID                                          โ”‚
โ”‚    Changes in subshell                                       โ”‚
โ”‚                                                              โ”‚
โ”‚  $!                                                          โ”‚
โ”‚    Last background PID                                       โ”‚
โ”‚    Set by &                                                  โ”‚
โ”‚                                                              โ”‚
โ”‚  Main shell: $$ = $BASHPID                                   โ”‚
โ”‚  Subshell:   $$ โ‰  $BASHPID                                   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

wait synchronization

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  WAIT                                                        โ”‚
โ”‚                                                              โ”‚
โ”‚  Script starts job1 &, job2 &, job3 &                       โ”‚
โ”‚       โ”‚                                                      โ”‚
โ”‚       โ–ผ                                                      โ”‚
โ”‚  wait $pid1 โ”€โ”€โ–บ blocks until job1 finishes                  โ”‚
โ”‚       โ”‚                                                      โ”‚
โ”‚       โ–ผ                                                      โ”‚
โ”‚  wait $pid2 โ”€โ”€โ–บ blocks until job2 finishes                  โ”‚
โ”‚       โ”‚                                                      โ”‚
โ”‚       โ–ผ                                                      โ”‚
โ”‚  wait $pid3 โ”€โ”€โ–บ blocks until job3 finishes                  โ”‚
โ”‚       โ”‚                                                      โ”‚
โ”‚       โ–ผ                                                      โ”‚
โ”‚  Script continues                                            โ”‚
โ”‚                                                              โ”‚
โ”‚  Without wait, the script exits and the jobs may die.        โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

disown and SIGHUP

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  DISOWN                                                      โ”‚
โ”‚                                                              โ”‚
โ”‚  Without disown:                                             โ”‚
โ”‚    Script exits โ”€โ”€โ–บ shell sends SIGHUP to jobs โ”€โ”€โ–บ jobs die โ”‚
โ”‚                                                              โ”‚
โ”‚  With disown:                                                โ”‚
โ”‚    Script exits โ”€โ”€โ–บ job not in table โ”€โ”€โ–บ no SIGHUP          โ”‚
โ”‚    Job continues running                                     โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
Background operator&
Job listjobs / jobs -l
Foregroundfg %n
Background resumebg %n
Stop a foreground jobCtrl+Z (SIGTSTP)
Last background PID$!
Script PID$$
Subshell PID$BASHPID
Wait for jobwait $pid or wait %n
Detach from shelldisown
Liveness checkkill -0 $pid

Key takeaways:

  • Job control is a shell-level abstraction. The job number (%1) refers to a process group, not a PID. It works in interactive shells and in scripts that have job control enabled, but kill %1 in a non-interactive script may fail if job control is disabled .
  • $! is the only reliable way to track a background job. It is set immediately after & and holds the PID of that specific process. If another background job starts, $! is overwritten. Store it in a variable immediately .
  • $$ is the script PID, not the subshell PID. Inside ( ... ), $$ still refers to the parent. Use $BASHPID when the code runs in a subshell and needs its own PID .
  • wait is how a script synchronizes with its children. Without it, the script exits and the background jobs may receive SIGHUP and terminate. wait "$pid" blocks until that process finishes and returns its exit status .
  • disown is for jobs that must outlive the shell. It removes the job from the shell’s job table, so the shell does not send SIGHUP on exit. nohup achieves a similar result by ignoring SIGHUP in the child .
  • kill -0 checks liveness without sending a signal. It returns success if the process exists and failure if it does not. It is the standard way to check whether a PID is still valid before attempting to wait or kill.

Remember: Process management in Bash is three separate concerns. Job control (&, jobs, fg, bg) manages foreground and background execution within a shell session. PID variables ($!, $$, $BASHPID) identify specific processes. wait synchronizes with child processes and collects their exit statuses. The most common mistake is starting a background job, losing its PID, and then having no way to wait for it or kill it. Store $! immediately. The second most common mistake is using $$ inside a subshell and getting the parent’s PID. Use $BASHPID for that. And when a job must survive the script’s exit, disown it.

Was this helpful?


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!