StackPractices
intermediate Por Mathias Paulenko

Ejecutar Comandos Shell en Paralelo con Bash

Ejecuta múltiples comandos shell concurrentemente usando xargs, GNU parallel y background jobs.

Temas: devops

Visión General

Ejecutar comandos secuencialmente es lento cuando las tareas son independientes. Bash tiene tres enfoques integrados para paralelismo: background jobs con &, xargs -P, y GNU parallel. Esta recipe cubre los tres con ejemplos prácticos para procesamiento en lote de archivos, conversión de imágenes y llamadas a APIs.

Cuándo Usar

  • Necesitas procesar cientos de archivos (redimensionar, convertir, comprimir)
  • Estás haciendo llamadas a múltiples endpoints de una API
  • Quieres acelerar operaciones batch que son I/O bound
  • Necesitas ejecutar comandos shell independientes concurrentemente

Solución

Background jobs con 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"

Limitar concurrencia con xargs

# Comprimir 4 archivos a la vez
find . -name "*.log" -print0 | xargs -0 -P4 -I{} gzip {}

# Redimensionar imágenes 8 a la vez
ls *.jpg | xargs -P8 -I{} convert {} -resize 50% small_{}

GNU parallel

# Instalar si es necesario
# apt install parallel

# Procesar archivos en paralelo con barra de progreso
ls *.jpg | parallel -j 8 convert {} -resize 50% small_{}

# Procesar con progreso y ETA
ls *.jpg | parallel --progress -j 8 convert {} -resize 50% small_{}

# Mantener output ordenado
ls *.txt | parallel -k grep "error" {}

Paralelo con función custom

#!/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 {}

Recolectar resultados de jobs paralelos

#!/bin/bash

# Ejecutar comandos en background y recolectar 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

Descargas de archivos en paralelo

#!/bin/bash

urls_file="downloads.txt"
# downloads.txt contiene un URL por línea

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

# O con GNU parallel
cat "$urls_file" | parallel -j 4 wget -q {}

Paralelo con output a archivos separados

# Cada comando escribe a su propio archivo de log
ls *.txt | parallel 'grep "error" {} > {.}.errors'

# {.} quita la extensión, entonces file.txt se convierte en file.errors

Explicación

Background jobs (&) envían un proceso al background y retornan inmediatamente. wait bloquea hasta que todos los background jobs terminan. Este es el enfoque más simple pero no tiene límite de concurrencia integrado. Si lanzas 1000 jobs, obtienes 1000 procesos simultáneos.

xargs -P N ejecuta hasta N comandos en paralelo. Lee items de stdin y los pasa al comando. -0 maneja nombres de archivo con espacios. -I{} te permite posicionar el argumento con precisión.

GNU parallel es la herramienta más capaz. Soporta:

  • -j N: Limitar jobs concurrentes a N.
  • -k: Mantener output en orden de input (no de completion).
  • --progress: Mostrar barra de progreso.
  • {}: Placeholder para el item de input.
  • {.}: Item de input sin extensión.
  • --eta: Mostrar tiempo estimado de completion.

Variantes

EnfoqueControl de ConcurrenciaOrdenUsar Cuando
& + waitNingunoNingunoPocos jobs, scripts simples
xargs -PFijo (-P N)NingunoProcesamiento batch de archivos
GNU parallelFijo (-j N)Opcional (-k)Workflows paralelos complejos
coprocÚnicoNingunoComunicación bidireccional

Pautas

  • Limita la concurrencia al número de cores de CPU para tareas CPU-bound. Usa -j $(nproc).
  • Para tareas I/O-bound (descargas, llamadas a API), concurrencia más alta (8-16) está bien.
  • Usa xargs -0 o parallel para manejar nombres de archivo con espacios correctamente.
  • Exporta funciones con export -f antes de usarlas en xargs o parallel.
  • Usa --dry-run con parallel para previsualizar comandos antes de ejecutarlos.

Errores Comunes

  • Lanzar demasiados background jobs sin límite. Esto puede agotar memoria o file descriptors.
  • No usar wait después de background jobs. El script sale antes de que los jobs terminen.
  • Olvidar -0 con xargs cuando los nombres de archivo contienen espacios. Los archivos se splitean en espacios.
  • No exportar funciones al usarlas con parallel. La función no está disponible en subshells.
  • Mezclar output de jobs paralelos sin -k. El output se intercala y se vuelve ilegible.

Tips de Rendimiento

  1. Benchmark diferentes niveles de concurrencia. El valor óptimo de -j depende de tu workload:
# Testear con diferentes concurrencias
for j in 1 2 4 8 16; do
    time find . -name "*.log" | parallel -j $j gzip {}
done
  1. Usa --round-robin para workloads desiguales. Distribuye el trabajo más uniformemente cuando los jobs varían en tamaño:
# Agrupar archivos por tamaño, luego distribuir
find . -name "*.log" -exec du -b {} + | sort -n | parallel --round-robin -j 4 gzip {2}
  1. Fija jobs a cores de CPU con taskset. Para trabajo CPU-bound, evita context switching:
# Asignar cada job paralelo a un core específico
parallel -j $(nproc) 'taskset -c %{} gzip {}' ::: *.log

Preguntas frecuentes

¿Cómo limito el paralelismo al número de cores de CPU?
find . -name "*.jpg" | parallel -j $(nproc) convert {} -resize 50% small_{}
¿Cómo reintentar comandos fallidos con GNU parallel?

Usa --retries N:

cat urls.txt | parallel --retries 3 wget -q {}
¿Puedo usar parallel con SSH?

Sí. GNU parallel puede ejecutar comandos en máquinas remotas:

parallel --sshlogin server1,server2 -j 2 --transfer --return {}.out --cleanup "process.sh {}" ::: file1 file2
¿Cómo muestro una barra de progreso con xargs?

xargs no tiene barra de progreso integrada. Usa parallel --progress en su lugar, o pipea a través de pv:

cat urls.txt | pv -l | xargs -P4 -I{} wget -q {}
Patrón Semáforo para Concurrencia Controlada
#!/bin/bash
# semaphore.sh — limitar background jobs con un semáforo

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

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

# Abrir semáforo
exec 3< <(open_semaphores)

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

wait
echo "Todos listos con max $MAX_JOBS jobs concurrentes"
Manejo de Errores en Jobs Paralelos
#!/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

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

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

# Cada job tiene un timeout de 30 segundos
cat urls.txt | parallel -j 8 --timeout 30 wget -q {}

# Con comando timeout de GNU para funciones custom
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 {}
Paralelo con 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 {}