Run Multiple Bash Commands in Parallel and Capture Exit Codes

Tested on RHEL 10.2 (Coughlan)
Package bash-5.2.26-6.el10.x86_64
findutils-4.10.0-5.el10.x86_64
parallel 20260822
Applies to Linux distributions with Bash installed
Privilege Normal user
Scope Running multiple Bash commands in parallel: &, $!, wait, per-child exit codes, PID→task maps, bounded concurrency with wait -n / wait -n -p, output isolation, stopping when one job fails, xargs -P, and GNU Parallel comparison. Does not cover interactive job control (fg, bg, jobs) in depth.
Related guides Shell scripting course hub
Short on time?Beta Get the gist of this 13-minute read in seconds, then ask follow-up questions

Run multiple Bash commands concurrently by starting them with &, saving each PID with $!, and collecting their exit codes with wait.


Quick answer: run commands in parallel and read exit codes

Append & to each command, then wait for every background job to finish:

bash
sleep 2 &
sleep 1 &
sleep 3 &
wait

All three commands overlap instead of running one after another.

IMPORTANT
A bare wait tells you when all jobs have finished, but not which one failed. Save $! and use wait "$pid" when exit status matters.

If you also need each command's exit status, save $! after starting each process:

bash
sleep 2 &
p1=$!
sleep 1 &
p2=$!
wait "$p1"; rc1=$?
wait "$p2"; rc2=$?
echo "cmd1=$rc1 cmd2=$rc2"

Sample output:

output
cmd1=0 cmd2=0

Use & to start commands concurrently, $! to save each PID, and wait "$pid" to retrieve each command's exit status.

Need Use
Run a few known commands together & + wait "$pid"
Limit how many jobs run at once wait -n, then wait -n -p when you need identity
Run one command over many inputs xargs -P
Grouped output, job logs, richer batch controls GNU Parallel

For line-based input without a custom Bash loop, -P caps concurrency directly:

bash
printf '%s\n' host1 host2 host3 | xargs -n1 -P3 ping -c1

Set a lower concurrency limit for disk- or network-heavy jobs. CPU-bound work often tracks core count; I/O-bound work usually needs a smaller cap.


Run Multiple Commands with & and wait

The pattern above overlaps independent work. Three sequential sleeps take about six seconds on the wall clock:

bash
TIMEFORMAT='real %R sec'
time { sleep 1; sleep 2; sleep 3; }

Sample output:

output
real 6.042 sec

The same three commands in the background finish in roughly the time of the longest sleep:

bash
time { sleep 1 & sleep 2 & sleep 3 & wait; }

Sample output:

output
real 3.029 sec

The three background jobs finish in about three seconds because their sleep periods overlap.

To measure script execution time on your own batches, wrap the loop with time the same way.

wait with no arguments waits for all running background jobs and returns zero when they have all exited, even when a child failed. It does not report which command broke or preserve individual exit codes. Save each PID with $! and call wait "$pid" on each child when per-task status matters. For more on &, $!, and plain wait, see Bash background processes.


Capture Exit Codes from Parallel Commands

After each wait "$pid", store $? before the next command runs:

bash
false & f=$!
true & t=$!
wait "$f"; e1=$?
wait "$t"; e2=$?
echo "fail=$e1 ok=$e2"

Sample output:

output
fail=1 ok=0

For many tasks, keep task_status[name]=$rc in an associative array. Walk the original task list in stable order when you print a summary:

bash
overall=0
for task in "${tasks[@]}"; do
  rc=${task_status[$task]:-999}
  printf '%-12s %s\n' "$task" "$rc"
  (( rc != 0 )) && overall=1
done
exit "$overall"

Exit code 999 in the summary means a task was never reaped. Under Bash strict mode (set -e), collect expected non-zero statuses in an if wait "$pid"; then … else rc=$?; fi branch rather than using a bare wait "$pid".

See Bash exit codes for how statuses above 128 encode signals.


Identify Which Parallel Command Failed

A PID alone does not tell you which command or task failed. Map each PID to a task name when you launch the child:

bash
declare -A pid_to_task=()
bash -c 'sleep 1; exit 3' & pid_to_task[$!]=alpha
bash -c 'sleep 1; exit 0' & pid_to_task[$!]=beta
for pid in "${!pid_to_task[@]}"; do
  wait "$pid"
  rc=$?
  echo "task=${pid_to_task[$pid]} pid=$pid exit=$rc"
done

Sample output:

output
task=alpha pid=39102 exit=3
task=beta pid=39103 exit=0

Print order is not guaranteed because associative-array iteration order is unspecified. Use wait -n -p when you need to know which child finished first.


Limit the Number of Parallel Jobs

When you have dozens of tasks, starting them all at once can overload CPU, disk, or network. On Bash 4.3+, a simple cap launches work until max_jobs slots are full, then uses wait -n to wait for one child to finish:

bash
max_jobs=4
running=0
tasks=(task1 task2 task3 task4 task5 task6)

for task in "${tasks[@]}"; do
  echo "start $task" &
  ((running++))
  if (( running >= max_jobs )); then
    wait -n || true
    ((running--))
  fi
done
wait

This simple throttle deliberately discards child exit codes. Use the PID-mapped worker pool below when failures matter.

Form What it does
wait Wait for all background jobs; returns 0 even if a child failed
wait "$pid" Wait for one known PID and return its status
wait -n Wait for whichever unwaited child finishes next
wait -n -p var Wait for the next child and store its process or job identifier in var

wait -n requires Bash 4.3 or later; wait -n -p requires Bash 5.1 or later. On older Bash, cap concurrency by waiting on the oldest PID in a FIFO queue.

Avoid polling jobs -p | wc -l as your primary throttle. Job counts and unwaited children can disagree during rapid start and exit bursts.


Build a Worker Pool with wait -n -p

When you need both bounded concurrency and per-task exit codes, keep a pid_to_task map and use wait -n -p as jobs finish. In these non-job-control script examples, -p stores the completed child's PID in finished_pid. The return status of wait -n is the child's exit code; you do not need a second wait "$pid".

bash
reap_one() {
  local finished_pid rc finished_task
  finished_pid=
  if wait -n -p finished_pid; then
    rc=0
  else
    rc=$?
  fi
  [[ -z ${finished_pid:-} ]] && return 1
  finished_task=${pid_to_task[$finished_pid]}
  task_status[$finished_task]=$rc
  unset 'pid_to_task[$finished_pid]'
}

Before starting another background job, call reap_one while ${#pid_to_task[@]} is at or above MAX_JOBS. When the task queue is empty but slots remain busy, keep reaping until the map is clear.

A two-job pool records status as each slot frees:

bash
declare -A pid_to_task=()
declare -A task_status=()
( sleep 0.5; exit 1 ) & pid_to_task[$!]=fast-fail
( sleep 2; exit 0 ) & pid_to_task[$!]=slow-ok
while ((${#pid_to_task[@]} > 0)); do
  finished_pid=
  if wait -n -p finished_pid; then
    rc=0
  else
    rc=$?
  fi
  [[ -z ${finished_pid:-} ]] && break
  task_status[${pid_to_task[$finished_pid]}]=$rc
  unset 'pid_to_task[$finished_pid]'
done
printf 'fast-fail=%s slow-ok=%s\n' "${task_status[fast-fail]}" "${task_status[slow-ok]}"

Sample output:

output
fast-fail=1 slow-ok=0

The shorter job finishes first even though it started second.


Stop Launching Jobs After a Failure

Decide what should happen when one parallel job fails:

  • Finish all: let all tasks finish, then return failure if any task failed.
  • Fail fast: stop launching new work after the first failure while workers may still be running; optionally kill running workers from your cleanup trap.

Finish all launches every job before waiting:

bash
declare -A st=()
for label in ok fail ok; do
  bash -c '[[ $1 == fail ]] && exit 2 || exit 0' _ "$label" &
  st[$!]=$label
done
overall=0
for pid in "${!st[@]}"; do
  wait "$pid"
  rc=$?
  echo "${st[$pid]} exit=$rc"
  (( rc != 0 )) && overall=1
done
echo "overall=$overall"

Sample output:

output
ok exit=0
fail exit=2
ok exit=0
overall=1

Every task runs to completion even after fail exits 2. A fail-fast implementation still needs bounded parallel execution; waiting immediately after each launch makes the loop sequential. Extend the worker pool so a non-zero wait -n -p result sets a flag and stops launching new tasks:

bash
if wait -n -p finished_pid; then
  rc=0
else
  rc=$?
  stop_launching=1
fi

Record the status, remove the PID from pid_to_task, and skip starting new work while stop_launching is set. Workers already in the pool can finish or receive kill from your cleanup trap.

GNU Parallel handles this without a custom loop. --halt soon,fail=1 stops launching new jobs and waits for running ones to finish. --halt now,fail=1 terminates running jobs when one fails.


Keep Parallel Output Readable

Background jobs share stdout and stderr unless you redirect them. Lines interleave unpredictably:

bash
for i in 1 2 3; do
  ( echo "task$i start"; sleep 1; echo "task$i end" ) &
done
wait

Sample output:

output
task1 start
task2 start
task3 start
task2 end
task1 end
task3 end

Redirect each child to its own log file to keep output coherent:

bash
for i in 1 2 3; do
  ( echo "task$i start"; sleep 1; echo "task$i end" ) >"task-$i.log" 2>&1 &
done
wait

For live output, prefix lines with the task name. For logs, writing one file per task keeps the output separate. See Bash redirection for append vs truncate and merging stderr into the same log.


Clean Up Parallel Jobs on Ctrl+C

Background jobs can continue after the parent script exits unless you clean them up. Add an EXIT trap that kills and waits for tracked workers, and handle INT and TERM by exiting with the corresponding status (same pattern as Bash trap). In a worker pool, iterate the live pid_to_task keys directly:

bash
cleanup() {
  local pid
  for pid in "${!pid_to_task[@]}"; do
    kill "$pid" 2>/dev/null || true
  done
  for pid in "${!pid_to_task[@]}"; do
    wait "$pid" 2>/dev/null || true
  done
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

Ctrl+C runs the INT trap, which calls exit 130. Bash then runs the EXIT trap once. The complete example at the end wires the same handlers into its worker pool.


Run Many Inputs with xargs -P

When each input line drives the same command template, GNU xargs -P n can run the same command over multiple inputs with a fixed concurrency limit. Pass each input as an argument to bash -c instead of interpolating it into the command string:

bash
printf '%s\n' host1 host2 host3 host4 host5 | xargs -P 2 -I{} bash -c 'sleep 1; printf "%s done\n" "$1"' _ {}

Sample output:

output
host1 done
host2 done
host3 done
host4 done
host5 done

-P 2 keeps at most two children alive. The _ before {} becomes $0, while {} is passed as $1 instead of being embedded in shell code. This avoids shell re-parsing of spaces and metacharacters in the substituted value. For arbitrary filenames, prefer NUL-delimited input with -0.

GNU xargs returns an aggregate status rather than preserving each child's exit code:

xargs exit Meaning
0 Success
123 At least one invocation exited with status 1–125
124 At least one invocation exited with status 255
125 At least one command was killed by a signal
126 Command found but not executable
127 Command not found
1 Other xargs error

When any child fails in a typical batch:

bash
printf '%s\n' ok fail ok | xargs -P 3 -I{} bash -c '[[ $1 == fail ]] && exit 2 || exit 0' _ {}
echo "xargs exit=$?"

Sample output:

output
xargs exit=123

Use xargs -P when you need throughput over many inputs and only care whether the batch failed. Use a Bash worker pool when you must map each failure to a named task and preserve individual exit codes in a summary table.


When to Use GNU Parallel

GNU Parallel adds job logs, grouped output, and rich input quoting on top of plain xargs. It is a separate package (parallel on most distros).

Run the same worker script over named tasks with at most four concurrent jobs:

bash
parallel -j4 ./worker.sh ::: task1 task2 task3 task4

Stop launching new jobs after the first failure, but let running jobs finish:

bash
parallel -j4 --halt soon,fail=1 ./worker.sh ::: task1 task2 task3 task4

GNU Parallel is useful for grouped output, job logs, and larger batch workflows. A Bash worker pool avoids the extra dependency and works well when tasks are shell functions in the same script.


Complete Example: Bounded Parallel Jobs with Exit Codes

The script below runs ten tasks with at most four concurrent workers. Each task writes to its own log file, records exit status in task_status, prints a summary table, exits non-zero if any task failed (task 7 exits 17 on purpose), and cleans up on interrupt:

bash
#!/usr/bin/env bash
MAX_JOBS=4
declare -A pid_to_task=()
declare -A task_status=()
tasks=()
for i in $(seq 1 10); do
  tasks+=("task-$i")
done

cleanup() {
  local pid
  for pid in "${!pid_to_task[@]}"; do
    kill "$pid" 2>/dev/null || true
  done
  for pid in "${!pid_to_task[@]}"; do
    wait "$pid" 2>/dev/null || true
  done
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

run_task() {
  local id=$1
  sleep $((RANDOM % 2 + 1))
  if (( id % 7 == 0 )); then
    return 17
  fi
  return 0
}

reap_one() {
  local finished_pid rc finished_task
  finished_pid=
  if wait -n -p finished_pid; then
    rc=0
  else
    rc=$?
  fi
  [[ -z ${finished_pid:-} ]] && return 1
  finished_task=${pid_to_task[$finished_pid]}
  task_status[$finished_task]=$rc
  unset 'pid_to_task[$finished_pid]'
}

for task in "${tasks[@]}"; do
  while (( ${#pid_to_task[@]} >= MAX_JOBS )); do
    reap_one || break
  done
  run_task "${task#task-}" >"out-${task}.log" 2>&1 &
  pid=$!
  pid_to_task[$pid]=$task
done

while (( ${#pid_to_task[@]} > 0 )); do
  reap_one || break
done

fail=0
printf '%-10s %s\n' "TASK" "EXIT"
for task in "${tasks[@]}"; do
  rc=${task_status[$task]:-999}
  printf '%-10s %s\n' "$task" "$rc"
  (( rc != 0 )) && fail=1
done
exit "$fail"

Save it as run-pool.sh and run bash run-pool.sh. task-7 exits with status 17, while the script itself exits with status 1 because at least one task failed. Check out-task-7.log for that task's output.


Common Parallel Execution Mistakes

Symptom Likely cause Fix
Wrong exit code in the report $? read after echo or another command Capture rc=$? immediately after each wait "$pid"
if ! wait shows status 0 ! negates the test; $? is not the child's code Use if wait "$pid"; then … else rc=$?; fi
Jobs never run in parallel Start one job, wait immediately, repeat Use a worker pool; sequential wait is not parallel fail-fast
All tasks show exit 0 but one failed Used bare wait with no arguments Wait per PID and store each status
Runaway load -P or MAX_JOBS set to CPU count for I/O work Set a lower concurrency limit for disk- or network-heavy jobs
Garbled log lines Every child prints to shared stdout Redirect per task or prefix lines inside the child
Orphans after Ctrl+C No trap on INT/TERM Kill tracked PIDs in EXIT cleanup; exit 130/143 from signal handlers

Summary

You can run multiple Bash commands in parallel by appending &, then either call bare wait to synchronize or save each $! and use wait "$pid" to collect exit codes. Bare wait does not report which child failed. Map PIDs to task names, cap concurrency with wait -n or wait -n -p, and decide whether to finish every job or stop launching after the first failure.

Redirect output per task when logs must stay readable, and register traps when interrupts should stop leftover workers. Use xargs -P or GNU Parallel for line-driven batches; use a Bash worker pool when you need a per-task status table. The complete example combines bounded concurrency, exit-code tracking, per-task logs, and interrupt cleanup.


References


Frequently Asked Questions

How do I run multiple Bash commands in parallel?

Append & to each command to start it in the background, then call wait with no arguments to block until every child finishes. Save each $! and use wait "$pid" when you need each command's exit code separately.

How do I limit Bash to N parallel jobs?

Start jobs until N are running. Before launching another, call wait -n to wait for one slot to become free. When you also need the completed job's identity and exit code, track live PIDs and use wait -n -p (Bash 5.1+).

How do I know which task failed?

Store pid_to_task[$!]=task_name when you start each job. After wait "$pid", look up the name and save task_status[$name]=$?. In a worker pool, wait -n -p finished_pid stores the completed child's PID in finished_pid, while wait itself returns that child's exit status.

How do I stop parallel jobs when one command fails?

Extend the worker pool so wait -n -p sets stop_launching=1 on a non-zero child exit and the launch loop skips new tasks while workers still run. With GNU Parallel, use --halt soon,fail=1 to stop launching and wait for running jobs.

How do I prevent interleaved output from parallel Bash jobs?

Redirect each child to its own log file, or prefix every line with the task name inside the child. Shared stdout without tagging produces mixed lines under load.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)