0Pricing
DevOps Bootcamp · Lección

Perfilado de scripts y cómo evitar subshells innecesarios

Mida el tiempo de ejecución de los scripts y sustituya patrones con muchos forks, como cadenas de cat y grep, por alternativas integradas.

Perfilado de scripts y cómo evitar subshells innecesarios es una lección gratuita de DevOps Bootcamp en CoddyKit. Esta es la lección 1 de 4. Puedes leer la lección completa abajo gratuitamente — luego la practicas en el navegador con un editor de código integrado y un tutor de IA 24/7. Forma parte de la ruta de aprendizaje de DevOps Bootcamp, y tu progreso se sincroniza en la web y la app de CoddyKit. El curso de DevOps Bootcamp incluye 4 lecciones en total.

Por qué es importante el rendimiento de los scripts

Los scripts de Bash que se ejecutan lentamente desperdician tiempo de CI, bloquean las tareas de cron y frustran a los usuarios. La mayor parte de la lentitud no se debe a una lógica compleja, sino a la creación innecesaria de procesos: cada comando externo que se invoca inicia un proceso hijo nuevo.

En esta lección aprenderá a:

  • Medir en qué se emplea realmente el tiempo con time y bash -x
  • Identificar antipatrones que crean muchos procesos, como el uso inútil de cat
  • Sustituir comandos externos por builtins del shell más rápidos
  • Usar subshells de forma intencionada y evitarlos cuando no aporten valor

El objetivo es escribir scripts que hagan el mismo trabajo con menos procesos hijos y menos tiempo de ejecución real.

Medir el tiempo de un script con el builtin time

La herramienta de perfilado más sencilla es el builtin del shell time. Anteponiéndolo a cualquier comando o canalización, obtendrá tres mediciones:

  • real: tiempo transcurrido real (lo que usted espera en la práctica)
  • user: tiempo de CPU empleado en el código de espacio de usuario
  • sys: tiempo de CPU empleado en el kernel (llamadas al sistema, E/S)

Una gran diferencia entre real y user+sys suele indicar que el script está esperando a la E/S o creando muchos procesos hijos. Ejecute time sobre el script completo primero para confirmar que existe un problema antes de optimizar nada.

#!/usr/bin/env bash
# Time a whole script block
time {
  for i in $(seq 1 1000); do
    echo "line $i"
  done | grep -c "5"
}
# Output example:
# 271
# real  0m0.045s
# user  0m0.038s
# sys   0m0.012s

Rastrear la ejecución con bash -x y PS4

bash -x muestra cada comando antes de ejecutarlo; esto se denomina trazado de la ejecución. Permite ver qué líneas se ejecutan con mayor frecuencia y si se están invocando programas externos más veces de lo esperado.

De forma predeterminada, cada línea trazada lleva el prefijo +. Puede enriquecer el prefijo mediante PS4 para incluir marcas de tiempo, convirtiendo el trazado en un perfilador ligero:

  • PS4 se expande antes de cada comando trazado
  • Incluir $EPOCHREALTIME (bash 5+) o $(date +%s%N) proporciona una resolución de nanosegundos
  • Redirija stderr a un archivo y procéselo posteriormente para encontrar las secciones lentas
#!/usr/bin/env bash
# Run with:  bash -x ./myscript.sh  2>trace.log
# Or embed tracing inside the script:
export PS4='+ [${EPOCHREALTIME}] ${BASH_SOURCE}:${LINENO}: '
set -x

slow_function() {
  local result
  result=$(cat /etc/hostname)   # fork — slow
  echo "host: $result"
}

slow_function
set +x

# trace.log now contains timestamps so you can diff
# adjacent lines to find which step took longest.

¿Qué es un subshell innecesario?

Un subshell es una copia hija del proceso del shell actual. Se crea mediante:

  • Sustitución de comandos: $(command)
  • Agrupación entre paréntesis: ( commands )
  • Envío de datos mediante una canalización a una construcción del shell: cmd | while read ...

Los subshells son necesarios cuando realmente necesita aislamiento o una canalización. Se vuelven innecesarios cuando se usan únicamente para invocar un programa externo que el propio shell podría gestionar, o cuando se envuelve un builtin en una capa adicional de creación de procesos sin motivo.

La creación de cada subshell cuesta aproximadamente entre 1 y 5 ms en un sistema Linux moderno. En un bucle que se ejecuta 10.000 veces, 1000 subshells innecesarios añaden entre 1 y 5 segundos de sobrecarga pura.

El anti patrón clásico: uso inútil de cat

cat file | grep pattern es el antipatron más conocido de los que crean muchos procesos. Inicia dos procesos (cat y grep) conectados mediante una canalización, aunque grep puede leer el archivo directamente.

La solución es sencilla: pase el nombre del archivo directamente al comando que sabe trabajar con archivos. Esto se denomina redirección de entrada cuando la herramienta no acepta nombres de archivo, o simplemente omitir cat cuando sí los acepta.

  • Lento: cat file | grep pattern — 2 procesos, 1 canalización
  • Rápido: grep pattern file — 1 proceso, sin canalización
  • También rápido: grep pattern < file — 1 proceso, redirección de stdin (sin búfer de canalización)
#!/usr/bin/env bash
# Create a sample file
seq 1 10000 > /tmp/numbers.txt

# --- Slow: useless cat ---
time cat /tmp/numbers.txt | grep -c "^5"

# --- Fast: grep reads the file directly ---
time grep -c "^5" /tmp/numbers.txt

# Both print the same count; the second is measurably faster
# because it skips the cat process and the inter-process pipe.

Sustituir comandos externos por builtins del shell

Muchas transformaciones de una sola línea tienen un equivalente builtin que evita por completo la creación de un proceso. Compare estas sustituciones habituales:

  • echo ${#var} en lugar de echo "$var" | wc -c — longitud de una cadena
  • ${var^^} y ${var,,} en lugar de echo "$var" | tr 'a-z' 'A-Z' — conversión de mayúsculas y minúsculas (bash 4+)
  • ${var//search/replace} en lugar de echo "$var" | sed 's/search/replace/' — sustitución sencilla
  • [[ "$var" =~ pattern ]] en lugar de echo "$var" | grep -q pattern — coincidencia con una expresión regular
  • read -r line < file en lugar de line=$(head -n1 file) — leer la primera línea

Ninguno de estos builtins crea un proceso hijo. El ahorro es pequeño en cada llamada, pero se acumula de forma considerable dentro de los bucles.

#!/usr/bin/env bash
sentence="hello world from bash"

# --- Fork-heavy ---
upper_slow=$(echo "$sentence" | tr 'a-z' 'A-Z')
length_slow=$(echo "$sentence" | wc -c)

# --- Builtin equivalents (zero extra processes) ---
upper_fast=${sentence^^}
length_fast=${#sentence}

echo "Slow upper : $upper_slow"
echo "Fast upper : $upper_fast"
echo "Slow length: $length_slow"
echo "Fast length: $length_fast"

Evitar subshells dentro de los bucles

La sustitución de comandos dentro de un bucle multiplica el coste de crear procesos por el número de iteraciones. Un bucle que se ejecuta 500 veces con una llamada a $(date) crea 500 procesos hijos solo para obtener marcas de tiempo.

Estrategias para reducir la sobrecarga de los bucles:

  • Mueva los comandos invariantes fuera del bucle (calcule una vez y reutilice el resultado)
  • Prefiera la expansión aritmética $(( expr )): es un builtin, no crea un proceso
  • Use printf en lugar de invocar date cuando solo necesite dar formato
  • Agrupe las llamadas externas: recopile primero los datos y procéselos una sola vez fuera del bucle
#!/usr/bin/env bash
# Demonstrate: compute-once vs fork-per-iteration

# Bad: $(date) forks 1000 times
time (
  for i in $(seq 1 1000); do
    ts=$(date +%s)   # fork each iteration
    echo "$i $ts" > /dev/null
  done
)

# Good: capture once, reuse
time (
  ts=$(date +%s)   # fork exactly once
  for i in $(seq 1 1000); do
    echo "$i $ts" > /dev/null
  done
)

Subshells de las canalizaciones y el problema del ámbito de las variables

En bash (a diferencia de ksh/zsh), cada comando de una canalización se ejecuta en su propio subshell. Esto significa que las variables establecidas dentro de una canalización se pierden cuando esta termina.

Esto constituye tanto un error de corrección como un problema de rendimiento: puede enviar datos a while read esperando recopilarlos y descubrir después que la variable está vacía.

Hay dos soluciones:

  • Use la sustitución de procesos while read line; do ...; done < <(command): el bucle while se ejecuta en el shell actual, no en un subshell
  • Use la opción lastpipe (shopt -s lastpipe): hace que el último segmento de la canalización se ejecute en el shell actual (bash 4.2+)
#!/usr/bin/env bash
count=0

# --- Bug: count is always 0 after pipe (subshell) ---
seq 1 5 | while read -r n; do
  (( count++ ))
done
echo "After pipe   : count=$count"   # prints 0

# --- Fix 1: process substitution (no subshell for while) ---
count=0
while read -r n; do
  (( count++ ))
done < <(seq 1 5)
echo "Process sub  : count=$count"   # prints 5

# --- Fix 2: lastpipe option ---
shopt -s lastpipe
count=0
seq 1 5 | while read -r n; do
  (( count++ ))
done
echo "lastpipe     : count=$count"   # prints 5

Medir el coste de los subshells con un microbenchmark

Es fácil demostrar la sobrecarga de los subshells con un pequeño benchmark. Compare una operación aritmética realizada mediante $(( )) (builtin) con la misma operación canalizada mediante expr (proceso externo).

Los resultados en un equipo Linux habitual muestran que 10.000 llamadas a expr tardan aproximadamente 5 segundos, mientras que el mismo número de llamadas a $(( )) tarda menos de 0,1 segundos: una diferencia de 50x con un resultado idéntico.

Este patrón de benchmark también resulta útil cuando quiera medir cualquier optimización: ejecute ambas versiones N veces en un bucle y compárelas con time.

#!/usr/bin/env bash
N=500

# External command (fork per call)
time (
  x=0
  for ((i=0; i<N; i++)); do
    x=$(expr $x + 1)   # forks expr each time
  done
  echo "expr result: $x"
)

# Arithmetic builtin (no fork)
time (
  x=0
  for ((i=0; i<N; i++)); do
    (( x++ ))          # pure builtin
  done
  echo "builtin result: $x"
)

Usar here-strings para evitar canalizaciones con echo

Un patrón habitual consiste en usar echo "$var" | command para proporcionar una variable mediante stdin. Esto crea dos procesos (echo + command) y una canalización. Un here-string (<<<) consigue el mismo resultado con un solo proceso: el comando externo lee de un búfer temporal gestionado por el kernel.

  • grep pattern <<< "$var" — un proceso, sin canalización
  • read -r field1 field2 <<< "$line" — dividir una variable sin una herramienta externa
  • wc -w <<< "$sentence" — contar las palabras de una variable

Los here-strings son especialmente útiles dentro de bucles ajustados, donde cada creación de proceso cuenta.

#!/usr/bin/env bash
data="The quick brown fox"

# --- Fork-heavy: echo spawns a child ---
word_count_slow=$(echo "$data" | wc -w)
echo "Slow word count: $word_count_slow"

# --- Fast: here-string, only wc spawns ---
word_count_fast=$(wc -w <<< "$data")
echo "Fast word count: $word_count_fast"

# --- Even better: use parameter expansion (zero forks) ---
# Split into array, count elements
read -ra words <<< "$data"
echo "Zero-fork count: ${#words[@]}"

Refactorización práctica: antes y después

Veamos un script realista que procesa un archivo de registro y apliquemos todo lo aprendido. La versión original encadena cat, grep, awk y tr mediante canalizaciones. La versión refactorizada reduce el número de procesos de 8 a 2.

Cambios principales:

  • Se eliminó cat: grep lee el archivo directamente
  • Se sustituyó tr '[:lower:]' '[:upper:]' por ${var^^}
  • Se sustituyó echo "$line" | grep -q por [[ $line =~ ]]
  • Se usó read -r con sustitución de procesos en lugar de un bucle while canalizado

Después de la refactorización, vuelva a ejecutar time ./script.sh para confirmar la mejora. Mida siempre el rendimiento; no haga suposiciones.

#!/usr/bin/env bash
# Create a sample log
printf 'ERROR: disk full\nINFO: started\nERROR: timeout\nINFO: done\n' \
  > /tmp/sample.log

# === BEFORE (fork-heavy) ===
time (
  cat /tmp/sample.log \
    | grep 'ERROR' \
    | while read -r line; do
        label=$(echo "$line" | tr '[:lower:]' '[:upper:]')
        echo "[ALERT] $label"
      done
)

# === AFTER (builtin-first) ===
time (
  while IFS= read -r line; do
    echo "[ALERT] ${line^^}"
  done < <(grep 'ERROR' /tmp/sample.log)
)

Comprobación de conocimientos: ámbito de los subshells

Ponga a prueba su comprensión de los subshells de las tuberías y de cómo evitar perder los cambios en las variables realizados dentro de una tubería.

Resumen de la lección: perfile primero, cree menos procesos

En esta lección ha aprendido a identificar y eliminar las fuentes más comunes de creación innecesaria de procesos en scripts de bash.

Conclusiones clave:

  • Use time y bash -x enriquecido con PS4 para medir antes de optimizar
  • cat inútil es el antipatrón más extendido: pase los nombres de archivo directamente a los comandos que los acepten
  • Sustituya echo "$var" | command por una here-string (command <<< "$var") o por un builtin
  • Las expansiones de parámetros (${var^^}, ${var//s/r}, ${#var}) sustituyen muchas llamadas a tr, sed y wc
  • Los subshells de las tuberías absorben las modificaciones de las variables: use sustitución de procesos o shopt -s lastpipe
  • Mueva las llamadas a comandos invariantes fuera de los bucles; prefiera la aritmética $(( )) en lugar de expr

La regla general es: mida primero, sustituya los comandos externos por builtins cuando sea posible y verifique la mejora con una segunda medición.

Preguntas frecuentes

¿La lección «Perfilado de scripts y cómo evitar subshells innecesarios» es gratis?

Sí — el texto completo de «Perfilado de scripts y cómo evitar subshells innecesarios» es gratis para leer aquí en la web. Para practicarla de forma interactiva (editor de código integrado y tutor de IA 24/7) y desbloquear el resto del curso de DevOps Bootcamp, actualiza a CoddyKit PRO. El curso de DevOps Bootcamp incluye 4 lecciones en total.

¿Qué aprenderé en «Perfilado de scripts y cómo evitar subshells innecesarios»?

Mida el tiempo de ejecución de los scripts y sustituya patrones con muchos forks, como cadenas de cat y grep, por alternativas integradas. Practicas DevOps Bootcamp con código real que ejecutas directamente en el navegador, y un tutor de IA 24/7 responde tus preguntas mientras trabajas en la lección.

¿Necesito experiencia previa para empezar DevOps Bootcamp?

No se requiere experiencia previa. DevOps Bootcamp en CoddyKit está estructurado para principiantes hasta estudiantes avanzados, así que puedes empezar aquí o desde el inicio y avanzar a tu ritmo. Esta es la lección 1 de 4.

¿Cuánto tiempo toma la lección «Perfilado de scripts y cómo evitar subshells innecesarios»?

La mayoría de las lecciones de CoddyKit toman alrededor de 5–10 minutos. Cada una es compacta e interactiva, así que avanzas constantemente y retomas exactamente por donde dejaste en la web y la app.

¿Puedo escribir y ejecutar código en esta lección de DevOps Bootcamp?

Sí. Cada lección de DevOps Bootcamp incluye un editor de código integrado, así que escribes y ejecutas código real directamente en tu navegador y obtienes retroalimentación instantánea de IA — sin configuración local necesaria.

Todas las lecciones de este curso

  1. Perfilado de scripts y cómo evitar subshells innecesarios
  2. Paralelismo con xargs -P y trabajos en segundo plano
  3. Orquestación de cargas de trabajo con GNU parallel
  4. Pipelines de streaming y tuberías con nombre para mejorar el rendimiento
← Volver a DevOps Bootcamp