En 05-01 aprendiste a elegir con precisión los ficheros sobre los que actúa el toolkit. Pero un script de operaciones no solo lee y escribe: lanza procesos. informe-diario.sh invoca grep, bc, tar y consulta a la veloz-api; recorre cuatro ciudades una tras otra pudiendo hacerlo a la vez; y no tiene ninguna defensa si el informe de las 6:00 sigue corriendo cuando cron arranca el de las 6:05 —dos procesos escribiendo el mismo fichero, contadores mezclados y un informe sin sentido—. En 02-06 viste lo justo para no perder el control de la terminal: Ctrl-C, Ctrl-Z, jobs, fg, bg, &. Aquí completamos ese cuadro y lo llevamos al terreno del scripting: ver, buscar, lanzar en paralelo, esperar, señalizar, limitar y bloquear.

Contenido

  1. El modelo de procesos de Unix
  2. Ver lo que corre: ps, pstree, top
  3. Buscar procesos: pgrep
  4. Primer y segundo plano
  5. Sobrevivir al cierre de sesión: disown, nohup, setsid
  6. $! y wait: paralelismo controlado
  7. Señales
  8. kill, pkill, killall y timeout
  9. Prioridades con nice y renice
  10. Una sola instancia a la vez: flock
  11. Zombis y huérfanos

  1. El modelo de procesos de Unix

Un proceso es un programa en ejecución con su propia memoria, su entorno (las variables exportadas de 01-02) y un número que lo identifica: el PID. Todo proceso tiene además un PPID, el PID de quien lo creó. Eso convierte el sistema en un árbol cuya raíz es init/systemd (PID 1).

Cuando en 01-04 viste que un comando externo se ejecuta en un proceso hijo, eso es exactamente lo que ocurre: Bash se duplica (fork) y el duplicado se transforma en el comando (exec). De ahí salen dos consecuencias que ya conoces y que ahora encajan: el hijo hereda el entorno del padre, y nada de lo que el hijo cambie afecta al padre —el motivo por el que cd es un builtin y por el que el bucle de una tubería no ve sus variables (Módulo 4)—.

echo $$        # 4821  ← PID de tu shell actual
echo $PPID     # 4790  ← quién lo lanzó (el emulador de terminal)
bash -c 'echo $$; echo $PPID'   # 5102 / 4821: el hijo conoce a su padre

Los estados en los que puede estar un proceso aparecen en la columna STAT de ps: R ejecutándose o listo, S dormido esperando algo (lo normal), D en espera ininterrumpible de disco, T detenido, Z zombi.

  1. Ver lo que corre: ps, pstree, top

ps tiene dos sintaxis históricas que conviven; ambas son correctas y verás las dos:

ps aux           # estilo BSD: todos los procesos, con dueño, sin controlar terminal
ps -ef           # estilo System V: todos, formato completo
ps -ef --forest  # con la jerarquía dibujada

Las columnas de ps aux que de verdad se leen:

Columna Qué significa
USER Dueño del proceso
PID Identificador, el que pasarás a kill
%CPU / %MEM Porcentaje de uso desde que arrancó (no instantáneo)
VSZ / RSS Memoria virtual / memoria física real en KiB. RSS es la que importa
STAT Estado (R, S, T, Z) más sufijos: s líder de sesión, + en primer plano
START / TIME Cuándo arrancó / CPU consumida acumulada
COMMAND La línea de comandos completa

ps -ef muestra PPID en lugar de %CPU, lo que la hace mejor para investigar quién lanzó qué. Y -o selecciona solo los campos que te interesan, que es lo que se usa en scripts:

ps -eo pid,ppid,user,rss,etime,comm --sort=-rss | head -5
#   PID  PPID USER       RSS ELAPSED COMMAND
#  2210     1 veloz   184320 3-04:12 veloz-api

--sort=-rss ordena por memoria descendente; etime es el tiempo transcurrido desde el arranque, mucho más útil que TIME para saber si algo lleva colgado desde ayer.

pstree -p 2210 dibuja el árbol de un proceso y sus hijos, ideal para entender qué ha lanzado un script. top (y su versión cómoda htop) son interactivos: refrescan en vivo y sirven para mirar, no para programar. En un script nunca se usa top; se usa ps con -o.

  1. Buscar procesos: pgrep

El reflejo aprendido es ps aux | grep veloz-api, y tiene un defecto que causa bugs reales:

ps aux | grep veloz-api
# veloz  2210 ... /usr/local/bin/veloz-api
# joan   7788 ... grep --color=auto veloz-api    ← ¡el propio grep!

El grep aparece en su propia búsqueda, porque ps lo lista mientras corre. Un if ps aux | grep -q veloz-api da siempre cierto, aunque la API esté caída. La solución tradicional es el truco de la clase [v]eloz-api, pero la herramienta correcta existe:

pgrep veloz-api            # solo PIDs, uno por línea
pgrep -a veloz-api         # PID y línea de comandos
pgrep -c veloz-api         # cuántos hay
pgrep -u veloz veloz-api   # de ese usuario
pgrep -f 'informe-diario'  # busca en la línea COMPLETA, no solo en el nombre
pgrep -x bash              # nombre exacto, sin coincidencias parciales

-f es imprescindible para scripts: sin él, pgrep informe-diario.sh no encuentra nada, porque el nombre del proceso es bash, y el script es solo un argumento. Y pgrep devuelve código 0 si encontró algo y 1 si no, así que encaja directamente en una guarda de las de 03-04:

pgrep -x veloz-api > /dev/null \
    || log_error "veloz-api caída: el informe usará solo el CSV"

  1. Primer y segundo plano

El shell agrupa los procesos que lanza en trabajos (jobs), y los numera. Recapitulando y ampliando 02-06:

tar -czf respaldo.tar.gz /srv/veloz/datos &   # lanzar en segundo plano → [1] 8123
jobs -l          # listar con PID       # fg %1: traer al primer plano
bg %1            # reanudar en segundo plano tras un Ctrl-Z (que lo deja en estado T)
kill %1          # a los trabajos se les habla con %

Las referencias %1, %+ (el más reciente), %- (el anterior) y %?tar (por texto del comando) funcionan con fg, bg, kill y wait. Un detalle que importa: los trabajos son un concepto del shell interactivo; dentro de un script no hay control de trabajos, pero &, $! y wait sí funcionan, y son los que se usan.

  1. Sobrevivir al cierre de sesión: disown, nohup, setsid

Al cerrar la terminal, el shell envía SIGHUP (hang up, colgar, herencia de los módems) a sus trabajos, y estos mueren. Tres formas de evitarlo —nohup cmd > salida 2>&1 &, cmd & disown %1 y setsid cmd > /dev/null 2>&1 &—:

Herramienta Cuándo se decide Qué hace
nohup Antes de lanzar Ignora SIGHUP y redirige la salida a nohup.out si es un terminal
disown Después de lanzar Saca el trabajo de la lista del shell, que ya no le enviará SIGHUP
setsid Antes de lanzar Crea una sesión nueva: el proceso deja de tener terminal de control

disown es el rescate cuando ya lanzaste algo largo y te das cuenta de que necesitas cerrar la sesión. Para tareas de verdad desatendidas, systemd (07-05) es la respuesta correcta; nohup es el apaño rápido.

  1. $! y wait: paralelismo controlado

$! contiene el PID del último proceso lanzado en segundo plano, y wait espera a que termine. Con esos dos, informe-diario.sh puede calcular las cuatro ciudades a la vez:

declare -a PIDS=(); fallos=0
for ciudad in Valencia Sevilla Bilbao Madrid; do
    resumen_ciudad "$ciudad" > "$TMPDIR/$ciudad.txt" &
    PIDS+=( "$!" )                       # guardamos el PID de cada hijo
done
for pid in "${PIDS[@]}"; do
    wait "$pid" || (( ++fallos ))        # wait devuelve el código de salida del hijo
done
cat "$TMPDIR"/*.txt

Tres cosas que hacen que este patrón funcione y no sea un simple "lanzar cosas":

  • Cada hijo escribe en su propio fichero. Si todos escribieran en el mismo, sus salidas se entrelazarían a medias líneas. Un directorio de mktemp -d (05-01) es el sitio natural.
  • wait PID devuelve el código de salida de ese hijo, así que el paralelismo no renuncia al control de errores.
  • wait sin argumentos espera a todos los hijos, pero entonces pierdes los códigos individuales. wait -n devuelve en cuanto termina cualquiera de ellos, que es la base de una cola con un número máximo de tareas simultáneas.

Recuerda que cada hijo es un proceso separado: las variables que modifique no vuelven al padre. Por eso los resultados viajan por ficheros, exactamente igual que en el subshell de la tubería del Módulo 4.

  1. Señales

Una señal es una notificación asíncrona que el núcleo entrega a un proceso. El proceso puede manejarla, ignorarla o dejar que actúe el comportamiento por defecto —salvo dos que nadie puede interceptar—.

Señal Qué significa ¿Interceptable?
SIGHUP 1 Terminal cerrada; por convenio, "recarga tu configuración"
SIGINT 2 Ctrl-C: interrupción desde el teclado
SIGTERM 15 "Termina ordenadamente". El que envía kill por defecto
SIGKILL 9 Muerte inmediata decidida por el núcleo No
SIGSTOP 19 Congelar el proceso (Ctrl-Z envía SIGTSTP, su primo interceptable) No
SIGCONT 18 Reanudar un proceso detenido
SIGUSR1/SIGUSR2 10/12 Libres: las define tu aplicación
stateDiagram-v2
    [*] --> Ejecutandose: fork + exec
    Ejecutandose --> Detenido: SIGSTOP / SIGTSTP (Ctrl-Z)
    Detenido --> Ejecutandose: SIGCONT (fg / bg)
    Ejecutandose --> Limpiando: SIGTERM / SIGINT (interceptable)
    Limpiando --> [*]: cierra ficheros y sale
    Ejecutandose --> [*]: SIGKILL (sin limpieza)
    Detenido --> [*]: SIGKILL

El diagrama contiene la lección entera: la ruta por Limpiando es la que permite a un proceso cerrar ficheros, borrar temporales y dejar el sistema consistente. SIGKILL corta esa ruta. Capturar señales en tus propios scripts se hace con trap, y es el tema central de 05-03; aquí nos ocupamos de enviarlas.

  1. kill, pkill, killall y timeout

kill 8123              # envía SIGTERM: "termina cuando puedas"
kill -TERM 8123        # idéntico, explícito
kill -HUP 2210         # recargar configuración de veloz-api
kill -9 8123           # SIGKILL: último recurso
kill -l                # lista de señales disponibles
pkill -f informe-diario     # por patrón, como pgrep
pkill -u veloz -TERM veloz-api
killall veloz-api      # por nombre exacto de ejecutable

Por qué kill -9 es el último recurso: SIGKILL no llega al proceso, lo ejecuta el núcleo. El programa no cierra sus ficheros (queda un CSV a medio escribir), no borra sus temporales, no libera su bloqueo, y sus hijos quedan huérfanos. La secuencia correcta es SIGTERM, esperar unos segundos comprobando con kill -0 "$pid" —que no envía ninguna señal, solo pregunta "¿existe y puedo señalizarlo?"— y solo si sigue vivo, SIGKILL. Lo montarás así en el ejercicio 3.

timeout resuelve el otro lado del problema: que algo no termine nunca.

timeout 30 curl -s http://localhost:8080/salud   # mata a los 30 s
timeout -k 5 30 informe-diario.sh detalle        # TERM a los 30 s, KILL 5 s después

Si expira, timeout devuelve 124. Ese código es la forma de distinguir "falló" de "tardó demasiado", y es imprescindible en cualquier llamada de red dentro de una tarea programada.

  1. Prioridades con nice y renice

nice -n 10 tar -czf historico.tar.gz /srv/veloz/datos/historico   # baja prioridad
renice -n 5 -p 8123                                              # cambiar en marcha
ionice -c 3 tar -czf ...                                         # prioridad de disco

El valor va de -20 (máxima prioridad) a 19 (mínima). Solo root puede bajar el número; cualquiera puede subirlo, es decir, ser considerado. Para el archivado nocturno de Veloz Envíos, nice -n 10 junto con ionice -c 3 evita que la compresión compita con la veloz-api por CPU y disco.

  1. Una sola instancia a la vez: flock

El problema que abría la lección: dos informe-diario.sh simultáneos. El apaño casero es un fichero PID:

[[ -f /tmp/informe.pid ]] && exit 1      # FRÁGIL
echo $$ > /tmp/informe.pid

Es frágil por dos motivos. Entre la comprobación y la escritura hay una ventana en la que otro proceso hace lo mismo (condición de carrera, la misma familia que la del /tmp/fichero.$$ de 05-01). Y si el script muere de golpe, el fichero queda ahí y bloquea todas las ejecuciones futuras hasta que alguien lo borre a mano.

flock usa un bloqueo del núcleo asociado a un descriptor abierto, que el sistema libera solo cuando el proceso muere, pase lo que pase:

readonly LOCK=~/veloz-ops/logs/.informe.lock
exec 9> "$LOCK"          # descriptor 9 asociado al fichero de bloqueo
flock -n 9 || { printf 'Ya hay un informe en curso\n' >&2; exit 1; }
main "$@"                # a partir de aquí somos la única instancia

exec 9> abre el fichero en el descriptor 9 (los descriptores son el tema de 05-05; por ahora, léelo como "reserva el canal 9 para este fichero"). flock -n 9 intenta bloquearlo sin esperar. Si prefieres encolar en vez de abortar, flock -w 60 9 espera hasta 60 segundos. El bloqueo desaparece al terminar el script, aunque sea por SIGKILL. Cuando lleves esto a cron en 07-02 verás la variante de una sola línea, flock -n /ruta/lock comando, pensada justo para el crontab.

  1. Zombis y huérfanos

Cuando un proceso termina, su entrada permanece en la tabla del sistema hasta que su padre recoge el código de salida con wait. En ese intervalo es un zombi (STAT = Z): ya no consume CPU ni memoria, solo una entrada. Son normales y efímeros; solo son un síntoma si se acumulan cientos, lo que indica un padre que lanza hijos y nunca hace wait —exactamente el error que evita el patrón de la sección 6—.

Un huérfano es lo contrario: su padre murió antes que él. El sistema lo adopta asignándole init/systemd como padre, así que sigue funcionando con normalidad y alguien recogerá su código al terminar. Un proceso lanzado con nohup ... & acaba siendo un huérfano adoptado, y por eso sobrevive.

Errores Comunes y Consejos

  • ps aux | grep proceso en una condición. Se encuentra a sí mismo y siempre da cierto. Usa pgrep.
  • pgrep informe-diario.sh sin -f. El proceso se llama bash; sin -f no encuentras tu script.
  • Empezar por kill -9. Deja ficheros a medias, temporales sin borrar y bloqueos huérfanos. SIGTERM primero.
  • Lanzar N tareas con & y no hacer wait. El script termina antes que sus hijos y los resultados no están.
  • Esperar que un hijo modifique variables del padre. Es otro proceso: comunica por ficheros o por stdout.
  • Ficheros PID caseros como bloqueo. Carrera al crearlos y bloqueo permanente si el script muere. Usa flock.
  • Consejo: toda llamada a un servicio externo dentro de una tarea desatendida va envuelta en timeout. Un curl sin límite puede dejar colgada la tarea de las 6:00 hasta la del día siguiente.

Ejercicios

Ejercicio 1. Escribe api_viva(), que devuelva 0 si el proceso veloz-api está corriendo y 1 si no, sin caer en el problema del grep que se encuentra a sí mismo, y que además informe por stderr del PID y de los minutos que lleva en marcha.

Ejercicio 2. Modifica el bucle de ciudades de informe-diario.sh para que las cuatro se calculen en paralelo, cada una en su fichero, esperando a todas y contando cuántas fallaron.

Ejercicio 3. Escribe una función parar_servicio() que envíe SIGTERM a todos los procesos veloz-api, espere hasta 15 segundos comprobando cada segundo y solo entonces recurra a SIGKILL, informando de qué camino tomó.

Soluciones

Solución 1.

api_viva() {
    local pid
    pid=$(pgrep -x -u veloz veloz-api | head -1) || return 1
    printf 'veloz-api viva (PID %s, %s en marcha)\n' \
        "$pid" "$(ps -o etime= -p "$pid" | tr -d ' ')" >&2
    return 0
}

pgrep -x exige nombre exacto y ya devuelve 1 si no hay coincidencias, así que el || return 1 basta como guarda (03-04). ps -o etime= con el = final imprime el valor sin cabecera, y tr -d ' ' quita el relleno de alineación.

Solución 2.

calcular_ciudades() {
    local tmpdir ciudad pid fallos=0
    tmpdir=$(mktemp -d) || return 1
    local -a pids=()
    for ciudad in "${CIUDADES[@]}"; do
        resumen_ciudad "$ciudad" > "$tmpdir/$ciudad.txt" 2>"$tmpdir/$ciudad.err" &
        pids+=( "$!" )
    done
    for pid in "${pids[@]}"; do wait "$pid" || (( ++fallos )); done
    cat "$tmpdir"/*.txt
    (( fallos > 0 )) && log_error "$fallos de ${#pids[@]} ciudades fallaron"
    rm -rf "$tmpdir"
    return 0
}

Los errores de cada hijo van a su propio .err para que no se entrelacen. Con cuatro ciudades el ahorro es de tres cuartas partes del tiempo si el cuello de botella es la lectura del CSV. Fíjate en que rm -rf "$tmpdir" solo se ejecuta si llegamos vivos hasta ahí; en 05-03 lo convertirás en un trap.

Solución 3.

parar_servicio() {
    local i pids
    pids=$(pgrep -x veloz-api) || { printf 'No estaba corriendo\n'; return 0; }
    printf 'Enviando SIGTERM a: %s\n' "$pids"
    kill -TERM $pids 2>/dev/null
    for (( i = 1; i <= 15; i++ )); do
        pgrep -x veloz-api > /dev/null || { printf 'Parado limpiamente en %ds\n' "$i"; return 0; }
        sleep 1
    done
    printf 'No responde tras 15s: SIGKILL\n' >&2
    pkill -x -KILL veloz-api
    return 1
}

$pids va sin comillas a propósito —el único caso de todo el curso en que eso es correcto—: queremos que la lista de PIDs se parta en varios argumentos para kill. Y el return 1 final permite a quien llama saber que hubo que forzar la muerte, lo que probablemente merezca una entrada en el registro.

Conclusión

Cada comando externo es un proceso con su PID, su PPID y su lugar en un árbol que nace en systemd. ps aux y ps -ef lo enseñan todo —con RSS y etime como columnas más útiles—, ps -eo selecciona campos para scripts, pstree dibuja la jerarquía y top/htop sirven para mirar en vivo, nunca para programar. pgrep, con -f y -x, sustituye al ps aux | grep que se encuentra a sí mismo. &, jobs, fg, bg y %1 gobiernan los trabajos del shell; nohup, disown y setsid los sacan del alcance de SIGHUP. La pareja $! + wait convierte cuatro cálculos secuenciales en cuatro paralelos sin renunciar a sus códigos de salida. Las señales son el lenguaje entre procesos: SIGTERM pide terminar y permite limpiar, SIGKILL no se puede interceptar y por eso es el último recurso, timeout pone un límite y devuelve 124. Y flock sobre un descriptor da la exclusión mutua que un fichero PID casero nunca dará.

informe-diario.sh ya sabe comprobar si la API está viva, repartir el trabajo entre cuatro procesos y negarse a ejecutarse dos veces a la vez. Pero sigue teniendo un agujero: si algo falla dentro —una variable sin definir, un grep sin resultados, un disco lleno a media escritura—, el script continúa como si nada y produce un informe silenciosamente falso, dejando además el temporal de mktemp sin borrar. En 05-03 cerramos ese agujero: set -euo pipefail con su crítica honesta, PIPESTATUS, trap con EXIT y ERR para la limpieza garantizada que llevamos dos lecciones prometiendo, una función morir() que informa de la línea exacta del fallo, un convenio de códigos de salida y las herramientas de depuración bash -x, PS4 y BASH_XTRACEFD.

Curso de Programación en Bash

Módulo 1: Introducción a Bash

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados