StackPractices
intermediate Por Mathias Paulenko

Ejecución Paralela de Jobs con Bash

Ejecuta comandos y scripts de shell en paralelo de forma segura usando xargs, GNU parallel o jobs en segundo plano.

Visión General

Los servidores modernos tienen múltiples núcleos, pero un script de shell ingenuo ejecuta un comando a la vez. La ejecución paralela de jobs te permite procesar muchos archivos, URLs o tareas simultáneamente, reduciendo el tiempo de horas a minutos. Bash ofrece varias herramientas: jobs en background, xargs -P y GNU parallel. Cada opción equilibra control, portabilidad y facilidad de uso.

Cuándo Usar

Usa este recurso cuando:

  • Necesites procesar muchos archivos o registros en un lote.
  • Un loop secuencial sea demasiado lento para tu workflow.
  • Quieras controlar el número máximo de jobs concurrentes.
  • Necesites recolectar códigos de salida de cada proceso hijo.

Solución

Ejecución paralela de jobs en Bash

#!/usr/bin/env bash
set -euo pipefail

MAX_JOBS="${1:-4}"
INPUT_FILE="${2:-jobs.txt}"

# Opción 1: xargs con workers paralelos
process_task() {
    local task="$1"
    echo "Processing $task"
    sleep "$((RANDOM % 3 + 1))"
    echo "Done $task"
}
export -f process_task

cat "$INPUT_FILE" | xargs -P "$MAX_JOBS" -I {} bash -c 'process_task "{}"'

# Opción 2: GNU parallel
# parallel -j "$MAX_JOBS" process_task {} < "$INPUT_FILE"

# Opción 3: Background jobs con semáforo
SEMAPHORE=0
while IFS= read -r task; do
    if [[ $SEMAPHORE -ge $MAX_JOBS ]]; then
        wait -n
        SEMAPHORE=$((SEMAPHORE - 1))
    fi
    process_task "$task" &
    SEMAPHORE=$((SEMAPHORE + 1))
done < "$INPUT_FILE"
wait

Explicación

El script muestra tres enfoques comunes. xargs -P es portátil y está disponible en la mayoría de sistemas, pero menos flexible que GNU parallel. GNU parallel ofrece mejor manejo de salida, reanudación y progreso. El enfoque de background jobs usa wait -n para mantener un número máximo de jobs concurrentes sin herramientas externas. export -f hace visible la función de Bash a subprocesos cuando se usa xargs -I {} bash -c.

Variantes

EnfoqueHerramientaProsContras
xargscoreutilsPortátil, simpleControl limitado, salida desordenada
GNU parallelparallelPotente, reanudable, salida ordenadaDependencia extra
Background jobsbuiltin de bashSin dependencias externasBookkeeping manual, propenso a race conditions

Lo que funciona

  1. Limita la concurrencia a un límite probado. Demasiados jobs agotan CPU, memoria o descriptores de archivo.
  2. Haz que los jobs sean idempotentes. Un job reintentado debe producir el mismo resultado sin efectos secundarios.
  3. Captura y agrega códigos de salida. Un job fallido no debe ocultarse silenciosamente entre los exitosos.
  4. Usa un directorio temporal por job. Esto previene colisiones de archivos y facilita la limpieza.
  5. Registra con el identificador del job. Prefija la salida con el nombre de la tarea para poder rastrear fallos.

Errores Comunes

  1. Paralelismo sin límites. Lanzar cada tarea en background de inmediato puede colapsar el shell.
  2. Perder códigos de salida. xargs devuelve el último código por defecto; usa -P con -t o GNU parallel para rastrear cada job.
  3. Ignorar el quoting del shell. Los nombres de archivo con espacios rompen xargs a menos que uses -0 o -d.
  4. Escribir en el mismo archivo de salida. Las escrituras concurrentes entrelazan la salida; usa un archivo por job o bloquea el archivo.
  5. Sin timeout. Un job atascado puede bloquear todo el lote; agrega timeout a cada comando.

Mejores Prácticas Adicionales

  1. Usa nproc para auto-detectar núcleos de CPU. En lugar de hardcodear MAX_JOBS, derívalo del conteo de CPU del sistema. Para tareas I/O-bound, usa 2-4x el conteo de núcleos; para CPU-bound, usa 1x:
CPU_CORES=$(nproc)
IO_JOBS=$((CPU_CORES * 4))  # I/O-bound: 4x núcleos
CPU_JOBS=$CPU_CORES          # CPU-bound: 1x núcleos
  1. Usa --bar con GNU parallel para mostrar progreso. Para batches largos, una barra de progreso ayuda a estimar el tiempo de completitud:
parallel --bar --jobs "$MAX_JOBS" process_task {} < "$INPUT_FILE"
  1. Configura límites de descriptores de archivo para alta concurrencia. Cada job en background usa descriptores de archivo. Para más de 100 jobs concurrentes, aumenta el ulimit:
ulimit -n 4096  # Permitir 4096 descriptores de archivo abiertos

Preguntas frecuentes

GNU parallel con salida ordenada y progreso
#!/usr/bin/env bash
set -euo pipefail

MAX_JOBS="${1:-4}"
INPUT_FILE="${2:-jobs.txt}"
LOG_DIR="${3:-/tmp/parallel-logs}"

mkdir -p "$LOG_DIR"

# GNU parallel con salida ordenada, barra de progreso y archivos de log por job
parallel --jobs "$MAX_JOBS" \
    --progress \
    --joblog "$LOG_DIR/joblog.txt" \
    --results "$LOG_DIR" \
    --halt soon,fail=20% \
    --timeout 300 \
    --shuf \
    process_task {} \
    < "$INPUT_FILE"

# El joblog contiene: Seq Host Starttime JobRuntime Send Receive Exitval Signal Command
echo "=== Resumen de Jobs ==="
awk 'NR>1 {exitcodes[$7]++} END {for (code in exitcodes) printf "Exit %s: %d jobs\n", code, exitcodes[code]}' "$LOG_DIR/joblog.txt"

# Mostrar jobs fallidos
FAILED=$(awk 'NR>1 && $7!=0 {print $NF}' "$LOG_DIR/joblog.txt")
if [ -n "$FAILED" ]; then
    echo "=== Jobs Fallidos ==="
    echo "$FAILED"
fi
Colección de códigos de salida con xargs
#!/usr/bin/env bash
set -euo pipefail

MAX_JOBS="${1:-4}"
INPUT_FILE="${2:-jobs.txt}"
RESULTS_DIR=$(mktemp -d)

process_with_exit() {
    local task="$1"
    local result_file="$2"
    # Simular trabajo
    sleep "$((RANDOM % 3 + 1))"
    if (( RANDOM % 10 == 0 )); then
        echo "FAIL" > "$result_file"
        return 1
    fi
    echo "OK" > "$result_file"
    return 0
}
export -f process_with_exit

# Ejecutar en paralelo y recolectar códigos de salida
cat "$INPUT_FILE" | xargs -P "$MAX_JOBS" -I {} bash -c '
    process_with_exit "{}" "'"$RESULTS_DIR"'/{//}_result" || true
'

# Agregar resultados
TOTAL=$(wc -l < "$INPUT_FILE")
SUCCESS=$(grep -l "OK" "$RESULTS_DIR"/*_result 2>/dev/null | wc -l)
FAILED=$(grep -l "FAIL" "$RESULTS_DIR"/*_result 2>/dev/null | wc -l)

echo "Total: $TOTAL, Éxito: $SUCCESS, Fallidos: $FAILED"

# Limpieza
rm -rf "$RESULTS_DIR"
Control de jobs con timeouts y reintentos
#!/usr/bin/env bash
set -euo pipefail

MAX_JOBS="${1:-4}"
MAX_RETRIES="${2:-3}"
TIMEOUT="${3:-60}"
INPUT_FILE="${4:-jobs.txt}"

run_with_retry() {
    local task="$1"
    local attempt=1

    while (( attempt <= MAX_RETRIES )); do
        echo "[$(date -Iseconds)] Intento $attempt para tarea: $task"
        if timeout "$TIMEOUT" bash -c "process_task '$task'" 2>&1; then
            echo "[$(date -Iseconds)] ÉXITO: $task (intento $attempt)"
            return 0
        fi
        echo "[$(date -Iseconds)] REINTENTO: $task falló intento $attempt"
        attempt=$((attempt + 1))
        # Backoff exponencial: 1s, 2s, 4s, 8s...
        sleep "$((2 ** (attempt - 2)))"
    done

    echo "[$(date -Iseconds)] FALLÓ: $task después de $MAX_RETRIES intentos"
    return 1
}
export -f run_with_retry
export MAX_RETRIES TIMEOUT

# Ejecutar con GNU parallel para mejor control
parallel --jobs "$MAX_JOBS" \
    --retry-failed \
    --jobs "$MAX_JOBS" \
    run_with_retry {} \
    < "$INPUT_FILE"
Comparación con multiprocessing de Python
import multiprocessing
import subprocess
import time
from pathlib import Path

def run_task(task: str) -> tuple[str, int, float]:
    start = time.time()
    result = subprocess.run(
        ["bash", "-c", f"process_task '{task}'"],
        capture_output=True,
        text=True,
        timeout=300,
    )
    elapsed = time.time() - start
    return task, result.returncode, elapsed

def main():
    tasks = Path("jobs.txt").read_text().strip().split("\n")
    max_workers = min(4, multiprocessing.cpu_count())

    with multiprocessing.Pool(max_workers) as pool:
        results = pool.map(run_task, tasks)

    succeeded = sum(1 for _, code, _ in results if code == 0)
    failed = len(results) - succeeded
    total_time = sum(t for _, _, t in results)

    print(f"Total: {len(results)}, Éxito: {succeeded}, Fallidos: {failed}")
    print(f"Wall time: {total_time:.1f}s, Speedup paralelo: {total_time / max_workers:.1f}s ahorrados")

    for task, code, elapsed in results:
        status = "OK" if code == 0 else f"FAIL (exit {code})"
        print(f"  {task}: {status} ({elapsed:.1f}s)")

if __name__ == "__main__":
    main()
Procesamiento paralelo de archivos con find y xargs
#!/usr/bin/env bash
set -euo pipefail

MAX_JOBS="${1:-$(nproc)}"
TARGET_DIR="${2:-.}"

# Comprimir todos los archivos log en paralelo usando find + xargs -0
find "$TARGET_DIR" -type f -name "*.log" -print0 | \
    xargs -0 -P "$MAX_JOBS" -I {} gzip -9 {}

# Generar checksums para todos los archivos en paralelo
find "$TARGET_DIR" -type f -print0 | \
    xargs -0 -P "$MAX_JOBS" -I {} sh -c 'sha256sum "{}" > "{}.sha256"'

# Redimensionar imágenes en paralelo (requiere ImageMagick)
find "$TARGET_DIR" -type f \( -name "*.jpg" -o -name "*.png" \) -print0 | \
    xargs -0 -P "$MAX_JOBS" -I {} convert {} -resize 50% "{}.thumb"

# Subir archivos a S3 en paralelo
find "$TARGET_DIR" -type f -name "*.gz" -print0 | \
    xargs -0 -P "$MAX_JOBS" -I {} aws s3 cp {} "s3://my-bucket/uploads/" --no-progress