Linux & Bash · Part II · Module 30 · Running Work on Shared Engineering Machines
Several Jobs at Once, Without Losing Track
Four simulations from one shell is two lines of bash, and collecting their results correctly is not. This chapter measures the obvious way — which reports success for a batch in which two jobs failed — finds that the modern idiom for fixing it does not exist on this bash, and builds the pattern that works anyway.
Intermediate21 min readLinuxPart IIJobsExit StatusParallelism
Part II · Module 30 · Chapter 30.2 · Several Jobs at Once, Without Losing Track
Chapter 30.1 launched one long job and made it findable. Four at once is almost the same two lines, and the difference is entirely in how you collect the results.
1. The Obvious Way Reports Success
$ ( exit 0 ) & ( exit 3 ) & ( exit 7 ) &
$ wait
$ echo $?
0Three jobs exiting 0, 3 and 7, and the shell reports 0.
wait with no arguments waits for every child and returns 0 — not the worst status, not an aggregate, just zero. It is documented behaviour and it is the single most common way a parallel regression reports a clean night that was not.
The shape to recognise in a real script:
# this reports success whatever happens
for t in smoke fairness random burst; do
./run_one.sh "$t" > "logs/$t.log" 2>&1 &
done
wait
echo "all tests passed" # printed unconditionallyNothing about that is obviously wrong, which is why it survives review.
2. The Modern Fix Does Not Exist Here
Newer bash offers wait -n, which returns when the next job finishes and gives you its status — the building block for bounded parallelism. On this machine:
$ bash -c 'sleep 0.1 & wait -n'
bash: line 0: wait: -n: invalid option
wait: usage: wait [n]
$ echo "$BASH_VERSION"
3.2.57(1)-release3. The Pattern That Collects Every Status
wait with a pid argument returns that job's status. So keep the pids:
pids=""
for code in 0 3 7 0; do
( exit $code ) & pids="$pids $!"
done
i=0; fails=0
set -- $pids
for p in "$@"; do
wait "$p"; rc=$?
i=$((i + 1))
printf ' job %d pid=%-7s rc=%d\n' "$i" "$p" "$rc"
[ "$rc" -ne 0 ] && fails=$((fails + 1))
done
printf ' collected %d statuses, %d non-zero\n' "$i" "$fails" job 1 pid=98387 rc=0
job 2 pid=98388 rc=3
job 3 pid=98389 rc=7
job 4 pid=98390 rc=0
collected 4 statuses, 2 non-zeroEvery status, correctly attributed. Three things make it work.
$! is captured immediately. It holds the pid of the most recent background command and is overwritten by the next one, so it must be read on the line after the & — never later.
The pids are accumulated as a string and re-split with set --. Bash 3.2 has arrays and they would be cleaner (pids+=("$!")), and the string form is shown here because it is what survives being pasted into a sh-only context. Either works; what does not work is relying on $! to still be meaningful after the loop.
Each wait is separate. Waiting on a pid blocks until that job finishes, so the loop finishes in the order you wrote rather than the order they completed. For collecting statuses that is fine. For bounded parallelism — starting a new job as soon as any slot frees — it is not, and without wait -n that needs a different structure, which is Chapter 31.5's subject.
4. Knowing Which Job Is Which
jobs -l lists them with pids, and it works in a script even though job control is off there:
$ set -o | awk '/monitor/{print $2}'
off
$ sleep 5 & sleep 5 &
$ jobs -l
[3] 98356 Exit 7 ( exit 7 )
[4]- 98361 Running sleep 5 &
[5]+ 98362 Running sleep 5 &Three things in that output are worth knowing.
Job control is off and jobs still works. What monitor mode adds is process groups per job, fg/bg, and job-control signals — not the table. So a script can enumerate its own jobs.
A finished job is still listed, with its status: [3] Exit 7. Bash retains a terminated job until it has been reported once, which is why a jobs call in a loop can show a job that is already gone.
- and + mark the previous and current jobs. Those are what a bare fg or %+ refers to, and they shift as jobs finish — so a script should never use them. Use the pid.
For a batch you want the mapping written down rather than inferred:
#!/bin/bash
# runbatch TEST... — run each test in its own directory, collect every status.
set -u
[ $# -gt 0 ] || { echo "usage: runbatch TEST..." >&2; exit 2; }
runs=${RUNS:?runbatch: set RUNS}
[ -d "$runs" ] || { echo "runbatch: no such directory: $runs" >&2; exit 2; }
pids=""; names=""
for t in "$@"; do
d="$runs/$t"
mkdir -p "$d" || exit 2
( cd "$d" && exec vvp "$SIM" "+TESTNAME=$t" +STRICT ) > "$d/run.log" 2>&1 &
pids="$pids $!"
names="$names $t"
printf 'started %-12s pid=%-7s %s\n' "$t" "$!" "$d/run.log"
done
# the mapping, on disk, because $! and the jobs table are both transient
paste <(printf '%s\n' $pids) <(printf '%s\n' $names) > "$runs/batch.map"
bad=0
set -- $pids
i=0
for p in "$@"; do
i=$((i + 1))
t=$(printf '%s\n' $names | sed -n "${i}p")
wait "$p"; rc=$?
log="$runs/$t/run.log"
if grep -q 'TEST PASSED' "$log" 2>/dev/null; then v=PASS
elif grep -q 'TEST FAILED' "$log" 2>/dev/null; then v=FAIL
else v=NO-VERDICT; fi
printf '%-12s pid=%-7s rc=%-4s %s\n' "$t" "$p" "$rc" "$v"
[ "$v" = PASS ] || bad=$((bad + 1))
done
printf '%d of %d not passing\n' "$bad" "$#"
exit $(( bad > 0 ))$ RUNS=$PWD/batch SIM=$PWD/logs/plusarg.vvp runbatch smoke fairness random bogus
started smoke pid=1959 /…/batch/smoke/run.log
started fairness pid=1961 /…/batch/fairness/run.log
started random pid=1963 /…/batch/random/run.log
started bogus pid=1965 /…/batch/bogus/run.log
smoke pid=1959 rc=0 PASS
fairness pid=1961 rc=0 PASS
random pid=1963 rc=0 PASS
bogus pid=1965 rc=0 FAIL
1 of 4 not passing
$ echo $?
1
$ cat batch/batch.map
1959 smoke
1961 fairness
1963 random
1965 bogusLook at the rc column: all four exited 0, including the one that failed. The PASS/FAIL column came from the log, and the two columns beside each other are the whole of Module 29's argument in one screen.
And the refusals:
$ runbatch
usage: runbatch TEST...
$ ( unset RUNS; runbatch smoke )
runbatch: line 5: RUNS: runbatch: set RUNS
$ RUNS=/no/such runbatch smoke
runbatch: no such directory: /no/suchFour decisions.
The mapping is written to batch.map. Both $! and the jobs table are transient — the first is overwritten immediately and the second is cleared as jobs are reported — so the only durable record is a file.
The verdict comes from the log, and rc is printed beside it. Module 29 in one line: a failing simulation exits 0, so the status is evidence and not the answer.
NO-VERDICT is a third state, for the job that was killed or starved — Chapter 29.5.
It exits non-zero if anything is not a pass, so the batch is usable in a condition.
5. Where the Output Goes
The reason each job gets its own directory is Chapter 27.3's measurement — four concurrent runs sharing one directory produced a single waveform that matched none of them. Running several at once makes two further redirection mistakes easy, and both are silent.
Appending instead of truncating. >> on a shared log interleaves four jobs' output at line granularity if they are line-buffered, and at block granularity if they are not:
# wrong: four jobs, one log, interleaved at whatever granularity the buffers use
./run_one.sh a >> batch.log 2>&1 &
./run_one.sh b >> batch.log 2>&1 &Chapter 30.3 measures the buffering directly; the consequence here is that the interleaving is not even predictable.
Forgetting 2>&1 on one of them. Three jobs' stderr goes to a file and the fourth's goes to your terminal, so one job's errors are the only ones you see and the other three look clean:
# the fourth job's stderr is missing from its log
./run_one.sh d > logs/d.log &The defence is to not write the redirection four times:
run() { # one place where redirection is decided
local t=$1 d="$RUNS/$1"
mkdir -p "$d" || return 2
( cd "$d" && exec vvp "$SIM" "+TESTNAME=$t" +STRICT ) > "$d/run.log" 2>&1 &
}
for t in "$@"; do run "$t"; pids="$pids $!"; done$! after a function call that backgrounds something still works, and it is more robust than it looks:
$ run() { ( sleep 2 ) & }
$ run2() { ( sleep 2 ) & echo started >/dev/null; }
$ run; p1=$!; kill -0 "$p1" && echo "last command was the &: \$!=$p1 valid"
last command was the &: $!=1923 valid
$ run2; p2=$!; kill -0 "$p2" && echo "a command AFTER the &: \$!=$p2 valid"
a command AFTER the &: $!=1927 validBoth work, because $! is only updated by the background operator — an ordinary command afterwards does not disturb it, and neither does returning from the function. What does overwrite it is the next &, which is why §3 captures it immediately inside the loop.
Practice
- Run three subshells exiting 0, 3 and 7, then bare
wait, and record$?. - Write the four-line loop that looks correct and reports success unconditionally.
- Try
wait -nand record the exact error and your bash version. - Add the version guard and confirm it fires with a clear message.
- Capture
$!one line later than the&and see which pid you get. - Collect four statuses with the pid loop and confirm two are non-zero.
- Wait twice on the same pid and record both statuses.
- Wait on pid 1 and on a pid that cannot exist, and compare the two messages.
- Check whether
monitoris on in a script, then calljobs -lanyway. - Find a completed job in
jobs -loutput and explain why it is still listed. - Write
runbatchand confirm it exits non-zero when one test fails. - Delete
batch.mapand try to work out which pid was which from the jobs table. - Send four jobs' output to one appended log and look at how the lines interleave.
- Omit
2>&1from one job and find where its errors went.
Check Your Understanding
- What does bare
waitreturn when three jobs exit 0, 3 and 7? - Why does the four-line loop survive code review?
- What is
wait -nfor, and what happens here? - Which bash version introduced it, and which is on this machine?
- What updates
$!, and what does not? - Why does the collection loop finish in the order written rather than the order completed?
- What is that unsuitable for, and which module addresses it?
- What does a second
waiton the same pid return? - What does
wait 1return, and how does it differ fromwait 999999? - Is job control on in a script, and does
jobswork? - Why is a finished job still in the
jobslisting? - Why should a script never use
%+or%-? - Why is the pid-to-name mapping written to a file?
- Give the two silent redirection mistakes that appear only with several jobs.
6. Remember
- Bare
waitreturns 0 even when jobs exited 3 and 7. It is the commonest way a parallel regression reports a clean night that was not. wait -nneeds bash 4.3 and this is 3.2.57. Guard the version or do not depend on it.- Only
&updates$!. A command after it, or returning from a function, leaves it intact; the next&overwrites it. wait <pid>per job collects every status, correctly attributed.- Waiting twice on a pid returns the same status; a non-child or a nonexistent pid returns 127 with the same message for both.
jobs -lworks with job control off, lists finished jobs until reported, and marks+/-positions that shift — so scripts use pids.- Write the pid-to-name mapping to a file. Both
$!and the jobs table are transient. - Take the verdict from the log and print
rcbeside it. A failing simulation exits 0. - One directory per job, and decide redirection in exactly one place so no job loses its stderr.
7. What Is Next
Four jobs are running and their results will be collected correctly. Chapter 30.3 watches them while they run — and finds that a log with nothing new in it is no evidence at all that a job has stopped working.
Where this fits
Part of the Linux & Bash curriculum.
