To run work in parallel on Linux, start each job in the background with & and collect results with wait; for a list of inputs, fan them out with xargs -P or GNU parallel; and when the work outgrows a shell script, move up to Python's concurrent.futures.ProcessPoolExecutor or a job queue such as Celery. The original version of this article focused on tracking and controlling child processes by hand with $!, $?, signals, and the fork/exec/wait model. That foundation still matters in 2026, but most parallel work today should use a higher-level tool that handles the bookkeeping for you.
This guide covers all four layers — shell jobs, xargs -P, GNU parallel, and a real task queue — with runnable snippets, a side-by-side comparison table, and clear guidance on when to stop fighting wait loops and let a library do the work.
Key takeaways
- Run jobs concurrently with
cmd &and synchronize withwait; capture each child's PID from$!and its exit status from$?. - Use
wait -n(Bash 4.3+) to react the moment any job finishes instead of blocking on the slowest one — pair it with a counter to cap how many run at once. - For fan-out over a list,
xargs -P N -n 1is already installed everywhere; GNU parallel adds retries, per-job logs (--joblog), failure control (--halt), and the{}/:::syntax. - Parallel shell jobs only use multiple CPU cores if the work is CPU-bound and the kernel schedules them across cores; pin with
tasksetand lower priority withnicewhen needed. - A direct parent can always reap (
wait) its own children, which prevents zombies; never kill a recycled PID you read long ago — kill from the direct parent only. - When jobs are long-running, need retries, scheduling, or run across machines, move up to Python
multiprocessing/ProcessPoolExecutoror a task queue like Celery managed by Supervisor or systemd.
How do you run commands in parallel in the shell?
Append & to a command to launch it as a background job in its own child process; the shell returns immediately and keeps going. Each background command runs concurrently with the others and with the parent shell. The key built-ins for coordinating them are:
&— run the preceding command in the background.$!— the PID of the most recently backgrounded job. Save it right after the&or you will lose it.wait— block until all background children finish (orwait PIDfor a specific one). Afterwait PID,$?holds that job's exit code.jobs— list the current shell's active jobs and their job specs (%1,%2, …).
A common mistake is reading $? after launching several jobs and expecting a meaningful status — $? only reflects the last command. To get each child's real exit code, save its PID, then wait on that PID and read $? immediately.
#!/usr/bin/env bash
set -uo pipefail
# Launch three jobs concurrently and capture each PID.
sleep 3 & p1=$!
sleep 1 & p2=$!
sleep 2 & p3=$!
jobs # show the three running jobs
# wait on each specific PID, then read ITS exit code from $?
wait "$p1"; echo "job1 (pid $p1) exited $?"
wait "$p2"; echo "job2 (pid $p2) exited $?"
wait "$p3"; echo "job3 (pid $p3) exited $?"
# Bare `wait` (no args) blocks until ALL children are done.
wait
echo "all background jobs finished"How do you react as each background job finishes?
Plain wait blocks until everything is done, so one slow job stalls all your result handling. Since Bash 4.3, wait -n returns as soon as any single child exits, letting you process results in completion order. Bash 5.1 added wait -n -p VAR, which stores the PID of the job that just finished in VAR so you can map the result back to its input.
Keep a set of outstanding PIDs, call wait -n -p done_pid in a loop, and look up which task that PID belonged to. This is the building block for the bounded-concurrency pattern in the next section.
#!/usr/bin/env bash
set -uo pipefail
declare -A name_of # pid -> human label
for n in alpha bravo charlie delta; do
( sleep $((RANDOM % 4 + 1)); echo "$n done" ) &
name_of[$!]="$n"
done
# React the moment ANY job finishes (Bash 5.1+: -p captures the PID).
remaining=${#name_of[@]}
while (( remaining > 0 )); do
wait -n -p finished_pid
status=$?
echo "-> ${name_of[$finished_pid]} (pid $finished_pid) exited $status"
(( remaining-- ))
doneHow do you limit how many processes run at once?
Launching one background job per input is fine for ten items, but firing 5,000 & jobs at once will thrash the box — too many context switches, exhausted memory, and an overloaded disk or network. The fix is bounded concurrency: keep at most N jobs in flight, and only start a new one when a slot frees up.
In pure Bash, track a running counter and block with wait -n whenever you hit the cap. A good default for N is the core count from nproc for CPU-bound work, or a higher fixed number for I/O-bound work like downloads.
#!/usr/bin/env bash
set -uo pipefail
max_jobs="$(nproc)" # cap = number of CPU cores
running=0
process() { sleep 1; echo "processed $1"; } # stand-in for real work
for item in $(seq 1 50); do
process "$item" &
(( running++ ))
if (( running >= max_jobs )); then
wait -n # free a slot as soon as one job finishes
(( running-- ))
fi
done
wait # let the final batch drain
echo "done"How do you fan out work with xargs -P?
xargs reads items from standard input and runs a command for each; the -P N flag runs up to N of those commands in parallel. Always pair it with -n 1 (one item per command) or -L 1 (one line per command) so each invocation gets a single unit of work, and use -I {} to place the item anywhere in the command.
xargs ships with GNU findutils on virtually every Linux box, so xargs -P is the zero-install way to parallelize a loop. Use -0 with find -print0 to handle filenames containing spaces or newlines safely. If any command exits non-zero, xargs finishes the rest and then exits with status 123.
# Resize 200 images, 8 at a time. -P 8 = 8 parallel jobs, -n 1 = one file each.
ls *.jpg | xargs -P 8 -n 1 -I {} convert {} -resize 1080x {}.thumb.jpg
# NUL-safe fan-out over files (handles spaces/newlines in names).
find . -name '*.log' -print0 | xargs -0 -P "$(nproc)" -n 1 gzip
# Use $(nproc) to size the pool to the machine.
seq 1 100 | xargs -P "$(nproc)" -n 1 ./worker.shWhat is GNU parallel and when should you use it?
GNU parallel is a dedicated tool for running jobs concurrently from a list. It covers everything xargs -P does and adds the features you reach for once a pipeline grows up: per-job logs, automatic retries, fail-fast control, progress output, input from multiple lists, and even dispatching jobs to remote machines over SSH. You usually install it with sudo apt install parallel or sudo dnf install parallel.
The mental model: feed inputs after ::: (or pipe them in), reference each input with {}, and size the pool with --jobs (-j). Useful extras include {.} (input without extension), {/} (basename), --joblog run.log (a TSV record of every job's exit code and runtime), --halt now,fail=1 (stop on the first failure), and --retries 3. Its exit status is the number of jobs that failed.
# Run 4 jobs at a time over an inline list; {} is each input.
parallel --jobs 4 'echo start {}; sleep 1; echo done {}' ::: a b c d e f
# Convert files, log every job's exit code + runtime, retry failures, fail fast.
parallel --jobs "$(nproc)" --joblog run.log --retries 3 --halt now,fail=1 \
ffmpeg -i {} -c:v libx264 {.}.mp4 ::: *.mov
# Pipe input in and use one core per job (--jobs 100% = one per CPU thread).
find . -name '*.csv' | parallel --jobs 100% ./ingest.py {}
# Combine two input lists (one job per pair: 1.txt-en, 1.txt-fr, 2.txt-en, ...).
parallel ./translate.sh {1} {2} ::: 1.txt 2.txt ::: en frxargs -P vs GNU parallel vs &+wait vs a task queue
All four run work concurrently; they differ in how much control, observability, and resilience they give you. Use the lightest tool that meets the job's needs.
| Approach | Concurrency control | CPU pinning / sizing | Error handling | Best for |
|---|---|---|---|---|
& + wait |
Manual — track a counter and wait -n |
None built in; wrap with taskset / nice |
Roll your own: save $!, wait PID, read $? |
A handful of known jobs inside one script |
xargs -P |
-P N flag; size with $(nproc) |
None built in; wrap each with taskset |
Runs the rest, exits 123 if any failed |
Zero-install fan-out over a list or find output |
GNU parallel |
-j N / -j 100%; per-CPU or load-based |
-j per core; --memfree; taskset |
--halt, --retries, --joblog; exit = failures |
Complex fan-out: retries, logs, remote SSH nodes |
| Task queue (Celery) | Worker --concurrency, autoscaling |
Per worker and per host across machines | Built-in retries, dead-letter, result backend | Long-running, scheduled, or web-triggered async work |
Rule of thumb: a few jobs in a script → & + wait; a list to chew through on one box → xargs -P, upgrading to GNU parallel when you need retries or logs; work that must survive restarts, run on a schedule, or scale across servers → a task queue. See our guide to running Celery workers under Supervisor for that last step.
How does Linux actually run processes? fork, exec, wait
Every parallel tool above is built on the same kernel primitives, and knowing them helps you debug stuck or leaked processes.
- fork — a process clones itself, creating a child that is a near-identical copy.
&in the shell triggers a fork. - exec — the child replaces its own program image with a new one. The shell forks, then the child
execs your command. Runningexec cmdwithout a fork replaces the current shell, which is how you avoid an extra wrapper process (handy for keeping a command as the direct child of its parent). - wait — the parent collects a finished child's exit status, releasing the kernel's record of it.
Zombies and reaping. When a child exits, the kernel keeps a tiny record (its exit status) until the parent calls wait. A child whose status has not been reaped is a zombie — it holds no memory, just a PID slot. A parent that forks children but never waits leaks zombies. The fix is to always wait, or to handle the SIGCHLD signal the kernel sends the parent each time a child exits and reap there.
Orphans. If the parent dies first, the children are re-parented to init/systemd (PID 1), which reaps them automatically — so orphans are not zombies. Because Linux recycles PIDs, never store a child's PID for a long time and kill it later: that number may now belong to an unrelated process. Send signals from the direct parent only.
Process groups. A pipeline or a ( … ) subshell forms a process group; signaling the whole group (kill -- -PGID, or Supervisor's stopasgroup/killasgroup) stops the parent and all its children together. This is exactly why prefork workers need group signaling to shut down cleanly.
#!/usr/bin/env bash
# Reap children as they exit via SIGCHLD instead of a blocking wait.
set -uo pipefail
trap 'reap' CHLD
reap() { while wait -n 2>/dev/null; do :; done; } # drain finished children
for i in 1 2 3; do
( sleep "$i"; echo "child $i ($$) finished" ) &
done
# Forward Ctrl-C / TERM to the whole process group so nothing is orphaned.
trap 'kill -- -$$' INT TERM
wait
echo "parent reaped all children — no zombies left"When should you move up from shell to a real concurrency layer?
Shell parallelism is perfect for glue work, batch jobs, and CI steps. Move up a layer when you need structured results, shared state, error propagation, retries, scheduling, or work that runs longer than a single script.
Python concurrent.futures.ProcessPoolExecutor is the natural next step for CPU-bound work in code. It spawns a pool of worker processes (sidestepping the GIL), maps inputs to them, and hands back results and exceptions as Future objects — no PID bookkeeping. Use ProcessPoolExecutor for CPU-bound tasks and ThreadPoolExecutor for I/O-bound ones. For lower-level control, the multiprocessing module gives you Pool, queues, and shared memory. If you write a lot of this, our Python development services team builds and tunes these pipelines in production.
Job runners and process managers handle the long-running and scheduled end of the spectrum. A task queue like Celery distributes work across worker processes and machines with retries, scheduling, and a result backend. To keep any of these workers alive across crashes and reboots, supervise them with Supervisor or systemd, as in our walkthrough of daemonizing any command with Supervisor. For tuning the hosts these run on, our server maintenance team handles the operational side.
# Python 3.13 — fan out CPU-bound work across processes (sidesteps the GIL).
from concurrent.futures import ProcessPoolExecutor, as_completed
import os
def work(n):
total = sum(i * i for i in range(n)) # stand-in for heavy CPU work
return n, total
if __name__ == "__main__": # required so children can import the module
inputs = [200_000, 500_000, 1_000_000, 750_000]
with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool:
futures = {pool.submit(work, n): n for n in inputs}
for fut in as_completed(futures): # results in completion order
try:
n, total = fut.result() # re-raises any worker exception here
print(f"n={n} -> {total}")
except Exception as exc:
print(f"input {futures[fut]} failed: {exc}")Frequently Asked Questions
What is the difference between xargs -P and GNU parallel?
Both run a command concurrently over a list of inputs, but GNU parallel does more. xargs -P N is preinstalled almost everywhere and is ideal for simple fan-out where you just want N copies running at once. GNU parallel adds per-job logging (--joblog), automatic retries (--retries), fail-fast control (--halt), richer placeholders ({}, {.}, {/}), input from multiple lists, and the ability to dispatch jobs to remote machines over SSH. Use xargs -P for quick parallelism with zero install; reach for GNU parallel when you need retries, logs, or multi-host fan-out.
How do I limit the number of parallel processes in a bash script?
Keep a counter of running jobs and block with wait -n whenever you hit your cap, decrementing the counter each time a slot frees up. Set the cap to $(nproc) for CPU-bound work or a larger fixed number for I/O-bound work like downloads. With tools, the cap is a single flag: xargs -P N or GNU parallel's --jobs N (-j 100% means one job per CPU thread). Avoid launching thousands of unbounded & jobs — that thrashes CPU, memory, and disk.
What is a zombie process and how do I avoid it?
A zombie is a child process that has exited but whose exit status the parent has not yet collected. It uses no memory or CPU — only a PID-table slot — but leaking many of them can exhaust available PIDs. You avoid zombies by always calling wait on your children, or by handling the SIGCHLD signal and reaping there. If the parent dies first, the children are re-parented to PID 1 (init/systemd), which reaps them automatically, so orphaned children never become zombies.
Does parallel processing in the shell use multiple CPU cores?
Yes — separate processes can run on separate cores, and the Linux scheduler spreads them across available CPUs automatically. The catch is that you only get a speedup when the work is genuinely CPU-bound and you run enough jobs to keep the cores busy; I/O-bound work (network, disk) is limited by the device, not the CPU. Size your pool to $(nproc) for CPU work, and use taskset to pin jobs to specific cores or nice to lower their priority when you need to.
When should I use Python multiprocessing instead of shell parallelism?
Use Python multiprocessing or concurrent.futures.ProcessPoolExecutor when you need structured results, shared state, exception propagation, or logic too complex for shell quoting. A ProcessPoolExecutor gives you real worker processes (sidestepping the GIL for CPU-bound work) and returns results and exceptions as Future objects, with no PID bookkeeping. Stay in the shell for gluing existing commands together; move to Python when the work is the code and you want type safety, tests, and clean error handling.
How do I stop all background jobs if one fails?
In a script, trap the failure and signal the whole process group with kill -- -$$, which sends the signal to the parent and every child it started. GNU parallel does this for you with --halt now,fail=1, which stops launching new jobs and terminates running ones as soon as one fails. For supervised long-running workers, configure stopasgroup=true and killasgroup=true so the process manager signals the entire group instead of orphaning child processes.