Cuando un proceso hijo termina, el kernel no elimina inmediatamente su entrada de la tabla de procesos. La mantiene en un estado especial —zombie— hasta que el proceso padre recoja el código de salida mediante la llamada al sistema wait() o alguna de sus variantes (waitpid(), waitid()). Solo entonces el kernel libera esa entrada por completo.
El diseño tiene sentido: el padre necesita un mecanismo fiable para saber cómo terminó el hijo, con qué código de salida, cuánto tiempo de CPU consumió. Si el kernel destruyera la entrada en el momento de la muerte del hijo, esa información se perdería antes de que el padre pudiera consultarla. El zombie es, literalmente, el cadáver esperando a ser reclamado.
El problema aparece cuando el padre está vivo pero nunca llama a wait(). Cada hijo que termina deja una entrada en la tabla de procesos en estado Z. Esas entradas no consumen CPU ni memoria de usuario —el proceso ya no ejecuta código—, pero sí ocupan una entrada en la tabla de procesos del kernel y, con ella, un PID. En sistemas con límites ajustados (/proc/sys/kernel/pid_max) o daemons con fugas graves, acumular miles de zombies puede agotar el espacio de PIDs y hacer que fork() falle con EAGAIN, bloqueando la creación de nuevos procesos en todo el sistema.
El caso contrario —el padre muere antes de llamar a wait()— produce un proceso huérfano. El kernel lo re-emparenta automáticamente a PID 1 (systemd en Debian Bookworm), que tiene un bucle interno de wait() y limpia a los adoptados sin dejar zombies.
Para ver zombies en el sistema basta con filtrar por estado Z en la columna STAT de ps:
ps aux | awk '$8 == "Z" { print $0 }'
La columna 8 es el estado del proceso; grep Z funciona pero produce falsos positivos si algún comando tiene “Z” en su nombre.
El siguiente escenario reproduce el problema completo: creamos un padre que genera hijos y deliberadamente no llama a wait(), observamos los zombies que acumula, luego enviamos SIGCHLD para que los recoja, y finalmente matamos al padre para ver cómo systemd adopta y limpia a los supervivientes.
#!/usr/bin/env bash
# zombie_demo.sh — demuestra la acumulación y limpieza de zombies
set -euo pipefail
# --- Paso 1: padre que no recoge a sus hijos ---
# Este proceso lanza 5 hijos, cada uno termina de inmediato,
# pero el padre duerme sin llamar a wait(). Los hijos quedan zombie.
python3 - <<'PYEOF' &
import os, time, signal
def spawn_and_forget(n):
for _ in range(n):
pid = os.fork()
if pid == 0:
# Hijo: termina inmediatamente con código 42
os._exit(42)
# Padre: no llama a os.waitpid() — zombie garantizado
spawn_and_forget(5)
print(f"[padre] PID={os.getpid()}, hijos lanzados, durmiendo 60s sin wait()")
time.sleep(60)
PYEOF
PADRE_PID=$!
echo "[shell] PID del padre: $PADRE_PID"
# Espera breve para que los hijos hayan terminado
sleep 1
# --- Paso 2: confirmar zombies ---
echo ""
echo "[verificación] Procesos zombie hijos del padre $PADRE_PID:"
# ps -o muestra PID, PPID, estado y comando; filtramos hijos del padre
ps --ppid "$PADRE_PID" -o pid,ppid,stat,comm 2>/dev/null || true
echo ""
echo "[verificación] Conteo de zombies en el sistema:"
ps aux | awk '$8 == "Z"' | wc -l
# --- Paso 3: enviar SIGCHLD para que el padre llame a wait() ---
# SIGCHLD notifica al padre que un hijo cambió de estado.
# Un handler correcto llama a waitpid() en bucle con WNOHANG.
# El padre de este demo no tiene handler, así que SIGCHLD no lo ayudará —
# lo dejamos para ilustrar el caso en que SÍ funciona.
echo ""
echo "[acción] Enviando SIGCHLD al padre (útil cuando el padre tiene handler)..."
kill -SIGCHLD "$PADRE_PID" 2>/dev/null || true
sleep 1
echo "[verificación] Zombies tras SIGCHLD:"
ps aux | awk '$8 == "Z"' | wc -l
# --- Paso 4: matar al padre — systemd (PID 1) adoptará y limpiará ---
echo ""
echo "[acción] Matando al padre ($PADRE_PID)..."
kill "$PADRE_PID" 2>/dev/null || true
# Le damos un ciclo al kernel para que PID 1 recoja a los huérfanos
sleep 2
echo "[verificación] Zombies tras matar al padre:"
ps aux | awk '$8 == "Z"' | wc -l
echo ""
echo "[fin] Comprueba /proc/sys/kernel/pid_max en sistemas con límites ajustados:"
cat /proc/sys/kernel/pid_max
Lo que ocurre debajo de cada decisión
El bucle spawn_and_forget sin waitpid() es exactamente el error que aparece en daemons mal escritos: hacen fork() + exec() para lanzar tareas pero olvidan recoger el resultado. os._exit(42) en el hijo evita que Python ejecute sus atexit handlers y el __del__ del intérprete —importante en demos de bajo nivel para no contaminar el estado del padre.
ps --ppid "$PADRE_PID" -o pid,ppid,stat,comm filtra directamente por padre. La columna STAT mostrará Z o Z+ (zombie en foreground de su grupo de proceso). Ver eso en producción sobre un daemon es señal inequívoca de que falta wait() en algún punto del ciclo de vida.
La inutilidad de SIGCHLD sin handler es el matiz que más confunde. SIGCHLD es una notificación, no una operación. Enviárselo a un padre que no tiene registrado un handler SA_SIGCHLD que llame a waitpid(..., WNOHANG) en bucle no resuelve nada. La señal se entrega, el proceso la ignora (la acción por defecto de SIGCHLD es ignorar), los zombies permanecen. Donde sí funciona es en daemons con librerías de event loop (libuv, asyncio con loop.add_child_watcher(), etc.) que instalan ese handler explícitamente.
Matar al padre es el remedio de último recurso y el más efectivo en producción cuando no puedes modificar el código del daemon. Al morir el padre, todos sus hijos —incluidos los zombies— quedan huérfanos. El kernel los re-emparenta a PID 1. systemd llama a wait() sobre ellos inmediatamente y las entradas de la tabla de procesos desaparecen. Si el daemon lo gestiona un unit de systemd, un systemctl restart hace exactamente esto de forma controlada.
/proc/sys/kernel/pid_max vale la pena consultarlo en servidores de alta densidad (contenedores, sistemas con muchos workers). El valor por defecto en Debian Bookworm es 4194304 en kernels de 64 bits, suficientemente alto para que los zombies sean un problema de código antes que de límite de sistema. En kernels compilados para 32 bits el límite es 32768, donde una fuga moderada de zombies puede causar fallos reales de fork() en horas.
La herramienta correcta para detectar si un daemon específico tiene este problema en producción no es ps sino strace -e trace=wait4,waitpid -p <PID>: si ves que el proceso lanza clone()/fork() pero nunca aparece la correspondiente wait4(), tienes la prueba directa sin necesidad de esperar a que los zombies se acumulen lo suficiente para ser visibles.
N° 60