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:
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.
Lo que Funciona
- Configurá
-P/-jexplícitamente.xargspor 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 -nantes que polling consleeppara 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=1para 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á
SIGINTy matá los hijos. Ctrl+C sobre el padre deja los jobs en segundo plano corriendo;trap 'jobs -p | xargs -r kill' SIGINT SIGTERMlimpia.
Errores Comunes
- Asumir que
set -eatrapa 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. waity despuésjobs -p. Unwaita secas cosecha todos los jobs, así quejobs -pdespués no devuelve nada y tu loop de exit codes no hace nada en silencio. Guardá los PIDs al lanzar.- Salida entrelazada. Escrituras concurrentes a stdout producen líneas mezcladas; agrupalas con
--group/--keep-ordero redirigí por job. - Subshells que no comparten estado.
((counter++)) &actualiza una copia; el contador del padre queda en 0. Usá archivos de resultado,flocksobre un archivo compartido o el manejo de resultados de GNU parallel. - 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. - Ignorar el exit 123 de xargs. Significa “al menos un hijo falló”; verificá
$?o los fallos pasan en silencio.
Solución de Problemas
xargssale 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 --citationuna vez interactivo, o pasá--will-citedonde lo llames. - Los jobs siguen corriendo después de Ctrl+C. El padre murió pero los hijos no. Agregá el
trapde limpieza de Lo que Funciona, o corré bajotimeout. - Todo corre secuencial igual. Verificá que
-P/-jesté configurado de verdad (xargs por defecto es 1), y que la entrada no sea una sola línea:xargsagrupa ítems separados por espacios, así quecat file | xargs -P4con 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á
-jpor 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.
Recursos Relacionados
Cómo Iterar sobre Archivos en Bash
Cómo iterar de forma segura sobre archivos y directorios en Bash, manejando espacios, globs y listas grandes de archivos con patrones correctos.
RecipeProcesamiento de Texto en Bash: grep, sed y awk
Cómo construir pipelines de procesamiento de texto con grep, sed, awk, cut, sort, uniq y tr para análisis de logs y transformación de datos.
RecipeRotación de copias en Bash: retención e integridad
Automatiza la rotación de copias en Bash con archivos diarios, semanales y mensuales, retención, cifrado y verificación de integridad.
RecipeRotación y Compresión de Logs en Bash
Rota, comprime y purga logs de aplicaciones con configuraciones de logrotate y scripts bash personalizados —incluyendo zstd, copytruncate, chequeo de disco y envío a S3.
RecipeMonitorear Uso de Disco
Alerta cuando el espacio en disco cruza umbrales usando un script de Bash que verifica mount points y notifica operadores.
RecipeControl de Jobs Paralelos
Cómo controlar jobs de Bash en paralelo una vez que corren: recolectar códigos de salida, reintentar fallos, aplicar timeouts y reanudar lotes interrumpidos.