Evitar los zombis - Procesos e Hilos
Volver a Procesos e Hilos Bloque 6 - Zombis, huerfanos y demonios - Tema 25 de 43

Tema 25

Evitar los zombis

En el tema anterior viste que son los zombis y por que se acumulan. En este tema veras las cuatro estrategias para evitarlos: esperar a los hijos, usar un manejador de SIGCHLD, ignorar SIGCHLD, y el doble fork. Cada una tiene ventajas y desventajas, y la eleccion depende del contexto.

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:

Siempre recoger a los hijos

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.

estrategia_wait.c

#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.

estrategia_Sigchld.c

#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.

estrategia_ignore.c

#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;
}
terminal - ejecutar estrategia_ignore

$ 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.

doble_fork.c

#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.

El doble fork es el patron de los demonios

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

EstrategiaObtiene código de salida?Bloquea al padre?complejidadPortabilidad
wait en el padreSiSiBajaPOSIX
SIGCHLD + WNOHANGSiNoMediaPOSIX
Ignorar SIGCHLDNoNoMuy bajaLinux/BSD (no POSIX)
Doble forkNoNo (Sólo espera al hijo)MediaPOSIX

La eleccion depende de las necesidades:

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:

  1. El manejador sólo establece un flag: hijos_terminados = 1;
  2. 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.
  3. 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.

patron_seguro.c

#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.

C - configurar subreaper

#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:

doble_fork_demo.c

#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

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

  1. Compila y ejecuta estrategia_wait.c. Verifica que no quedan zombis.
  2. Compila y ejecuta estrategia_ignore.c. Verifica que no quedan zombis.
  3. Compila y ejecuta doble_fork.c. Observa con pstree que los nietos son hijos del proceso 1.
  4. Compila y ejecuta patron_seguro.c. Observa el patron de flag + recogida en el bucle principal.
  5. Escribe un programa que use las 4 estrategias y compare los resultados.
  6. Configura un proceso como subreaper y verifica que adopta a los huerfanos.