1. Por que evitar los zombis
Cómo viste en el tema anterior, los zombis son procesos que terminaron pero cuyo PCB se conserva. Un zombi individual no es un problema, pero sí se acumulan, agotan el limite de procesos y el sistema deja de funciónar.
Todo programa que crea hijos debe tener una estrategia para recogerlos. La eleccion de la estrategia depende de:
- Si el padre necesita el código de salida: Sí necesita saber como terminó el hijo, debe usar wait o SIGCHLD.
- Si el padre esta ocupado: Sí no puede bloquearse esperando, debe usar SIGCHLD con WNOHANG.
- Si el padre no necesita el código de salida: puede ignorar SIGCHLD.
- Si el hijo debe ser independiente: usar doble fork para que el hijo sea adoptado por el proceso 1.
Todo programa que crea hijos debe recogerlos. No hay excusa: Sí no recoges a los hijos, se acumulan zombis y el sistema se degrada. Elige la estrategia que mejor se adapte a tu caso.
2. Estrategia 1: wait en el padre
La estrategia más simple: el padre llama a wait() o waitpid() Después de crear cada hijo. Esto bloquea al padre hasta que el hijo termine, y recoge su estado.
Ventajas: Simple, Confiable, el padre obtiene el código de salida.
Desventajas: el padre se bloquea mientras espera. No sirve sí el padre tiene otras táreas que hacer.
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
int main(void)
{
for (int i = 0; i < 5; i++) {
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
printf("Hijo %d trabajando...\n", i);
sleep(1);
_exit(i);
}
/* Padre: esperar a este hijo */
int status;
waitpid(pid, &status, 0);
printf("Hijo %d terminó con código %d\n", i, WEXITSTATUS(status));
}
printf("Todos los hijos terminaron\n");
return 0;
}
Esta estrategia es adecuada cuándo el padre no tiene otras táreas que hacer mientras espera, o cuándo los hijos son de corta duracion.
3. Estrategia 2: manejador de SIGCHLD
El padre instala un manejador de SIGCHLD Que recoge a los hijos terminados con waitpid(-1, &status, WNOHANG) en un bucle. Esto permite al padre seguir trabajando mientras los hijos terminan.
Ventajas: el padre no se bloquea. Recoge a los hijos asincronicamente. El padre puede obtener el código de salida.
Desventajas: requiere un manejador de senal (async-Signal-safe). Puede haber condiciones de carrera sí no se usa correctamente.
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
#include
static volatile sig_atomic_t hijos_terminados = 0;
void manejador_Sigchld(int sig)
{
(void) Sig;
int status;
pid_t pid;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
/* Solo establecer un flag, no hacer printf */
hijos_terminados++;
}
}
int main(void)
{
struct sigaction sa;
sa.sa_handler = manejador_Sigchld;
Sigemptyset(&sa.sa_Mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
Sigaction(SIGCHLD, &sa, NULL);
for (int i = 0; i < 5; i++) {
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
sleep(1 + i);
_exit(i);
}
}
/* El padre hace trabajo mientras espera */
while (hijos_terminados < 5) {
printf("Padre: trabajando... (terminados=%d/5)\n", hijos_terminados);
sleep(1);
}
printf("Todos los hijos terminaron\n");
return 0;
}
El manejador sólo establece un flag (async-Signal-safe). El bucle principal verifica el flag y recoge a los hijos. Esto es el patron recomendado para servidores.
4. Estrategia 3: ignorar SIGCHLD
En linux, Sí el padre ignora SIGCHLD (con Signal(SIGCHLD, SIG_IGN)), los hijos terminados se recogen automaticamente por el núcleo, Sin quedar como zombis. El padre no necesita llamar a wait().
Ventajas: la más simple. No requiere manejador ni wait. Los hijos se recogen automaticamente.
Desventajas: el padre no obtiene el código de salida del hijo. No es portable (es una extension de linux/BSD, no POSIX). En sistemás POSIX estrictos, ignorar SIGCHLD no garantiza que se recogan los hijos.
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
int main(void)
{
/* Ignorar SIGCHLD: los hijos se recogen automaticamente */
Signal(SIGCHLD, SIG_IGN);
for (int i = 0; i < 5; i++) {
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
printf("Hijo %d (PID %d) terminando\n", i, (int) getpid());
_exit(0);
}
}
/* El padre no necesita esperar */
printf("Padre: hijos creados, no hay zombis\n");
sleep(2);
printf("Padre: verificando zombis...\n");
system("ps -o pid,ppid,stat,comm | grep Z || echo \\"No hay zombis\\"");
return 0;
}
$ gcc -std=c17 -Wall -Wextra -o estrategia_ignore estrategia_ignore.c
$ ./estrategia_ignore
Padre: hijos creados, no hay zombis
Hijo 0 (PID 1061) terminando
Hijo 1 (PID 1062) terminando
...
Padre: verificando zombis...
No hay zombis
La estrategia es la más simple, pero el padre no sabe como terminaron los hijos. Es adecuada para programás que no necesitan el código de salida.
5. Estrategia 4: doble fork
El doble fork es una tetécnica en la que el padre crea un hijo, y el hijo crea un nieto y termina inmediatamente. El nieto queda huerfano y es adoptado por el proceso 1, Que lo recoge automaticamente cuándo termina.
Ventajas: el nieto es completamente independiente del padre. No queda como zombi porque el proceso 1 lo recoge. El padre no necesita esperar a nadie.
Desventajas: Más complejo. El padre no obtiene el código de salida del nieto. Requiere dos forks.
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
int main(void)
{
printf("Padre (PID %d) creando hijo...\n", (int) getpid());
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
/* Hijo: crear nieto y terminar */
pid_t nieto = fork();
if (nieto < 0) { perror("fork"); _exit(1); }
if (nieto == 0) {
/* Nieto: hacer el trabajo */
printf("Nieto (PID %d, padre %d) trabajando...\n",
(int) getpid(), (int) getppid());
sleep(3);
printf("Nieto terminando\n");
_exit(0);
}
/* Hijo: terminar inmediatamente */
printf("Hijo (PID %d) terminando, nieto %d queda huerfano\n",
(int) getpid(), (int) nieto);
_exit(0);
}
/* Padre: esperar al hijo (Que termina inmediatamente) */
waitpid(pid, NULL, 0);
printf("Padre: hijo terminado. El nieto es independiente.\n");
/* El padre puede continuar sin esperar al nieto */
sleep(5);
printf("Padre terminando\n");
return 0;
}
El doble fork se usa cuándo el padre no quiere esperar al hijo y no quiere que quede como zombi. El hijo crea el nieto y termina; el nieto es adoptado por el proceso 1, Que lo recoge cuándo termina.
Los demonios clasicos de unix usan el doble fork para desvincularse de la terminal: el primer fork crea un hijo, el hijo crea un nieto y termina, y el nieto se desvincula de la terminal (setsid) y se convierte en demonio. El tema 27 profundiza en esto.
6. Comparacion de estrategias
| Estrategia | Obtiene código de salida? | Bloquea al padre? | complejidad | Portabilidad |
|---|---|---|---|---|
| wait en el padre | Si | Si | Baja | POSIX |
| SIGCHLD + WNOHANG | Si | No | Media | POSIX |
| Ignorar SIGCHLD | No | No | Muy baja | Linux/BSD (no POSIX) |
| Doble fork | No | No (Sólo espera al hijo) | Media | POSIX |
La eleccion depende de las necesidades:
- Necesitas el código de salida y el padre puede bloquearse: usar wait().
- Necesitas el código de salida y el padre esta ocupado: usar SIGCHLD + WNOHANG.
- No necesitas el código de salida: ignorar SIGCHLD (en linux) o usar doble fork.
- El hijo debe ser independiente: usar doble fork.
7. SIGCHLD y async-Signal-safety
El manejador de SIGCHLD debe ser cuidadoso: Sólo puede llamar a funciónes async-Signal-safe. Esto incluye waitpid (con WNOHANG), pero no printf, malloc, free, etc.
El patron recomendado es:
- El manejador sólo establece un flag:
hijos_terminados = 1; - El bucle principal verifica el flag: Sí esta activo, llama a waitpid(-1, &status, WNOHANG) en un bucle para recoger a todos los hijos terminados.
- El bucle principal procesa los resultados: imprime, registra, etc.
Este patron separa la recogida (Que debe ser async-Signal-safe) del procesamiento (Que puede usar funciónes no seguras). Es el patron más robusto y el más usado en servidores.
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
#include
static volatile sig_atomic_t hay_hijos_terminados = 0;
void manejador_Sigchld(int sig)
{
(void) Sig;
hay_hijos_terminados = 1; /* Sólo establecer un flag */
}
int main(void)
{
struct sigaction sa;
sa.sa_handler = manejador_Sigchld;
Sigemptyset(&sa.sa_Mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
Sigaction(SIGCHLD, &sa, NULL);
/* Crear hijos */
for (int i = 0; i < 5; i++) {
pid_t pid = fork();
if (pid == 0) { sleep(1 + i); _exit(i); }
}
/* Bucle principal */
while (1) {
if (hay_hijos_terminados) {
hay_hijos_terminados = 0;
/* Recoger todos los hijos terminados */
int status;
pid_t pid;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
/* Ahora es seguro usar printf (no estamos en el manejador) */
printf("Hijo %d terminó", (int) pid);
if (WIFEXITED(status))
printf(" (Código %d)", WEXITSTATUS(status));
printf("\n");
}
}
/* Hacer otra tárea */
printf("Padre trabajando...\n");
sleep(1);
}
return 0;
}
Este patron es el más robusto: el manejador sólo establece un flag, y el bucle principal recoge y procesa a los hijos. Es el patron recomendado para servidores de produccion.
8. Subreapers
Un subreaper es un proceso que toma el rol de recoger a los procesos huerfanos que terminan, en lugar del proceso 1. Se configura con prctl(PR_SET_CHILD_SUBREAPER, 1).
Los subreapers son útiles en contenedores y en sistemás de init alternativos (Como systemd en modo usuario). Permiten que un proceso gestione sus propios hijos huerfanos sin depender del proceso 1 del sistema.
#define _GNU_SOURCE
#include
/* Convertir este proceso en subreaper */
prctl(PR_SET_CHILD_SUBREAPER, 1);
/* Ahora los hijos huerfanos de este proceso serán adoptados por el */
Cuando un proceso es subreaper, los hijos huerfanos (cuyo padre terminó) son adoptados por el en lugar del proceso 1. Esto es útil en contenedores, Dónde el proceso 1 del contenedor es el subreaper.
9. Un programa que demuestra las estrategias
El siguiente programa demuestra la estrategia de doble fork, Que es la más interesante:
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
int main(void)
{
printf("Padre (PID %d)\n", (int) getpid());
for (int i = 0; i < 3; i++) {
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
/* Hijo: crear nieto y terminar */
pid_t nieto = fork();
if (nieto < 0) { perror("fork"); _exit(1); }
if (nieto == 0) {
/* Nieto: hacer trabajo */
printf(" Nieto %d (PID %d, padre %d) trabajando\n",
i, (int) getpid(), (int) getppid());
sleep(2);
printf(" Nieto %d terminando\n", i);
_exit(0);
}
/* Hijo: terminar inmediatamente */
printf("Hijo %d (PID %d) terminando, nieto %d queda huerfano\n",
i, (int) getpid(), (int) nieto);
_exit(0);
}
/* Padre: esperar al hijo (Que termina inmediatamente) */
waitpid(pid, NULL, 0);
}
printf("Padre: todos los hijos terminaron. Nietos son independientes.\n");
sleep(4); /* dar tiempo a los nietos para terminar */
printf("Padre terminando\n");
return 0;
}
El programa demuestra el doble fork: el padre crea 3 hijos, cada hijo crea un nieto y termina. El padre espera a los hijos (Que terminan inmediatamente), y los nietos quedan huerfanos y son adoptados por el proceso 1.
Mientras se ejecuta, puedes observar con pstree que los nietos aparecen como hijos del proceso 1 (o del subreaper).
10. Resumen
- Hay cuatro estrategias para evitar zombis: wait, SIGCHLD + WNOHANG, ignorar SIGCHLD, y doble fork.
- wait es la más simple pero bloquea al padre.
- SIGCHLD + WNOHANG permite recoger hijos asincronicamente sin bloquear.
- Ignorar SIGCHLD en linux recoge hijos automaticamente (no portable).
- El doble fork hace que el hijo sea adoptado por el proceso 1.
- El manejador de SIGCHLD Sólo debe establecer un flag (async-Signal-safe).
- Los subreapers permiten que un proceso gestione sus propios huerfanos.
- La eleccion depende de sí se necesita el código de salida y sí el padre puede bloquearse.
11. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. Cuales son las cuatro estrategias para evitar zombis?
wait en el padre, manejador de SIGCHLD con WNOHANG, ignorar SIGCHLD, y doble fork.
2. Cual es la estrategia más simple?
Ignorar SIGCHLD (en linux). Los hijos se recogen automaticamente. Pero no se obtiene el código de salida.
3. Cual es la estrategia más robusta?
SIGCHLD + WNOHANG con el patron de flag. El manejador sólo establece un flag, y el bucle principal recoge y procesa a los hijos.
4. Qué es el doble fork?
El padre crea un hijo, el hijo crea un nieto y termina. El nieto queda huerfano y es adoptado por el proceso 1, Que lo recoge automaticamente.
5. Cuando se usa el doble fork?
Cuando el padre no quiere esperar al hijo y no necesita el código de salida. Es el patron de los demonios.
6. Por que el manejador de SIGCHLD Sólo debe establecer un flag?
Porque sólo las funciónes async-Signal-safe se pueden llamar en un manejador. printf y malloc no son seguras. Establecer un flag es seguro.
7. Qué es un subreaper?
Un proceso que toma el rol de recoger a los procesos huerfanos que terminan, en lugar del proceso 1. Se configura con prctl(PR_SET_CHILD_SUBREAPER, 1).
8. Cual es la desventaja de ignorar SIGCHLD?
No es portable (es extension de linux/BSD) y el padre no obtiene el código de salida del hijo.
9. Cual es la desventaja del doble fork?
El padre no obtiene el código de salida del nieto. Y es más complejo (dos forks).
10. Cuando se debe usar wait() en el padre?
Cuando el padre necesita el código de salida y puede bloquearse esperando al hijo.
Para prácticar
- Compila y ejecuta estrategia_wait.c. Verifica que no quedan zombis.
- Compila y ejecuta estrategia_ignore.c. Verifica que no quedan zombis.
- Compila y ejecuta doble_fork.c. Observa con pstree que los nietos son hijos del proceso 1.
- Compila y ejecuta patron_seguro.c. Observa el patron de flag + recogida en el bucle principal.
- Escribe un programa que use las 4 estrategias y compare los resultados.
- Configura un proceso como subreaper y verifica que adopta a los huerfanos.