Bash & the POSIX Shell Module 12 – Processes, jobs and signals
Background jobs, wait and parallel work
Start Bash jobs with the ampersand, read a PID from the shell, wait for each exit status, run a wait -n pool and xargs -P, and detach a job with nohup.
What you will learn
- Start a background job with & and read its PID from $!
- Check each background job's status with wait and name the ones that failed
- Use wait -n to run a pool of jobs a few at a time, with a Bash 3.2 fallback
- Choose between a wait -n pool, xargs -P and nohup for parallel and detached work
Before you start
What you’ll learn · Before you start
What you will learn
- Start a background job with & and read its PID from $!
- Check each background job's status with wait and name the ones that failed
- Use wait -n to run a pool of jobs a few at a time, with a Bash 3.2 fallback
- Choose between a wait -n pool, xargs -P and nohup for parallel and detached work
Before you start
On this page
A script that checks 30 servers one after another spends almost all of its time waiting: 29 of every 30 seconds pass while the shell blocks on a probe it has already sent. Run the probes together and the same work ends in about a second, but only if the script still learns which servers answered and which did not.
You will start a job in the background with &, read its process id from $!, wait for each one and read its
exit status: The number from 0 to 255 every command ends with, kept by the shell in $?. 0 means success; Bash uses 126 for found but not executable and 127 for not found. More in “Your first script: shebang, chmod and running it”, run a pool that keeps only a few jobs going at once, hand a list to
xargs -P, and start a job that outlives the terminal with nohup.
A background job runs while the shell moves on
Ending a command with & starts it as a background job: A command run with a trailing & so the shell starts it in a subshell and goes on without waiting; $! holds the PID of the most recent one, and wait collects its exit status. and lets the shell carry on
with the next line. The Bash manual puts it plainly: a command ended with & runs asynchronously in a
subshell: A copy of the shell that runs commands apart from it, as Bash does for each command of a pipeline. It starts with the shell's variables, and every change it makes to them is lost when it exits. More in “Reading files line by line with read”, the shell does not wait for it, and the return status is 0. So & tells you the
job started, never how it ends. The special parameter $! holds the process id of the most recent background job,
so read it the instant you start a job you mean to track:
#!/usr/bin/env bash
# Start two workers in the background and keep the PID of each one. $! holds the
# PID of the most recent background job, so read it right after the & that made it.
set -u
bash worker.sh alpha 0 &
first=$!
bash worker.sh beta 4 &
second=$!
echo "the shell started both workers and moved on"
# wait PID returns that job's own exit status, so each check is reported on its own
wait "$first"
echo "alpha exited $?"
wait "$second"
echo "beta exited $?"
cat report.alpha report.beta The worker is a stand-in that writes its own report file and exits with a status. Each job writes a separate file,
so two jobs never mix their lines on one stream. The session runs fanout.sh, then barewait.sh:
from session import files, run
# Background work is started inside script files run with `bash file.sh`: a
# non-interactive shell prints no "[1] 12345" job notice, so the output has no PID
# in it and is the same on every run.
files("worker.sh", "fanout.sh", "barewait.sh")
run("""
# two workers run at the same time; wait PID reports each one's own status
bash fanout.sh
# a bare wait waits for both, then returns 0 even though delta exited 5
bash barewait.sh
""") Output
# two workers run at the same time; wait PID reports each one's own status $ bash fanout.sh the shell started both workers and moved on alpha exited 0 beta exited 4 report from alpha (rc=0) report from beta (rc=4) # a bare wait waits for both, then returns 0 even though delta exited 5 $ bash barewait.sh bare wait returned 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bg_session.py
fanout.sh caught first=$! right after the first & and second=$! after the second, so it could later wait
on each by name: alpha gave 0 and beta gave 4. barewait.sh instead ended with a bare wait, with no argument. A
bare wait waits for every background job and then returns 0, whatever those jobs did, so it printed bare wait returned 0 even though one worker had exited 5. That gap is the whole reason the next section exists.
Key idea
& starts a job and returns 0 at once; $! names the newest job for a later wait. A bare wait is only a
barrier: it waits for everything and reports nothing about what failed.
Checkpoint
Check: Starting a job with & and reading its PID from $!
wait on a PID returns that job’s own status
To learn what a job did, wait on its PID. wait PID waits for that one job and returns its exit status, turning a
background job back into a status you can test, just as a foreground command gives one. Naming several PIDs is a trap,
though: wait "$a" "$b" waits for both but returns only the status of the last id listed, so the earlier ones are
lost. The cure is to save each PID as its job starts and then wait on them one at a time, recording the failures.
collect-status.sh does that for five checks:
#!/usr/bin/env bash
# Start every check at once, remember each job's PID in order, then wait for each
# PID and record the ones that failed. A background job's failure never stops the
# script, so the only way to know what failed is to collect each status yourself.
set -u
names=(web api db cache queue)
codes=(0 0 7 0 2) # the status each check ends with; 0 is healthy
pids=()
for i in "${!names[@]}"; do
bash check-one.sh "${names[$i]}" "${codes[$i]}" &
pids+=("$!")
done
failed=()
for i in "${!pids[@]}"; do
if ! wait "${pids[$i]}"; then
failed+=("${names[$i]}")
fi
done
if ((${#failed[@]})); then
printf 'failed: %s\n' "${failed[*]}"
exit 1
fi
echo "every check healthy" Two checks are set to fail (db with 7, queue with 2). The loop over ${!pids[@]} waits on each saved PID and
appends the name to failed when wait reports non-zero:
from session import files, run
files("collect-status.sh", "check-one.sh")
run("""
# five checks run at once; the script waits for each PID and names what failed
!bash collect-status.sh
# a failing background job does not stop a set -e script: the shell runs on
bash -c 'set -e; (exit 5) & wait; echo "still running after the failure, status $?"'
""") Output
# five checks run at once; the script waits for each PID and names what failed $ bash collect-status.sh failed: db queue [exit status 1] # a failing background job does not stop a set -e script: the shell runs on $ bash -c 'set -e; (exit 5) & wait; echo "still running after the failure, status $?"' still running after the failure, status 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 collect_session.py
The script named failed: db queue and exited 1. The second line shows why you cannot lean on
errexit: Bash's set -e option (set -o errexit): the shell ends the script when a simple command returns non-zero, outside the exemption spots (if/while/until condition, every command but the last of && or ||, after !, inside a function called from those, a command substitution, and non-last pipeline stages). More in “set -e, set -u and pipefail: what they really do” here: under set -e, a background job that exits 5 does not stop the script, which
ran on and printed status 0. set -e watches the foreground command in front of it, and a job’s status arrives later,
through wait. The statuses line up with the common signal convention too: a job stopped with kill PID (a SIGTERM)
makes a later wait report 143, that is 128 + 15, as the exit status lesson
sets out.
| Form | Waits for | Returns |
|---|---|---|
wait |
every background job | 0, always |
wait "$pid" |
that one job | that job’s exit status |
wait "$a" "$b" |
both jobs | the status of the last id only |
wait -n |
the next job to finish | that job’s status (Bash 4.3) |
A bare wait that hides every failure
Wrong: starting jobs with & and ending with a bare wait, then treating status 0 as success; the failures are
invisible. Right: save each PID (pids+=("$!")) and wait "$pid" on each, collecting the non-zero ones, so the
script can report exactly what went wrong.
Checkpoint
Check: wait on a PID returns that job's own status
A job pool runs a few at a time
Starting every job at once with & is fine for a handful, but 200 jobs at once can exhaust connections, memory or
file handles. A job pool: A way to run many jobs a few at a time: start up to a set number N, then wait for one to finish before starting the next, so the parallel work never exceeds the limit N. keeps the parallel work but caps it: run at most N jobs, and start
the next only when one finishes. wait -n (added in Bash 4.3, the NEWS file records) is what makes a pool tidy: it
returns as soon as any one job finishes, freeing a single slot, and wait -n -p pid (Bash 5.1) stores the PID of the
job that just finished, so you can name it. Step through a pool of three slots running six checks:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Fill the three slots. The pool starts three checks at once, one per slot, and leaves app-4, app-5 and app-6 in the queue. The limit is three, so it starts no fourth job yet.
- wait -n opens a slot. wait -n returns as soon as any one job ends. app-2 finished healthy, so slot 2 is free again while app-1 and app-3 are still running.
- Start the next job. The pool starts app-4 in the slot app-2 left, so three jobs run again. Keeping every slot full is what makes the work finish sooner.
- A failure is recorded, not lost. wait -n hands back app-3's status, and it is non-zero. The pool adds app-3 to its failed list and frees slot 3, rather than letting the failure slip past.
- Keep the slots full. app-5 starts in slot 3. Only app-6 is left in the queue now, and three checks are running again.
- The last job starts. app-1 finishes healthy, so the pool starts app-6 in slot 1. The queue is empty; from here the pool only drains.
- Drain the running jobs. The pool keeps calling wait -n until no job is left. app-4 and app-6 end healthy and app-5 ends non-zero, so the failed list is app-3 and app-5.
- The result. At most three jobs ever ran at once, and every status was checked, so the two failures are named in the end, sorted as app-3 app-5.
A slot opened the moment app-2 finished, and the pool filled it at once rather than waiting for the whole batch; the
one failure, app-3, went onto a list instead of slipping past. parallel-check.sh is that pool, and the session runs
it, then shows the version gap and a portable fallback:
#!/usr/bin/env bash
# A job pool: run a list of checks with at most `limit` running at once. wait -n
# (Bash 4.3) returns as soon as any one job finishes, which frees a slot for the
# next, and -p (Bash 5.1) stores the finished job's PID so we can name its host.
set -u
limit=3
hosts=(app-1 app-2 app-3 app-4 app-5 app-6)
codes=(0 0 6 0 2 0)
declare -A host_of
failed=()
running=0
reap() {
local pid status
wait -n -p pid
status=$?
((status == 0)) || failed+=("${host_of[$pid]}")
((running--))
}
for i in "${!hosts[@]}"; do
bash check-one.sh "${hosts[$i]}" "${codes[$i]}" &
host_of[$!]=${hosts[$i]}
((running++))
((running < limit)) || reap
done
while ((running > 0)); do
reap
done
if ((${#failed[@]})); then
mapfile -t failed < <(printf '%s\n' "${failed[@]}" | sort)
printf 'failed: %s\n' "${failed[*]}"
exit 1
fi
echo "every host ok" from session import files, run
files("parallel-check.sh", "pool-portable.sh", "check-one.sh")
run("""
# six host checks, three at a time; the failed hosts are listed, sorted
!bash parallel-check.sh
# wait -n needs Bash 4.3; the Bash 3.2 that macOS ships rejects the option
!/bin/bash -c '(exit 1) & wait -n'
# the portable pool runs in batches, so it works in Bash 5.3 and in Bash 3.2 alike
!bash pool-portable.sh
!/bin/bash pool-portable.sh
""") Output
# six host checks, three at a time; the failed hosts are listed, sorted $ bash parallel-check.sh failed: app-3 app-5 [exit status 1] # wait -n needs Bash 4.3; the Bash 3.2 that macOS ships rejects the option $ /bin/bash -c '(exit 1) & wait -n' /bin/bash: line 0: wait: -n: invalid option wait: usage: wait [n] [exit status 2] # the portable pool runs in batches, so it works in Bash 5.3 and in Bash 3.2 alike $ bash pool-portable.sh failed: app-3 app-5 [exit status 1] $ /bin/bash pool-portable.sh failed: app-3 app-5 [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pool_session.py
Both pools reported failed: app-3 app-5 and exited 1. But the middle line matters for anyone shipping a script:
macOS still ships Bash 3.2, which has no wait -n and printed wait: -n: invalid option. pool-portable.sh avoids
wait -n entirely: it runs the list in batches of three and waits for each whole batch with plain wait, so it ran
the same on Bash 5.3 and on Bash 3.2. The trade-off is that one slow job in a batch holds up the next batch, which a
wait -n pool avoids.
wait -n is Bash 4.3 and later
wait -n arrived in Bash 4.3 and wait -n -p in Bash 5.1 (the Bash NEWS file). A stock macOS has Bash 3.2, so a
pool that must run there as well as on Linux uses a batch of plain wait, or xargs -P, instead.
Checkpoint
Check: A pool runs a few jobs at a time with wait -n
xargs runs commands in parallel for you
When the jobs are “run this one command over each of a list”, xargs -P is a pool in a single line, with no PIDs to
track. -P N runs up to N copies at once and -n 1 hands each copy one item. It is a separate program, on macOS and
Linux alike, so the shell’s version does not matter:
from session import files, run
files("grade.sh")
run("""
# xargs -P runs up to three copies at once; -n 1 hands each copy one argument
printf '%s\\n' alpha beta gamma delta | xargs -P 3 -n 1 bash grade.sh
# each job wrote its own file, so the lines are whole and in the order we read them
cat alpha.out beta.out gamma.out delta.out
# when any command ends non-zero, xargs ends non-zero too (this BSD xargs returns 1)
!printf '%s\\n' one two bad | xargs -P 2 -n 1 bash grade.sh
cat bad.out
""") Output
# xargs -P runs up to three copies at once; -n 1 hands each copy one argument $ printf '%s\n' alpha beta gamma delta | xargs -P 3 -n 1 bash grade.sh # each job wrote its own file, so the lines are whole and in the order we read them $ cat alpha.out beta.out gamma.out delta.out alpha: ok beta: ok gamma: ok delta: ok # when any command ends non-zero, xargs ends non-zero too (this BSD xargs returns 1) $ printf '%s\n' one two bad | xargs -P 2 -n 1 bash grade.sh [exit status 1] $ cat bad.out bad: failed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 xargs_session.py
Each copy wrote its own .out file, so the lines stayed whole and the cat read them back in order; left to share one
stream, parallel output interleaves. The last run shows the price of the convenience: when one command exited non-zero,
xargs itself ended non-zero (this BSD xargs returned 1), but it gives one combined status for the whole run, not a
per-job result. For which item failed, have each copy record its own result, as grade.sh did. Which tool fits depends
on your case; answer three questions and let the table rule the others out:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Way to run jobs | Caps concurrency | Runs in Bash 3.2 | Per-job status | Output stays grouped | No extra tool |
|---|---|---|---|---|---|
| & then wait per PID Runs them: all at once | Poor starts every job at once, so 50 jobs means 50 at a time | Good plain & and wait per PID work in every shell | Good wait on each PID in turn gives each job its own status | Mixed lines interleave unless each job writes its own file | Good nothing beyond the shell itself |
| batch pool (plain wait) Runs them: N at a time | Good runs a batch of N, waits for all of it, then the next batch | Good only & and plain wait, so Bash 3.2 runs it | Good wait on each PID of the batch for its status | Mixed within a batch lines interleave; give each a file | Good nothing beyond the shell itself |
| wait -n pool Runs them: N at a time | Good runs at most N; a slot opens the moment any job ends | Poor wait -n needs Bash 4.3; Bash 3.2 rejects the option | Good wait -n -p names the job that just finished (Bash 5.1) | Mixed still interleaves; write per-job files or prefix lines | Good built into Bash 4.3 and later |
| xargs -P N Runs them: N at a time | Good -P N runs at most N copies at once | Good a separate program, so the shell version does not matter | Poor one combined status for the lot, not per job | Mixed -P mixes output; have each copy write its own file | Good xargs ships on macOS and Linux (-P on both) |
| GNU Parallel Runs them: N at a time | Good -j N runs N at once | Good a separate program, any shell | Good a per-job exit code, and --halt to stop early | Good --keep-order holds each job output back into order | Poor must be installed; not on a stock macOS |
How the answers decide
- “Could starting every job at once overload the target or your own machine?” Yes, cap how many run: rules out & then wait per PID. Starting every job at once can overload the target or the machine, so a way that caps concurrency fits better.
- “Must the same script run on a stock macOS (Bash 3.2) as well as on Linux?” Yes, Bash 3.2 too: rules out wait -n pool and GNU Parallel. A stock macOS has neither wait -n (it needs Bash 4.3) nor GNU Parallel, so neither runs there unchanged.
- “Do you need to know which individual jobs failed, not just that one did?” Yes, name each failure: rules out xargs -P N. xargs reports one combined status for the whole run, so it cannot tell you which job failed.
In short: a loop of & with a per-PID wait when the list is small; a wait -n pool or xargs -P when you must cap
how many run at once; a batch pool when the script must also run on Bash 3.2; and GNU Parallel, named here but not run
because it is not installed, when you want grouped output and per-job codes and can install it.
Checkpoint
Check: xargs -P runs commands in parallel
nohup keeps a job running after logout
A background job still belongs to its shell. When the terminal closes, the shell receives SIGHUP and passes it on to
its jobs, so a long job started with a plain & can die with the connection. nohup fixes that: it starts the command
set to ignore SIGHUP, and the usual form redirects all three streams so the job never touches a terminal that may
vanish.
#!/usr/bin/env bash
# Start a long job so that it survives the terminal closing. nohup makes the job
# ignore SIGHUP, and redirecting all three streams means the job never reads from
# or writes to a terminal that may vanish. The shell is then free to log out.
set -u
# > log sends standard output to a file, 2>&1 sends standard error the same way,
# and < /dev/null gives the job an input that is always at end of file.
nohup bash long-build.sh > build.log 2>&1 < /dev/null &
echo "build started; its PID is in \$! and its output is in build.log"
# disown would also remove the job from this shell's table so no SIGHUP is sent to
# it on exit; nohup already covers that, so the two are rarely both needed. > build.log sends standard output to a file, 2>&1 sends standard error the same way, and < /dev/null gives the
job an input that is always at end of file, so it never blocks waiting for a terminal. nohup(1) adds one rule worth
knowing: if standard output is still a terminal, nohup appends the output to a file called nohup.out instead; with
the explicit > build.log above, it does not. The other two tools people reach for do related jobs:
| Tool | What it does | Where |
|---|---|---|
nohup cmd > log 2>&1 & |
starts the job set to ignore SIGHUP | macOS and Linux |
setsid cmd |
runs the command in a new session, with no controlling terminal | Linux (util-linux) |
disown %1 |
removes a running job from the shell’s job table, so no SIGHUP is sent on exit | Bash, any version |
setsid is a Linux tool
setsid starts a command in a new session so no terminal can signal it, but it is part of util-linux and a stock
macOS does not have it, so the sessions here do not run it. disown only affects an interactive shell’s own job
table; the job control lesson covers disown and disown -h in
full.
Checkpoint
Check: nohup keeps a job alive after logout
Practise: a parallel checker you can run
Write run_parallel N cmd_file, a script that reads one command per line from cmd_file and runs them with at most
N going at once, exiting non-zero if any command failed. Build it from this lesson’s pieces: a wait -n -p pool that
caps concurrency, a saved association from each PID to its command, and a failed list. Check it in your own shell
against a small cmd_file of your own: a mix of true, false and sh -c 'exit 2' lines should make run_parallel 2
exit non-zero and name the failing lines, while a file of only true lines should exit 0. To prove the cap holds
without timing anything, have each command append a line to a counter file on entry and remove it on exit, then check
that the counter file never held more than N lines at once. A reference solution built this way exits 0 on the
all-true file and non-zero, naming two failures, on the mixed file.
Practice that adapts to you
Practise this lesson
11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): what does $! hold, and what do wait PID and a bare wait each return?
$! holds the process id of the most recent background job, the one started by the last & (or a coprocess), so you
read it straight after starting a job you want to track; the next background job overwrites it. wait PID waits for
that one job and returns its own exit status, which is how you turn a background job back into a status you can test:
a job that ran (exit 7) makes wait return 7, and a job killed by SIGTERM makes it return 143. A bare wait, with
no argument, is different: it waits for every background job to finish and then returns 0, whatever those jobs did. So
a bare wait is a barrier, not a status check; when a job may fail, save its PID and wait on it by name.
The two harder questions are about real breakages: a deploy that reported success while two hosts had failed, and a CI step that hung for its whole timeout though it returned at once on a laptop. Think about where you would look first in each case, then open the worked first step below:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A deploy script starts rsync to 12 hosts, each with &, and ends with a bare wait. Two hosts failed, yet the script exited 0 and the release was marked done. The team now needs to run four at a time, on macOS laptops (Bash 3.2) as well as Linux CI. Redesign the loop.
-
Mid to senior
A CI step runs ./start-server.sh, which ends with server &, and the script exits 0. On a laptop the step returns at once, but in CI it hangs until the job times out ten minutes later. Explain why, and fix start-server.sh.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does the status of cmd & tell you?
Only that the job started: & returns 0 at once. The job's own result comes later, from wait.
-
What does $! hold, and when must you read it?
The process ID of the most recent background job. Read it straight after the & that started the job you want to track, because the next background job overwrites it.
-
What do wait PID and a bare wait return?
wait PID waits for that job and returns its own exit status. A bare wait waits for every job and returns 0, so it reports nothing about what failed.
-
How can a script tell which of several background jobs failed?
Keep each job's PID and call wait on them one at a time; naming several PIDs in one wait returns only the last one's status, and a background job's failure never trips set -e.
-
How does a script run many jobs but only a few at a time?
With a job pool: start jobs until the cap is reached, then wait -n to reuse a slot as soon as any job ends, and wait -n -p var, from Bash 5.1, to learn which one ended. wait -n needs Bash 4.3, so a stock macOS Bash falls back to batches of plain wait.
-
What do xargs -P N and nohup add for background work?
xargs -P N is a one-line pool that runs up to N commands at once, with one combined status. nohup cmd > log 2>&1 & keeps a job alive after you log out by ignoring SIGHUP.
Key takeaways
&starts a background job and returns 0 at once, saying only that the job started;$!holds the PID of the most recent job, so read it immediately for a laterwait.wait PIDreturns that job’s own exit status, while a barewaitreturns 0, and naming several PIDs returns only the last, so collect failures one PID at a time; a background job’s failure never tripsset -e.- A job pool caps how many run at once:
wait -nfrees a slot as soon as any job finishes andwait -n -pnames it, butwait -nneeds Bash 4.3, so a script that must run on a stock macOS falls back to a batch of plainwait. xargs -P Nis a one-line pool with a single combined status, andnohup cmd > log 2>&1 &keeps a job alive past a logout by ignoring SIGHUP and keeping it off the terminal.
Terms in this lesson
- background job
- A command run with a trailing & so the shell starts it in a subshell and goes on without waiting; $! holds the PID of the most recent one, and wait collects its exit status.
- job pool
- A way to run many jobs a few at a time: start up to a set number N, then wait for one to finish before starting the next, so the parallel work never exceeds the limit N.
References
- Bash Reference Manual: Lists (the & operator) (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Job Control Builtins (wait, disown) (Chet Ramey (GNU Bash maintainer))
- Bash NEWS (wait -n in bash-4.3, wait -n -p in bash-5.1) (Chet Ramey (GNU Bash maintainer))
- POSIX.1-2024: wait (The Open Group and IEEE)
- nohup(1): run a command immune to hangups (man7.org (Linux man-pages))
- setsid(1): run a program in a new session (man7.org (Linux man-pages))
Report a problem with this lesson
Kept only in this browser. Your Learn progress