Volver a Procesos e Hilos Bloque 8 - Hilos en profundidad - Tema 36 de 43

Tema 36

Hilos, fork, exec y señales

Los hilos y los procesos interactúan de formas complejas. ¿Qué pasa cuando un programa con hilos llama a fork? ¿Y a exec? ¿Qué hilo recibe una señal? En este tema verás las reglas que gobiernan estas interacciones, y cómo manejar señales en programas multihilo.

1. El problema: hilos y procesos

Los hilos comparten el espacio de direcciones de un proceso. Los procesos son independientes. Cuando un programa con hilos interactúa con el sistema de procesos (fork, exec, señales), surgen preguntas: ¿qué pasa con los hilos? ¿cuáles se conservan? ¿cuáles se pierden? ¿quién recibe las señales?

Estas interacciones son complejas y propensas a errores. Un programa que combina hilos con fork o señales debe seguir reglas estrictas para evitar comportamientos indefinidos.

La regla de oro

En un programa con hilos, llamar a fork() es peligroso. Si es posible, evitar fork() en programas con hilos. Si es necesario, usar pthread_atfork() para preparar el estado, y llamar a exec() inmediatamente después de fork() en el hijo.

2. fork en un programa con hilos

Cuando un proceso con múltiples hilos llama a fork(), solo el hilo que llamó a fork() sobrevive en el hijo. Los demás hilos desaparecen. Esto es una consecuencia de que fork crea un proceso nuevo con una sola línea de ejecución.

El hijo recibe:

El hijo NO recibe:

Esto significa que si el padre tenía un mutex bloqueado por un hilo que no es el que llamó a fork(), ese mutex queda bloqueado para siempre en el hijo. Si el hijo intenta tomar ese mutex, se bloquea indefinidamente (deadlock).

3. Qué pasa con los hilos después de fork

Después de fork en un programa con hilos:

  1. El hijo tiene un solo hilo: el que llamó a fork(). Los demás hilos no existen.
  2. Los mutex bloqueados por otros hilos quedan bloqueados: si un mutex estaba bloqueado por un hilo que no sobrevivió, queda bloqueado para siempre.
  3. Las variables thread-local se copian: el hijo tiene su propia copia de las variables _Thread_local.
  4. Los datos de pthread_key se copian: el hijo tiene los mismos valores para las claves.
  5. Los descriptores se comparten: como en fork normal.

Por eso, la recomendación es: después de fork en un programa con hilos, llamar a exec inmediatamente. No hacer otra cosa (ni lock de mutex, ni malloc, ni printf). exec reemplaza el programa y los problemas de los hilos desaparecen.

No hacer nada entre fork y exec en un programa con hilos

Entre fork y exec en un programa con hilos, solo se deben hacer operaciones async-signal-safe y preparaciones simples (dup2, close, chdir). No se deben llamar a malloc, printf, lock de mutex, ni ninguna función que use estado compartido.

4. El problema del mutex bloqueado

El problema más grave de fork en un programa con hilos es el de los mutex bloqueados. Considera este escenario:

  1. Hilo A: toma un mutex y se bloquea en una operación (por ejemplo, read).
  2. Hilo B: llama a fork(). Solo B sobrevive en el hijo.
  3. En el hijo: el mutex está bloqueado (porque A lo tomó), pero A no existe. Si el hijo intenta tomar el mutex, se bloquea indefinidamente.

Este problema es inevitable si hay mutex bloqueados por hilos que no sobreviven al fork. La solución es:

5. pthread_atfork: preparar para fork

pthread_atfork() registra funciones que se ejecutan antes y después de fork. Permite preparar el estado de los mutex para que el hijo no herede mutex bloqueados.

C - firma de pthread_atfork

#include 

int pthread_atfork(void (*prepare)(void),
                   void (*parent)(void),
                   void (*child)(void));

Los parámetros son:

pthread_atfork_demo.c

#define _POSIX_C_SOURCE 200809L
#include 
#include 
#include 
#include 

static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void prepare(void) { pthread_mutex_lock(&mutex); }
void parent(void) { pthread_mutex_unlock(&mutex); }
void child(void) { pthread_mutex_unlock(&mutex); }

int main(void)
{
    pthread_atfork(prepare, parent, child);

    /* Tomar el mutex en el hilo principal */
    pthread_mutex_lock(&mutex);

    pid_t pid = fork();
    if (pid < 0) { perror("fork"); return 1; }

    if (pid == 0) {
        /* Hijo: el mutex está desbloqueado (por child()) */
        printf("Hijo: mutex disponible\n");
        pthread_mutex_lock(&mutex);  /* puede tomarlo */
        printf("Hijo: mutex tomado, liberando...\n");
        pthread_mutex_unlock(&mutex);
        _exit(0);
    }

    /* Padre: el mutex sigue bloqueado (por parent()) */
    pthread_mutex_unlock(&mutex);  /* desbloquear */
    wait(NULL);
    return 0;
}

pthread_atfork asegura que el hijo no hereda mutex bloqueados. Pero es complejo de usar correctamente y tiene overhead. Por eso, la recomendación sigue siendo: evitar fork en programas con hilos.

pthread_atfork es frágil

pthread_atfork solo protege los mutex que se registran. Si una biblioteca usa mutex internos sin registrar atfork handlers, el hijo puede heredarlos bloqueados. Esto es un problema conocido con bibliotecas como malloc (que usa mutex internos).

6. exec en un programa con hilos

Cuando un proceso con hilos llama a exec(), todos los hilos se terminan y el programa se reemplaza. Esto es porque exec reemplaza el programa del proceso, y los hilos son parte del proceso.

Después de exec, solo queda el nuevo programa, con un solo hilo (el que llamó a exec). Los demás hilos desaparecen. Los mutex, las variables thread-local, y los datos de pthread_key se pierden (porque el programa se reemplaza).

Esto es seguro: exec no tiene los problemas de fork con hilos, porque no se conserva el estado de los hilos. El nuevo programa empieza desde cero.

Por eso, el patrón fork + exec es seguro en programas con hilos: fork crea un hijo con un solo hilo, y exec reemplaza el programa. Los problemas de fork con hilos (mutex bloqueados) desaparecen con exec.

exec es seguro con hilos

A diferencia de fork, exec es seguro en programas con hilos. Todos los hilos se terminan y el programa se reemplaza. No hay mutex bloqueados ni estado inconsistente.

7. Señales y hilos

Las señales y los hilos interactúan de forma compleja. En un programa multihilo, las señales se entregan a un hilo arbitrario del proceso, no a todos los hilos. Esto puede causar problemas si el hilo que recibe la señal no está preparado para manejarla.

Las reglas son:

Por eso, en un programa multihilo, es importante gestionar las señales correctamente:

Los manejadores de señales son compartidos

Los manejadores de señales se comparten entre todos los hilos del proceso. Si un hilo instala un manejador, todos los hilos lo usan. Esto puede causar problemas si el manejador usa datos que no son thread-safe.

8. Qué hilo recibe cada señal

La entrega de señales en un programa multihilo sigue estas reglas:

Tipo de señalSeñalQué hilo la recibe
SíncronaSIGSEGV, SIGFPE, SIGILL, SIGBUSEl hilo que causó la señal.
AsíncronaSIGTERM, SIGINT, SIGUSR1, SIGUSR2Un hilo arbitrario que no la tenga bloqueada.
De hijoSIGCHLDUn hilo arbitrario (típicamente el que no la bloqueó).
De temporizadorSIGALRM, SIGVTALRMEl hilo que configuró el temporizador.

Si todos los hilos bloquean una señal, la señal queda pendiente y se entrega cuando algún hilo la desbloquee. Si ningún hilo la desbloquea, la señal no se entrega nunca.

Por eso, la práctica recomendada es:

  1. Bloquear las señales asíncronas en todos los hilos: con pthread_sigmask en main, antes de crear hilos.
  2. Crear un hilo dedicado que llame a sigwait: este hilo recibe y maneja las señales.
  3. No usar manejadores de señales: usar sigwait en lugar de signal/sigaction.

9. pthread_kill: enviar señal a un hilo

pthread_kill() envía una señal a un hilo específico. Es el equivalente de kill() para hilos.

C - firma de pthread_kill

#include 
#include 

int pthread_kill(pthread_t thread, int sig);

Devuelve 0 si tiene éxito, o un código de error (ESRCH: hilo no existe, EINVAL: señal inválida).

Si sig es 0, no se envía ninguna señal, pero se verifica si el hilo existe. Es útil para verificar si un hilo sigue vivo.

pthread_kill envía la señal al hilo especificado. Si el hilo tiene la señal bloqueada, la señal queda pendiente. Si no, se entrega inmediatamente (o se ejecuta el manejador si hay uno).

pthread_kill_demo.c

#define _POSIX_C_SOURCE 200809L
#include 
#include 
#include 
#include 
#include 

void *trabajo(void *arg)
{
    int id = (int)(long) arg;
    printf("Hilo %d: esperando señal...\n", id);
    while (1) pause();  /* esperar señal */
    return NULL;
}

int main(void)
{
    pthread_t hilo;
    pthread_create(&hilo, NULL, trabajo, (void *)(long) 1);

    sleep(1);
    printf("Main: enviando SIGUSR1 al hilo...\n");
    pthread_kill(hilo, SIGUSR1);

    sleep(1);
    printf("Main: cancelando el hilo...\n");
    pthread_cancel(hilo);
    pthread_join(hilo, NULL);
    return 0;
}

El main envía SIGUSR1 al hilo con pthread_kill. Si el hilo no captura SIGUSR1, la acción por omisión es terminar el proceso. Para evitar esto, se puede bloquear SIGUSR1 en el hilo o instalar un manejador.

10. sigwait: esperar señales en un hilo

sigwait() bloquea al hilo hasta que se recibe una de las señales especificadas. Es la forma recomendada de manejar señales en programas multihilo: en lugar de usar manejadores, se bloquean las señales y se espera con sigwait.

C - firma de sigwait

#include 

int sigwait(const sigset_t *set, int *sig);

El parámetro set es el conjunto de señales a esperar. sig es donde se guarda el número de la señal recibida. sigwait bloquea hasta que se recibe una señal del conjunto.

El patrón recomendado para manejar señales en programas multihilo es:

  1. Bloquear las señales en todos los hilos: con pthread_sigmask(SIG_BLOCK, &set, NULL) en main, antes de crear hilos.
  2. Crear un hilo dedicado que llame a sigwait: este hilo recibe y maneja las señales.
  3. El hilo de señales procesa cada señal: según el número de señal, ejecuta la acción correspondiente.
sigwait_demo.c

#define _POSIX_C_SOURCE 200809L
#include 
#include 
#include 
#include 
#include 

void *manejador_senales(void *arg)
{
    (void) arg;
    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGTERM);
    sigaddset(&set, SIGINT);

    printf("Hilo de señales: esperando SIGTERM o SIGINT...\n");
    while (1) {
        int sig;
        sigwait(&set, &sig);
        if (sig == SIGTERM) {
            printf("Recibí SIGTERM, terminando...\n");
            exit(0);
        } else if (sig == SIGINT) {
            printf("Recibí SIGINT (Ctrl+C)\n");
        }
    }
    return NULL;
}

int main(void)
{
    /* Bloquear SIGTERM y SIGINT en todos los hilos */
    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGTERM);
    sigaddset(&set, SIGINT);
    pthread_sigmask(SIG_BLOCK, &set, NULL);

    /* Crear hilo dedicado para señales */
    pthread_t hilo_senales;
    pthread_create(&hilo_senales, NULL, manejador_senales, NULL);

    /* El main puede hacer otras cosas */
    printf("Main: PID %d, esperando señales...\n", (int) getpid());
    while (1) {
        printf("Main: trabajando...\n");
        sleep(2);
    }
    return 0;
}

El programa bloquea SIGTERM y SIGINT en todos los hilos, y crea un hilo dedicado que espera estas señales con sigwait. El main puede hacer otras cosas mientras el hilo de señales espera.

Este patrón es el recomendado para programas multihilo: un hilo dedicado para señales, y los demás hilos bloquean las señales. Es más seguro que usar manejadores de señales.

11. Un programa que demuestra hilos, fork, exec y señales

El siguiente programa demuestra la interacción entre hilos y fork:

hilos_fork.c

#define _POSIX_C_SOURCE 200809L
#include 
#include 
#include 
#include 
#include 

static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void *trabajo(void *arg)
{
    (void) arg;
    printf("  Hilo (TID %lu) ejecutándose\n", (unsigned long) pthread_self());
    sleep(2);
    printf("  Hilo terminando\n");
    return NULL;
}

int main(void)
{
    printf("Main (PID %d): creando hilo...\n", (int) getpid());

    pthread_t hilo;
    pthread_create(&hilo, NULL, trabajo, NULL);

    sleep(1);
    printf("Main: llamando a fork()...\n");

    pid_t pid = fork();
    if (pid < 0) { perror("fork"); return 1; }

    if (pid == 0) {
        /* Hijo: solo el hilo que llamó a fork sobrevive */
        printf("  Hijo (PID %d): solo un hilo existe\n", (int) getpid());
        /* Llamar a exec inmediatamente (recomendado) */
        execlp("echo", "echo", "Hijo ejecutando echo", NULL);
        perror("exec");
        _exit(127);
    }

    /* Padre: esperar al hijo */
    printf("Main: esperando al hijo...\n");
    waitpid(pid, NULL, 0);
    printf("Main: hijo terminado\n");

    /* Esperar al hilo */
    pthread_join(hilo, NULL);
    printf("Main: hilo terminado\n");
    return 0;
}
terminal - ejecutar hilos_fork

$ gcc -std=c17 -Wall -Wextra -pthread -o hilos_fork hilos_fork.c
$ ./hilos_fork
Main (PID 1090): creando hilo...
  Hilo (TID 1091) ejecutándose
Main: llamando a fork()...
Main: esperando al hijo...
  Hijo (PID 1092): solo un hilo existe
Hijo ejecutando echo
Main: hijo terminado
  Hilo terminando
Main: hilo terminado

El programa demuestra que: el hijo tiene solo un hilo (el que llamó a fork), y llama a exec inmediatamente. El padre espera al hijo y luego al hilo.

12. Resumen

13. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. Que pasa con los hilos cuando se llama a fork?

Solo el hilo que llamó a fork sobrevive en el hijo. Los demás hilos desaparecen.

2. Qué pasa con los mutex bloqueados después de fork?

Los mutex bloqueados por hilos que no sobrevivieron quedan bloqueados para siempre en el hijo. Esto puede causar deadlock.

3. Para qué sirve pthread_atfork?

Registra funciones (prepare, parent, child) que se ejecutan antes y después de fork, para preparar el estado de los mutex.

4. Es exec seguro en programas con hilos?

Sí. Todos los hilos se terminan y el programa se reemplaza. No hay problemas de mutex bloqueados.

5. Qué hilo recibe una señal síncrona (SIGSEGV)?

El hilo que causó la señal.

6. Qué hilo recibe una señal asíncrona (SIGTERM)?

Un hilo arbitrario que no tenga la señal bloqueada.

7. Qué hace pthread_kill?

Envía una señal a un hilo específico. Es el equivalente de kill() para hilos.

8. Que es sigwait?

Una función que bloquea al hilo hasta que se recibe una de las señales especificadas. Es la forma recomendada de manejar señales en programas multihilo.

9. Cuál es el patrón recomendado para señales en programas multihilo?

Bloquear las señales en todos los hilos (pthread_sigmask), y crear un hilo dedicado que llame a sigwait para recibir y manejar las señales.

10. Por qué los manejadores de señales son problemáticos en programas multihilo?

Porque son compartidos por todos los hilos. Si un hilo instala un manejador, todos los hilos lo usan, y puede causar problemas si el manejador usa datos no thread-safe.

Para practicar

  1. Compila y ejecuta hilos_fork.c. Observa que el hijo tiene solo un hilo.
  2. Compila y ejecuta sigwait_demo.c. Envía SIGTERM con kill y observa la respuesta.
  3. Escribe un programa que use pthread_kill para enviar SIGUSR1 a un hilo específico.
  4. Escribe un programa que use pthread_atfork para proteger un mutex.
  5. Escribe un programa que bloquee señales en todos los hilos y las maneje con sigwait.
  6. Investiga por qué malloc no es async-signal-safe y qué problemas causa en fork con hilos.