Almacenamiento local a hilo con _Thread_local y tss_t en C11

El almacenamiento local a hilo (Thread-Local Storage o TLS) permite que cada hilo de ejecución posea su propia instancia privada de una variable, incluso si la variable parece ser global. Esto permite segmentar el estado para evitar la contención de memoria y la necesidad de utilizar mutexes cuando el acceso a un dato es lógicamente privado para un único hilo.

En C11, existen dos mecanismos para lograr esto: el especificador _Thread_local y la gestión de claves mediante tss_t de la biblioteca <threads.h> [C11]. El primero es un mecanismo estático, donde el compilador y el enlazador reservan espacio en un segmento de memoria especial (como .tdata o .tbss en el formato ELF) que se mapea de forma única para cada hilo al crearse. El segundo es un mecanismo dinámico basado en claves (keys), donde el programa asocia un puntero void * a una clave específica durante la ejecución.

Se debe usar _Thread_local para variables de tipo primitivo o estructuras simples que tengan un ciclo de vida vinculado a la existencia del hilo y que se inicialicen con constantes. Por el contrario, se debe emplear tss_t cuando necesites gestionar punteros a memoria dinámica (heap) que requieran una función de limpieza (destructor) para evitar fugas de memoria al terminar el hilo. Si utilizas _Thread_local para almacenar un puntero y el hilo termina, el puntero se perderá y la memoria apuntada nunca será liberada. Si usas tss_t de forma incorrecta, como guardar un puntero a una variable local en el stack de una función, el destructor intentará liberar memoria que no pertenece al heap, provocando un error de segmentación o corrupción del heap.

Cuando el compilador procesa _Thread_local, no genera una única dirección de memoria global, sino que utiliza un offset relativo al puntero de control del hilo (como el FS o GS segment en x86_64). Esto es lo que permite que implementaciones como errno en glibc funcionen de forma segura: errno no es una variable global real, sino una macro que llama a __errno_location(), la cual retorna la dirección de una variable local a cada hilo, permitiendo que cada hilo tenga su propio código de error sin interferencias.

#include <threads.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>

/* Estructura de datos dinámica para demostrar el uso de destructores */
typedef struct {
    char *name;
    int id;
} ThreadContext;

/* 
 * _Thread_local: Cada hilo tendrá su propia copia de este contador.
 * Se inicializa en el segmento de datos del hilo.
 */
_Thread_local int thread_local_counter = 0;

/* 
 * Clave para el Thread-Specific Storage (TSS).
 * Permite asociar punteros dinámicos con un destructor.
 */
tss_t context_key;

/* Destructor para tss_t: Se ejecuta automáticamente al terminar el hilo. */
void context_destructor(void *data) {
    ThreadContext *ctx = (ThreadContext *)data;
    if (ctx) {
        printf("[Destructor] Liberando contexto de hilo ID %d\n", ctx->id);
        free(ctx->name);
        free(ctx);
    }
}

int worker_thread(void *arg) {
    int id = *(int *)arg;
    free(arg); // Limpieza del argumento pasado por el hilo principal

    // 1. Uso de _Thread_local
    thread_local_counter++;
    printf("[Hilo %d] Contador local: %d\n", id, thread_local_counter);

    // 2. Uso de tss_t para memoria dinámica
    ThreadContext *ctx = malloc(sizeof(ThreadContext));
    if (!ctx) return -1;

    ctx->id = id;
    ctx->name = malloc(20 * sizeof(char));
    snprintf(ctx->name, 20, "Buffer-Hilo-%d", id);

    /* Asociamos el puntero a la clave de TSS */
    if (tss_set(context_key, ctx) != thrd_success) {
        free(ctx->name);
        free(ctx);
        return -1;
    }

    printf("[Hilo %d] Contexto asignado: %s\n", id, tss_get(context_key)->name);
    
    // Simulamos trabajo...
    thread_local_counter++;
    printf("[Hilo %d] Contador local tras incremento: %d\n", id, thread_local_counter);

    return thrd_success;
}

int main(void) {
    /* 
     * Creamos la clave de TSS con un destructor. 
     * Esto es vital para evitar memory leaks en entornos multihilo.
     */
    if (tss_create(&context_key, context_destructor) != thrd_success) {
        fprintf(stderr, "Error al crear tss_t\n");
        return 1;
    }

    thrd_t threads[3];
    for (int i = 0; i < 3; i++) {
        int *id_arg = malloc(sizeof(int));
        *id_arg = i + 1;
        if (thrd_create(&threads[i], worker_thread, id_arg) != thrd_success) {
            fprintf(stderr, "Error al crear el hilo %d\n", i);
            return 1;
        }
    }

    for (int i = 0; i < 3; i++) {
        thrd_join(threads[i], NULL);
    }

    /* 
     * Importante: tss_delete elimina la clave. 
     * No destruye los datos de los hilos existentes (ya terminaron), 
     * pero es necesario para limpiar la propia clave en el runtime.
     */
    tss_delete(context_key);

    printf("Proceso principal finalizado.\n");
    return 0;
}

Desglose del ejemplo

En el código, hemos gestionado dos tipos de estados distintos. Primero, thread_local_counter utiliza el especificador _Thread_local. Al ser un int con almacenamiento estático, el compilador asegura que cada hilo tenga su propio espacio en memoria sin necesidad de intervención manual; no requiere malloc ni free, y su ciclo de vida es automático.

Segundo, para el ThreadContext, que es una estructura dinámica en el heap, hemos utilizado tss_t. Como _Thread_local no tiene capacidad para ejecutar funciones de limpieza, si hubiéramos declarado _Thread_local ThreadContext *ctx, al terminar el hilo el puntero se perdería pero la memoria en el heap quedaría huérfana. Al usar tss_create(&context_key, context_destructor), le hemos dicho al runtime de C: “cada vez que un hilo que usó esta clave termine, llama a context_destructor con el puntero almacenado”.

Dentro de worker_thread, tss_set vincula la instancia de ThreadContext al hilo actual mediante la clave global context_key. tss_get recupera ese puntero específico para el hilo que lo solicita. Finalmente, en main, llamamos a tss_delete. Es crucial entender que tss_delete no es el destructor de los datos, sino el borrado de la “llave” en el sistema de gestión de la biblioteca.

El error frecuente

Un error común al usar tss_t es intentar almacenar la dirección de una variable automática (en el stack) en lugar de un puntero a memoria dinámica.

// ERROR FATAL: Uso de variable en el stack para TSS
int worker_error(void *arg) {
    int mi_variable_local = 42;
    // ERROR: tss_set guarda la dirección de la variable en el stack
    tss_set(context_key, &mi_variable_local); 
    return thrd_success;
}
// Cuando el hilo termina, el destructor intentará hacer 'free()' 
// sobre una dirección de la pila, provocando un crash inmediato.

Este error es particularmente difícil de detectar porque el programa puede parecer funcionar si el hilo es muy corto, pero fallará de forma intermitente o causará una corrupción de memoria catastrófica. Herramientas como AddressSanitizer (ASan) detectarán esto como un error de invalid-free o stack-use-after-scope. Siempre asegúrate de que el puntero pasado a tss_set sea una dirección de memoria en el heap si vas a usar un destructor que llame a free.

97

Dejar un comentario

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

Scroll al inicio