Cuando el kernel arranca un programa, no lo ejecuta directamente como si fuera un script de teatro con actores nuevos. Lo que hace es crear una instancia de ejecución — un proceso — que tiene su propio espacio de memoria virtual, su tabla de file descriptors (los canales abiertos hacia ficheros, sockets, pipes), su conjunto de señales pendientes, y un contexto de CPU que el planificador puede pausar y reanudar. El binario en disco no cambia; lo que cambia es que el kernel reserva recursos y empieza a ejecutar instrucciones en nombre de ese proceso.
Cada proceso recibe un PID (Process ID), un entero que el kernel asigna de forma incremental y que identifica al proceso de forma única mientras vive. La palabra clave es “mientras”: cuando el proceso muere, ese PID queda disponible para reutilizarse. En un servidor que lleva meses arriba y lanza miles de procesos cortos, es perfectamente normal ver un proceso con PID 312 que no tiene nada que ver con el proceso 312 que existía hace dos horas. Si escribes código que almacena un PID para “encontrar el proceso después”, tienes una carrera potencial ahí.
Todo proceso tiene un PPID (Parent Process ID), porque en Unix los procesos forman un árbol. No existe ningún mecanismo para crear un proceso “desde la nada” en espacio de usuario: la única forma de crear un proceso es que otro proceso llame a fork(). El proceso llamante se clona a sí mismo — memoria, file descriptors, señales — y el resultado son dos procesos casi idénticos que se distinguen únicamente por el valor de retorno de fork(): el padre recibe el PID del hijo, el hijo recibe cero. Esto no es una curiosidad histórica, es el único camino disponible.
Ahora bien, si el hijo fuese una copia exacta del padre, sólo podrías ejecutar el mismo programa una y otra vez. Aquí entra exec(): una familia de llamadas al sistema que reemplaza la imagen del proceso actual (código, datos, heap, stack) con un nuevo programa, manteniendo el mismo PID y la mayoría de los file descriptors. La combinación fork+exec es el modelo universal de “lanzar un programa”: fork crea el proceso, exec lo convierte en algo distinto. La shell hace esto cada vez que ejecutas un comando.
La raíz del árbol es el proceso con PID 1, que en Debian Bookworm es systemd. Es el primero que arranca el kernel (después del proceso de inicialización interna), y todos los demás procesos son sus descendientes directos o indirectos. PID 1 tiene una responsabilidad especial que merece atención: cuando un proceso huérfano (cuyo padre ha muerto) queda sin padre, el kernel lo reasigna automáticamente a PID 1, que debe llamar a wait() para recoger su estado de salida. Si PID 1 no hace esto, el sistema se llena de procesos zombie — procesos que ya han terminado pero cuya entrada en la tabla de procesos permanece porque nadie ha recogido su código de salida. Un zombie no consume CPU ni memoria real, pero sí ocupa un slot en la tabla de procesos y un PID.
Los estados posibles de un proceso son: running (ejecutando instrucciones o en cola del planificador), sleeping interruptible (esperando un evento, se puede interrumpir con una señal), sleeping uninterruptible (esperando una operación de E/S que no puede interrumpirse sin corromperse — típicamente I/O de disco), stopped (pausado con SIGSTOP, no avanza hasta recibir SIGCONT), y zombie (terminado, esperando que el padre llame a wait()).
#!/bin/bash
# Demostración práctica del modelo fork+exec y del árbol de procesos.
# Ejecutar como usuario normal en cualquier Debian Bookworm.
echo "=== PID del shell actual y su padre ==="
echo "PID propio : $$"
echo "PID padre : $PPID"
echo ""
echo "=== Ancestros hasta PID 1 ==="
# ps -o muestra columnas específicas; 'comm' es el nombre del comando sin ruta
pid=$$
while [ "$pid" -gt 1 ]; do
ppid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
comm=$(ps -o comm= -p "$pid" 2>/dev/null)
printf "PID %-6s nombre: %-20s padre: %s\n" "$pid" "$comm" "$ppid"
pid="$ppid"
done
# Imprimimos PID 1 explícitamente
printf "PID %-6s nombre: %-20s (raíz del árbol)\n" "1" "$(ps -o comm= -p 1)"
echo ""
echo "=== Demostración de fork+exec: lanzar un subproceso y recoger su salida ==="
# La shell hace fork() antes de ejecutar cualquier comando externo.
# El hijo resultante llama a exec() con el binario indicado.
# $! contiene el PID del último proceso lanzado en background.
sleep 5 &
hijo_pid=$!
echo "Proceso hijo lanzado: PID $hijo_pid"
# Mientras el hijo duerme, su estado es 'S' (sleeping interruptible)
estado=$(ps -o stat= -p "$hijo_pid" 2>/dev/null)
echo "Estado del hijo mientras duerme: $estado"
# Esperamos a que termine (wait() interno de la shell — evita zombies)
wait "$hijo_pid"
echo "Hijo $hijo_pid terminado, código de salida: $?"
echo ""
echo "=== Zombie breve: proceso que termina antes de que el padre llame a wait() ==="
# Creamos un subshell que termina inmediatamente.
# Durante el intervalo entre su muerte y nuestro wait(), es zombie.
(exit 0) &
zombie_pid=$!
# Pausa mínima para que el hijo termine antes de que la shell llame a wait()
sleep 0.1
# En este instante el proceso puede aparecer como 'Z' en la tabla de procesos.
# En la práctica el intervalo es tan corto que puede no verse, pero el mecanismo es real.
estado_z=$(ps -o stat= -p "$zombie_pid" 2>/dev/null || echo "ya recogido")
echo "Estado del proceso $zombie_pid justo tras terminar: $estado_z"
wait "$zombie_pid"
echo "wait() ejecutado; entrada eliminada de la tabla de procesos"
echo ""
echo "=== Árbol completo desde este shell (dos niveles) ==="
# pstree -p muestra el árbol con PIDs; -s incluye los ancestros del proceso indicado
pstree -p -s $$
El script hace exactamente lo que describe el modelo teórico, y vale la pena fijarse en cada decisión.
$$ y $PPID son variables especiales que la shell rellena con su propio PID y el PID de su proceso padre. No hay ninguna llamada externa; la shell los conoce desde el momento en que arrancó porque el kernel los proporcionó al proceso en su estructura de control.
El bucle que sube por los ancestros usa ps -o ppid= -p <PID>: el campo ppid con -p acepta cualquier PID, no solo procesos del usuario actual. Cuando llegas a PID 1 (systemd), el bucle se detiene porque la condición $pid -gt 1 falla. No existe PID 0 como proceso de usuario; el “proceso 0” es internal al kernel.
El bloque de sleep 5 & demuestra fork+exec desde el punto de vista de la shell. Cuando escribes sleep 5, la shell llama a fork() — ahora hay dos procesos shell — y el hijo llama a execve("/usr/bin/sleep", ["sleep", "5"], ...). El hijo deja de ser una copia de bash y se convierte en el proceso sleep. Durante esos cinco segundos, ps -o stat= devuelve S (sleeping interruptible), porque el proceso está dentro de una llamada a nanosleep() esperando una señal del kernel de tipo “tiempo transcurrido”. La S en mayúscula es el código canónico de ese estado.
El wait "$hijo_pid" al final no es cosmético. La shell bash llama a wait() de todas formas cuando un proceso en background termina, pero en un script donde el proceso padre podría seguir ejecutándose durante tiempo apreciable, no llamar a wait() significa que la entrada del hijo permanece en la tabla de procesos en estado zombie. Aquí lo hacemos explícito para que quede claro el mecanismo.
La demostración del zombie usa (exit 0) & — un subshell que termina de inmediato. El sleep 0.1 crea una ventana temporal en la que el proceso ya ha llamado a _exit() internamente pero el padre (este script) aún no ha ejecutado wait(). En ese instante preciso, el proceso aparece como Z (zombie) en ps. La línea ps -o stat= -p "$zombie_pid" 2>/dev/null || echo "ya recogido" maneja el caso en que el planificador y la shell sean tan rápidos que la shell haya recogido el zombie antes de que lleguemos al ps: no hay condición de error, simplemente el mecanismo fue más veloz que la observación.
Finalmente, pstree -p -s $$ construye visualmente el árbol. La opción -s (show parents) hace que muestre la cadena completa desde PID 1 hasta el script, y -p añade los números de PID entre paréntesis. Verás algo como systemd(1)───sshd(...)───bash(...)───bash(N)───pstree(M), que es el árbol real en disco en ese instante. Cada nivel de ese árbol llegó ahí mediante un fork+exec, sin excepción.
El estado uninterruptible sleep (D en ps) no aparece en este script porque se produce en el kernel durante operaciones de E/S bloqueante — no se puede simular limpiamente desde espacio de usuario sin acceso a disco real bajo carga. Pero si ejecutas ps aux en un servidor con problemas de almacenamiento lento, verás procesos atascados en estado D que no responden a señales, incluido SIGKILL, porque el kernel no les da oportunidad de procesar señales hasta que la operación de E/S se completa o falla.
N° 59