StackPractices
intermediate Por Mathias Paulenko

Ejecución Paralela en Bash

Cómo ejecutar comandos de shell en paralelo con xargs, GNU parallel y trabajos en segundo plano de Bash controlando la concurrencia y recolectando resultados. Incluye manejo de exit codes y un semáforo para APIs con límite de tasa.

Descripción General

Las máquinas modernas tienen varios núcleos de CPU, pero la mayoría de los scripts de shell corren un comando por vez. Repartir la misma tarea entre varios workers (fan-out) y después recolectar resultados (fan-in) puede recortar muchísimo el tiempo de un batch, y Bash te da tres formas de hacerlo sin salir de la terminal: xargs -P, GNU parallel y trabajos en segundo plano con wait. Esta receta cubre las tres, más cómo limitar la concurrencia y detectar fallos. Para control de jobs de nivel producción (joblogs, resume, reintentos con backoff), mirá Control de Jobs Paralelos.

Cuándo Usar

  • Procesar miles de archivos con una herramienta intensiva en CPU (conversión de imágenes, compresión)
  • Correr tests en varios directorios o configuraciones
  • Llamadas a APIs en bulk donde el servicio remoto tolera concurrencia
  • Descargar o codificar muchos archivos a la vez

Salteate el paralelismo cuando el cuello de botella es un solo disco, cuando la API remota te limita la tasa, cuando las tareas dependen de la salida de otras (usá una herramienta DAG como Make), o cuando necesitás los resultados en el orden de entrada; los workers terminan cuando terminan.

Solución

xargs (POSIX, sin dependencias extra)

xargs -P es la opción de mínimo denominador: está en todos lados, incluidas imágenes Docker mínimas.

#!/bin/bash
set -euo pipefail

# Procesar 4 archivos a la vez; -print0/-0 maneja espacios en nombres
find images/ -name '*.png' -print0 | \
    xargs -0 -n 1 -P 4 convert '{}' '{}.jpg'

# Igualar la concurrencia a la cantidad de CPUs
find images/ -name '*.png' -print0 | \
    xargs -0 -n 1 -P "$(nproc)" convert '{}' '{}.jpg'

# Un comando por archivo (-I ya implica una entrada por llamada); sale con 123 si algún hijo falla
find data/ -name '*.json' -print0 | \
    xargs -0 -P 4 -I {} sh -c 'jq empty "$1" || echo "FAIL: $1"' _ {}

# Repartir a varios hosts
printf '%s\n' host1 host2 host3 | \
    xargs -n 1 -P 3 -I {} rsync -avz ./deploy/ {}:/var/app/

GNU Parallel (el más capaz)

GNU parallel suma barras de progreso, agrupación de salida, resultados taggeados y ejecución remota:

#!/bin/bash
set -euo pipefail

# Ejecución básica con barra de progreso; {.} quita la extensión
find images/ -name '*.png' | parallel --bar convert '{}' '{.}.jpg'

# Limitar concurrencia, preservar el orden de entrada en la salida
find logs/ -name '*.log' | parallel -j 8 --keep-order gzip '{}'

# Un archivo de resultado por job bajo ./logs/ en vez de stdout mezclado
find data/ -name '*.csv' | \
    parallel -j 4 --results 'logs/{=s:.*/::;s:\.csv::=}' \
        awk -F, '{print $2}' {}

# SSH paralelo a una flota, taggeado por host
parallel -j 10 --tag ssh {} uptime ::: server1 server2 server3

Para joblogs y reanudar corridas fallidas (--joblog, --resume-failed), mirá la receta de job control.

Trabajos en segundo plano de Bash (cero dependencias)

#!/bin/bash
set -uo pipefail   # sin -e: de todos modos no atrapa fallos en segundo plano

MAX_JOBS=4
pids=()

for file in *.mp4; do
    [ -e "$file" ] || continue
    # Bash 4.3+: wait -n bloquea hasta que termine algún job — sin polling con sleep
    while [ "$(jobs -rp | wc -l)" -ge "$MAX_JOBS" ]; do
        wait -n 2>/dev/null || sleep 0.1
    done
    ffmpeg -i "$file" "${file%.mp4}.webm" &
    pids+=($!)
done

# Recolectar el exit code de cada job — esperalos por PID, no con `wait` a secas
failed=0
for pid in "${pids[@]}"; do
    if ! wait "$pid"; then
        echo "Job $pid falló" >&2
        ((failed++))
    fi
done
[ "$failed" -eq 0 ] || exit 1

Dos detalles importan acá: pids+=($!) registra cada job al lanzarlo, y wait "$pid" (según los builtins de job control de Bash) devuelve el exit code real de ese job. Un wait a secas primero cosecharía todo y dejaría jobs -p vacío. Tu chequeo de fallos no vería nada.

Patrón semáforo para APIs con límite de tasa

GNU parallel trae sem, un semáforo contador que limita las llamadas concurrentes:

#!/bin/bash
set -euo pipefail

API_LIMIT=10  # máximo de llamadas concurrentes

while read -r id; do
    sem --id api_calls -j "$API_LIMIT" \
        curl -sf "https://api.example.com/items/$id" -o "results/$id.json" &
done < ids.txt

wait
sem --id api_calls --wait  # drenar los que queden

Flags útiles para trabajo frágil o desigual

#!/bin/bash
# Reintenta cada job fallido hasta 3 veces
parallel -j 4 --retries 3 wget -q :::: urls.txt

# Mata cualquier job que supere los 30s
parallel -j 8 --timeout 30 curl -s :::: urls.txt

# Reparte el trabajo cuando los tamaños de entrada varían mucho
find . -name '*.log' -exec du -b {} + | sort -n | \
    parallel --round-robin -j 4 gzip {2}

# Progreso con xargs (que no tiene) vía pv
xargs -P4 -I{} gzip {} < filelist.txt   # a ciegas
pv -l < filelist.txt | xargs -P4 -I{} gzip {}

# Ancla cada job a un núcleo para trabajo CPU-bound
parallel -j "$(nproc)" 'taskset -c %{} gzip {}' ::: *.log

Para funciones shell, envuelve la llamada en timeout 30 en vez de depender de --timeout — también funciona con xargs y background jobs.

Explicación

Todos los enfoques siguen la misma forma:

Patrón fan-out/fan-in para ejecución paralela en Bash. Un dispatcher lee la lista de entrada y lanza hasta N workers concurrentes — vía xargs -P, GNU parallel o trabajos en segundo plano. Cada worker escribe su propia salida o archivo de resultado, después el padre espera a todos los workers y agrega los códigos de salida para decidir éxito o fallo.

Las diferencias están en la plomería, no en la forma. xargs es un fan-out simple: sin progreso, sin orden, con el exit code 123 como única señal de fallo. GNU parallel es la caja de herramientas completa: orden, agrupación, salida taggeada, hosts remotos. jobs/wait crudo es para cuando ninguna de las dos herramientas está instalada o necesitás lógica propia entre lanzamientos, como el semáforo de arriba.

Elección entre xargs, GNU parallel y trabajos en segundo plano de Bash. Si GNU parallel está instalado, usarlo — cubre progreso, orden y archivos de resultado. Si solo hay herramientas POSIX, usar xargs -P. Si hace falta lógica propia de lanzamiento como semáforos o límites dinámicos, usar trabajos en segundo plano con wait.

Lo que Funciona

  • Configurá -P/-j explícitamente. xargs por defecto es secuencial; GNU parallel por defecto usa los núcleos, lo que igual puede saturar trabajo intensivo en I/O.
  • Usá -print0 | xargs -0. Los nombres de archivo con espacios o saltos de línea rompen el split por espacios.
  • Preferí wait -n antes que polling con sleep para manejar slots; despierta apenas termina un job en vez de cada 100ms.
  • Redirigí la salida de cada job a archivos. Un stdout entrelazado es indebugeable; --results (parallel) o un redirect por job mantiene cada log intacto.
  • Usá --halt soon,fail=1 para fail-fast. Si una entrada mala debería frenar el batch, no dejes que los otros 500 jobs quemen CPU antes: find tests/ -name '*.sh' | parallel -j 8 --halt soon,fail=1 bash {}.
  • Atrapá SIGINT y matá los hijos. Ctrl+C sobre el padre deja los jobs en segundo plano corriendo; trap 'jobs -p | xargs -r kill' SIGINT SIGTERM limpia.

Errores Comunes

  1. Asumir que set -e atrapa fallos en segundo plano. No lo hace: un job con & que falla no hace salir al script a menos que esperes su PID y lo verifiques. Guardá pids+=($!) y verificá cada uno.
  2. wait y después jobs -p. Un wait a secas cosecha todos los jobs, así que jobs -p después no devuelve nada y tu loop de exit codes no hace nada en silencio. Guardá los PIDs al lanzar.
  3. Salida entrelazada. Escrituras concurrentes a stdout producen líneas mezcladas; agrupalas con --group/--keep-order o redirigí por job.
  4. Subshells que no comparten estado. ((counter++)) & actualiza una copia; el contador del padre queda en 0. Usá archivos de resultado, flock sobre un archivo compartido o el manejo de resultados de GNU parallel.
  5. Bugs de quoting en xargs -I {}. {} dentro de comillas simples no expande donde esperás; pasalo como argumento posicional (sh -c '... "$1"' _ {}) para que ningún nombre de archivo inyecte shell.
  6. Ignorar el exit 123 de xargs. Significa “al menos un hijo falló”; verificá $? o los fallos pasan en silencio.

Solución de Problemas

  • xargs sale con 123 sin mostrar el error. Uno o más hijos fallaron; su stderr probablemente se mezcló con stdout. Recorré con logs por job para ver qué entrada rompió.
  • GNU parallel imprime un aviso de citación en CI. Es un aviso de primer uso, no un error. Corré parallel --citation una vez interactivo, o pasá --will-cite donde lo llames.
  • Los jobs siguen corriendo después de Ctrl+C. El padre murió pero los hijos no. Agregá el trap de limpieza de Lo que Funciona, o corré bajo timeout.
  • Todo corre secuencial igual. Verificá que -P/-j esté configurado de verdad (xargs por defecto es 1), y que la entrada no sea una sola línea: xargs agrupa ítems separados por espacios, así que cat file | xargs -P4 con una línea se la da entera a un solo job.
  • La máquina se ahoga con jobs paralelos. Tu límite está por encima de lo que la RAM o el I/O aguantan. Bajá -j por debajo de los núcleos para trabajo de I/O, o usá --memfree (parallel) para frenar jobs bajo presión de memoria.

Preguntas frecuentes

¿Cómo elijo entre xargs, GNU parallel y trabajos en segundo plano?

xargs -P cuando querés cero dependencias y un fan-out simple. GNU parallel cuando necesitás progreso, orden, resultados por job u hosts remotos. jobs/wait crudo cuando ninguna está disponible o necesitás lógica propia de lanzamiento, como un semáforo o concurrencia dinámica.

¿Qué exit code devuelve xargs cuando fallan jobs?

Exit 123 significa que al menos un hijo devolvió non-zero. xargs sigue corriendo el resto de las entradas; no frena en el primer fallo a menos que se lo señales. Verificá $? después del pipeline o recolectá resultados por job.

¿Puedo limitar GNU parallel por memoria en vez de cantidad de jobs?

Sí: --memfree 2G frena los jobs nuevos mientras la memoria libre cae por debajo del umbral. Combinálo con -j para un tope duro, como parallel -j 16 --memfree 2G.

¿Cómo conservo los resultados en el mismo orden que la entrada?

El --keep-order (o -k) de GNU parallel buffera la salida de cada job y la emite en el orden de entrada. xargs y los jobs en segundo plano crudos no pueden; los workers imprimen a medida que terminan.

¿Necesito GNU parallel para reintentos y resume?

No lo necesitás, pero su --joblog + --resume-failed es la forma más simple de recorrer solo los jobs que fallaron. Los mecanismos, más reintentos con backoff exponencial, están en Control de Jobs Paralelos.

¿Hay forma de correr esto en varias máquinas, no solo varios núcleos?

GNU parallel puede distribuir jobs por SSH con -S host1,host2 o --sshloginfile. Para algo más que un puñado de hosts, subí a una cola de trabajo real o a una herramienta como Ansible en vez de estirar Bash.

Código companion: El repo companion de bash-parallel-execution tiene los tres patrones como scripts ejecutables, incluido el bug de exit codes ya corregido y verificado.