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
- El modelo de procesos de Unix
- Ver lo que corre:
ps,pstree,top - Buscar procesos:
pgrep - Primer y segundo plano
- Sobrevivir al cierre de sesión:
disown,nohup,setsid $!ywait: paralelismo controlado- Señales
kill,pkill,killallytimeout- Prioridades con
niceyrenice - Una sola instancia a la vez:
flock - Zombis y huérfanos
- 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 padreLos 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.
- Ver lo que corre:
ps, pstree, top
ps, pstree, topps 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 dibujadaLas 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.
- Buscar procesos:
pgrep
pgrepEl 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:
- 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.
- Sobrevivir al cierre de sesión:
disown, nohup, setsid
disown, nohup, setsidAl 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.
$! y wait: paralelismo controlado
$! 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"/*.txtTres 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 PIDdevuelve el código de salida de ese hijo, así que el paralelismo no renuncia al control de errores.waitsin argumentos espera a todos los hijos, pero entonces pierdes los códigos individuales.wait -ndevuelve 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.
- 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 | Nº | Qué significa | ¿Interceptable? |
|---|---|---|---|
SIGHUP |
1 | Terminal cerrada; por convenio, "recarga tu configuración" | Sí |
SIGINT |
2 | Ctrl-C: interrupción desde el teclado |
Sí |
SIGTERM |
15 | "Termina ordenadamente". El que envía kill por defecto |
Sí |
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 | Sí |
SIGUSR1/SIGUSR2 |
10/12 | Libres: las define tu aplicación | Sí |
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.
kill, pkill, killall y timeout
kill, pkill, killall y timeoutkill 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 ejecutablePor 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ésSi 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.
- Prioridades con
nice y renice
nice y renicenice -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 discoEl 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.
- Una sola instancia a la vez:
flock
flockEl problema que abría la lección: dos informe-diario.sh simultáneos. El apaño casero es un fichero 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 instanciaexec 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.
- 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 procesoen una condición. Se encuentra a sí mismo y siempre da cierto. Usapgrep.pgrep informe-diario.shsin-f. El proceso se llamabash; sin-fno encuentras tu script.- Empezar por
kill -9. Deja ficheros a medias, temporales sin borrar y bloqueos huérfanos.SIGTERMprimero. - Lanzar N tareas con
&y no hacerwait. 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. Uncurlsin 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
- ¿Qué es Bash?
- Configurando tu Entorno
- Navegación Básica en la Línea de Comandos
- Entendiendo el Shell
- Encontrar Ayuda: man, help y --help
Módulo 2: Comandos Básicos de Bash
- Operaciones con Archivos y Directorios
- Comandos de Procesamiento de Texto
- Permisos y Propiedad de Archivos
- Redirección y Tuberías
- Comodines y Expansión de Rutas
- Historial y Atajos de Teclado
Módulo 3: Fundamentos de Scripting
- Creando y Ejecutando un Script
- Variables y Constantes
- Operadores Básicos
- Sentencias Condicionales
- Argumentos y Entrada del Usuario
- Comillas, Expansión y Sustitución
Módulo 4: Scripting Intermedio
- Bucles en Bash
- Funciones en Bash
- Arrays y Arrays Asociativos
- Manipulación de Cadenas
- La Sentencia case y los Menús Interactivos
- Aritmética y Cálculos Numéricos
Módulo 5: Técnicas Avanzadas de Scripting
- Operaciones Avanzadas con Archivos
- Gestión de Procesos
- Manejo de Errores y Depuración
- Expresiones Regulares
- Entrada/Salida Avanzada: Descriptores y Here-Documents
- Scripts Modulares y Librerías Reutilizables
Módulo 6: Trabajando con Herramientas Externas
Módulo 7: Automatización y Programación
- Trabajos Cron
- Automatizando Tareas
- Scripts de Respaldo y Restauración
- Monitoreo y Registro
- Servicios y Temporizadores con systemd
- Automatización Remota con SSH
Módulo 8: Mejores Prácticas y Optimización
- Escribiendo Código Legible
- Optimizando Scripts en Bash
- Consideraciones de Seguridad
- Control de Versiones con Git
- Análisis Estático con ShellCheck y shfmt
- Pruebas Automatizadas con Bats
- Portabilidad: POSIX sh frente a Bashismos
