StackPractices
intermediate By Mathias Paulenko

Run Shell Commands in Parallel with Bash

Execute multiple shell commands concurrently using xargs, GNU parallel, and background jobs.

Topics: devops

Overview

Running commands sequentially is slow when tasks are independent. Bash has three built-in approaches for parallelism: background jobs with &, xargs -P, and GNU parallel. The following demonstrates how to all three with practical examples for batch file processing, image conversion, and API calls.

When to Use

  • You need to process hundreds of files (resize, convert, compress)
  • You are making API calls to multiple endpoints
  • You want to speed up batch operations that are I/O bound
  • You need to run independent shell commands concurrently

Solution

Background jobs with wait

#!/bin/bash

process_file() {
    local file="$1"
    gzip "$file"
    echo "Done: $file"
}

for file in *.log; do
    process_file "$file" &
done

wait
echo "All files processed"

Limit concurrency with xargs

# Compress 4 files at a time
find . -name "*.log" -print0 | xargs -0 -P4 -I{} gzip {}

# Resize images 8 at a time
ls *.jpg | xargs -P8 -I{} convert {} -resize 50% small_{}

GNU parallel

# Install if needed
# apt install parallel

# Process files in parallel with progress bar
ls *.jpg | parallel -j 8 convert {} -resize 50% small_{}

# Process with progress and ETA
ls *.jpg | parallel --progress -j 8 convert {} -resize 50% small_{}

# Keep output ordered
ls *.txt | parallel -k grep "error" {}

Parallel with custom function

#!/bin/bash

process_url() {
    local url="$1"
    local output=$(curl -s -o /dev/null -w "%{http_code}" "$url")
    echo "$url: $output"
}

export -f process_url

urls=(
    "https://api.example.com/users"
    "https://api.example.com/posts"
    "https://api.example.com/comments"
)

printf '%s\n' "${urls[@]}" | parallel -j 4 process_url {}

Collect results from parallel jobs

#!/bin/bash

# Run commands in background and collect exit codes
declare -a pids
declare -a results

for i in 1 2 3 4 5; do
    (sleep $((RANDOM % 5)); echo "Task $i done") &
    pids+=($!)
done

for pid in "${pids[@]}"; do
    wait "$pid"
    results+=("PID $pid exited with $?")
done

for result in "${results[@]}"; do
    echo "$result"
done

Parallel file downloads

#!/bin/bash

urls_file="downloads.txt"
# downloads.txt contains one URL per line

cat "$urls_file" | xargs -P4 -I{} wget -q {}

# Or with GNU parallel
cat "$urls_file" | parallel -j 4 wget -q {}

Parallel with output to separate files

# Each command writes to its own log file
ls *.txt | parallel 'grep "error" {} > {.}.errors'

# {.} removes the extension, so file.txt becomes file.errors

Explanation

Background jobs (&) send a process to the background and return immediately. wait blocks until all background jobs finish. This is the simplest approach but has no built-in concurrency limit. If you start 1000 jobs, you get 1000 simultaneous processes.

xargs -P N runs up to N commands in parallel. It reads items from stdin and passes each to the command. -0 handles filenames with spaces. -I{} lets you position the argument precisely.

GNU parallel is the most capable tool. It supports:

  • -j N: Limit concurrent jobs to N.
  • -k: Keep output in order of input (not completion order).
  • --progress: Show a progress bar.
  • {}: Placeholder for the input item.
  • {.}: Input item without extension.
  • --eta: Show estimated time of completion.

Variants

ApproachConcurrency ControlOrderingUse When
& + waitNoneNoneFew jobs, simple scripts
xargs -PFixed (-P N)NoneBatch file processing
GNU parallelFixed (-j N)Optional (-k)Complex parallel workflows
coprocSingleNoneBidirectional communication

Guidelines

  • Limit concurrency to the number of CPU cores for CPU-bound tasks. Use -j $(nproc).
  • For I/O-bound tasks (downloads, API calls), higher concurrency (8-16) is fine.
  • Use xargs -0 or parallel to handle filenames with spaces correctly.
  • Export functions with export -f before using them in xargs or parallel.
  • Use --dry-run with parallel to preview commands before running them.

Common Mistakes

  • Starting too many background jobs without a limit. This can exhaust memory or file descriptors.
  • Not using wait after background jobs. The script exits before jobs finish.
  • Forgetting -0 with xargs when filenames contain spaces. Files get split on spaces.
  • Not exporting functions when using them with parallel. The function is not available in subshells.
  • Mixing output from parallel jobs without -k. Output interleaves and becomes unreadable.

Performance Tips

  1. Benchmark different concurrency levels. The optimal -j value depends on your workload:
# Test with different concurrency
for j in 1 2 4 8 16; do
    time find . -name "*.log" | parallel -j $j gzip {}
done
  1. Use --round-robin for uneven workloads. Distributes work more evenly when jobs vary in size:
# Group files by size, then distribute
find . -name "*.log" -exec du -b {} + | sort -n | parallel --round-robin -j 4 gzip {2}
  1. Pin jobs to CPU cores with taskset. For CPU-bound work, avoid context switching:
# Assign each parallel job to a specific core
parallel -j $(nproc) 'taskset -c %{} gzip {}' ::: *.log

Frequently Asked Questions

How do I limit parallelism to the number of CPU cores?
find . -name "*.jpg" | parallel -j $(nproc) convert {} -resize 50% small_{}
How do I retry failed commands with GNU parallel?

Use --retries N:

cat urls.txt | parallel --retries 3 wget -q {}
Can I use parallel with SSH?

Yes. GNU parallel can run commands on remote machines:

parallel --sshlogin server1,server2 -j 2 --transfer --return {}.out --cleanup "process.sh {}" ::: file1 file2
How do I show a progress bar with xargs?

xargs does not have a built-in progress bar. Use parallel --progress instead, or pipe through pv:

cat urls.txt | pv -l | xargs -P4 -I{} wget -q {}
Semaphore Pattern for Controlled Concurrency
#!/bin/bash
# semaphore.sh — limit background jobs with a semaphore

MAX_JOBS=4
open_semaphores() {
    for i in $(seq 1 $MAX_JOBS); do
        echo
    done
}

run_with_semaphore() {
    read -r line <&3
    (
        "$@"
    ) 3>&1
}

# Open semaphore
exec 3< <(open_semaphores)

for file in *.log; do
    run_with_semaphore process_file "$file" &
done

wait
echo "All done with max $MAX_JOBS concurrent jobs"
Error Handling in Parallel Jobs
#!/bin/bash
# parallel-with-errors.sh

process_with_error() {
    local file="$1"
    if gzip "$file" 2>/dev/null; then
        echo "OK: $file"
    else
        echo "FAIL: $file" >&2
        return 1
    fi
}

export -f process_with_error

# Capture exit codes
find . -name "*.log" | parallel -j 4 process_with_error {}
EXIT_CODES=$?

if [ $EXIT_CODES -ne 0 ]; then
    echo "Some jobs failed. Exit code: $EXIT_CODES"
    exit 1
fi
Timeout per Job
#!/bin/bash
# timeout-parallel.sh

# Each job has a 30-second timeout
cat urls.txt | parallel -j 8 --timeout 30 wget -q {}

# With GNU timeout command for custom functions
process_with_timeout() {
    local url="$1"
    timeout 30 curl -s -o /dev/null -w "%{http_code}" "$url" || echo "TIMEOUT: $url"
}

export -f process_with_timeout
cat urls.txt | parallel -j 8 process_with_timeout {}
Parallel with Logging
#!/bin/bash
# parallel-logging.sh

LOGDIR="./logs"
mkdir -p "$LOGDIR"

process_and_log() {
    local file="$1"
    local logfile="$LOGDIR/$(basename "$file").log"
    {
        echo "Start: $(date -Iseconds)"
        gzip "$file"
        echo "End: $(date -Iseconds) exit=$?"
    } > "$logfile" 2>&1
}

export -f process_and_log

find . -name "*.log" -not -path "./logs/*" | parallel -j 4 process_and_log {}