Manejo seguro de señales: sig_atomic_t y reentrancia

Cuando una señal llega al proceso, el kernel suspende la ejecución del flujo actual de instrucciones para saltar al manejador de señales (signal handler). Si el hilo fue interrumpido justo cuando estaba modificando una estructura de datos interna de una biblioteca estándar —como la lista de bloques de malloc o los buffers de stdout—, esa estructura queda en un estado inconsistente o bloqueada. Si el manejador intenta usar esa misma función, el programa entrará en un deadlock (bloqueo mutuo) o sufrirá una corrupción de memoria.

Para evitar esto, las funciones en C se clasifican en async-signal-safe (seguras para señales asíncronas) y no seguras. Solo un subconjunto muy limitado de funciones de la biblioteca estándar son seguras; printf y malloc no lo son. El patrón correcto para comunicar un evento de señal al programa principal no es realizar acciones complejas dentro del manejador, sino simplemente cambiar el estado de una bandera. Para que esta bandera sea segura, debe declararse como volatile sig_atomic_t. El tipo sig_atomic_t garantiza que la lectura o escritura de la variable sea una operación atómica a nivel de CPU, evitando que el manejador interrumpa una operación de escritura de múltiples bytes (común en arquitecturas de 8/16 bits o con tipos de datos grandes), lo que causaría que el programa lea un valor corrupto o “mitad escrito”.

#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

/* 
 * volatile: evita que el compilador optimice la lectura, 
 * asumiendo que el valor solo cambia mediante el flujo normal.
 * sig_atomic_t: garantiza que la escritura sea atómica ante interrupciones.
 */
volatile sig_atomic_t keep_running = 1;

/* 
 * El manejador debe ser lo más mínimo posible.
 * No llamar a printf, malloc, ni ninguna función no async-signal-safe.
 */
void handle_sigint(int sig) {
    (void)sig; // Evitamos el warning por parámetro no usado
    keep_running = 0; // Operación atómica y segura
}

int main(void) {
    struct sigaction sa;

    /* Configuración de sigaction [POSIX] */
    sa.sa_handler = handle_sigint;
    sigemptyset(&sa.sa_mask);
    
    /* 
     * SA_RESTART [POSIX] hace que las llamadas al sistema bloqueantes
     * (como read o sleep) se reinicien automáticamente después de
     * recibir una señal, evitando que devuelvan -1 con errno EINTR.
     */
    sa.sa_flags = SA_RESTART;

    if (sigaction(SIGINT, &sa, NULL) == -1) {
        perror("Error al configurar sigaction");
        return EXIT_FAILURE;
    }

    printf("Proceso activo. Pulsa Ctrl+C para detenerlo de forma limpia.\n");

    while (keep_running) {
        printf("Ejecutando tarea crítica...\n");
        
        // sleep() es una llamada al sistema; SA_RESTART manejará la interrupción
        sleep(2); 
    }

    /* 
     * Usamos _Exit() [C11] si quisiéramos salir de inmediato sin 
     * llamar a los manejadores de atexit(), pero aquí salimos normal.
     */
    printf("\nSeñal recibida. Limpiando recursos y saliendo...\n");

    return EXIT_SUCCESS;
}

Análisis del código

En el ejemplo, hemos utilizado sigaction [POSIX] en lugar del antiguo signal para garantizar un comportamiento consistente entre sistemas. Una diferencia crítica es el uso del flag SA_RESTART. Sin este flag, cuando sleep(2) es interrumpido por SIGINT, la función devolvería un error con errno seteado en EINTR, lo que podría romper la lógica de control del bucle si no se gestionara explícitamente.

La variable keep_running es el corazón de la comunicación segura. Al ser volatile, le indicamos al compilador que no debe asumir que el valor de keep_running permanece constante en un registro de la CPU durante el bucle while. Si no fuera volatile, el compilador podría optimizar el bucle leyendo el valor una sola vez al principio y guardándolo en un registro, ignorando por completo los cambios realizados por el manejador de señales en la memoria real. El uso de sig_atomic_t asegura que el paso de 1 a 0 sea visto por el hilo principal como una unidad indivisible, impidiendo que una interrupción ocurra en un punto medio de la escritura de la variable.

El error frecuente

El error más común en sistemas de alta disponibilidad es intentar usar printf o malloc dentro del manejador.

// CODIGO CON UNDEFINED BEHAVIOR
void bad_handler(int sig) {
    // ERROR: printf no es async-signal-safe. 
    // Si main() estaba ejecutando un printf, el mutex de stdout 
    // ya está bloqueado, y aquí el programa se quedará colgado (deadlock).
    printf("Señal recibida: %d\n", sig); 
}

Si ocurre este error, el programa no necesariamente fallará con un crash inmediato que sea capturado por AddressSanitizer; más bien, el proceso se quedará congelado indefinidamente. Los bloqueos por falta de reentrancia son de los errores más difíciles de depurar en producción porque dependen de un “timing” extremadamente preciso entre la interrupción de la señal y el estado interno de la biblioteca estándar.

101

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio