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

Tema 35

Datos propios de cada hilo

Los hilos comparten memoria, lo que facilita la comunicación. Pero a veces un hilo necesita datos que sean propios, que no se compartan con los demás. En este tema verás cómo implementar datos específicos del hilo con _Thread_local en C11 y con pthread_key_create, y por qué errno es distinto en cada hilo.

1. El problema: datos compartidos vs propios

Los hilos comparten memoria, lo que facilita la comunicación. Pero a veces un hilo necesita datos que sean propios: que cada hilo tenga su propia copia, independiente de los demás. Por ejemplo, un contador de errores por hilo, un buffer de trabajo por hilo, o un identificador de hilo.

Si se usa una variable global para esto, todos los hilos comparten la misma variable, y los cambios de uno afectan a los demás. La solución es usar datos específicos del hilo (thread-specific data o thread-local storage).

Hay dos mecanismos para esto en C:

Datos propios sin sincronización

La ventaja de los datos propios del hilo es que no necesitan sincronización: como cada hilo tiene su propia copia, no hay condiciones de carrera. Es la forma más simple de evitar problemas de concurrencia para datos que no necesitan ser compartidos.

2. _Thread_local: variables locales al hilo

_Thread_local es una palabra clave de C11 que declara una variable como local al hilo. Cada hilo tiene su propia copia de la variable, independiente de los demás. Es el mecanismo más simple y el más eficiente para datos propios del hilo.

C - _Thread_local

#include 
#include 

/* Variable thread-local: cada hilo tiene su propia copia */
_Thread_local int contador_hilo = 0;

void *trabajo(void *arg)
{
    int id = (int)(long) arg;
    contador_hilo = id * 10;  /* solo afecta a este hilo */
    printf("Hilo %d: contador = %d\n", id, contador_hilo);
    return NULL;
}

int main(void)
{
    pthread_t hilos[3];
    for (int i = 0; i < 3; i++)
        pthread_create(&hilos[i], NULL, trabajo, (void *)(long)(i + 1));
    for (int i = 0; i < 3; i++)
        pthread_join(hilos[i], NULL);
    printf("Main: contador = %d (no cambió)\n", contador_hilo);
    return 0;
}

En este ejemplo, cada hilo modifica contador_hilo, pero el main ve el valor original (0), porque cada hilo tiene su propia copia.

En C11, también se puede usar la palabra clave thread_local (sin guion bajo) si se incluye <threads.h>. En C++, se usa thread_local directamente.

C - thread_local (C11 con threads.h)

#include 

thread_local int contador_hilo = 0;

En la práctica, _Thread_local es el más usado en C, porque <threads.h> no está disponible en todos los sistemas (especialmente en sistemas que usan pthreads en lugar de los hilos de C11).

3. Como funciona _Thread_local

_Thread_local se implementa usando el mecanismo de TLS (Thread-Local Storage) del sistema operativo y del compilador. El funcionamiento es:

  1. El compilador marca la variable: cuando se declara una variable _Thread_local, el compilador la coloca en una sección especial del ELF (.tdata o .tbss).
  2. El núcleo asigna espacio por hilo: cuando se crea un hilo, el núcleo (o la biblioteca pthreads) reserva espacio para las variables thread-local en la pila o en una región dedicada.
  3. El acceso usa un registro especial: en x86-64, el registro FS (o GS) apunta a la base del TLS del hilo actual. El acceso a una variable _Thread_local se traduce en un acceso relativo a FS, que es diferente para cada hilo.
  4. Cada hilo ve su propia copia: cuando un hilo lee la variable, el hardware usa el registro FS del hilo, que apunta a su propia copia.

El acceso a una variable _Thread_local es ligeramente más lento que el acceso a una variable global normal, porque requiere una indirección a través del registro FS. Pero es mucho más rápido que usar pthread_getspecific, porque no requiere una llamada a función.

En /proc, las variables thread-local no se ven directamente, pero el espacio que ocupan se incluye en el tamaño de la pila o en una región separada del espacio de direcciones.

4. pthread_key_create: claves de datos

pthread_key_create() es la API de POSIX para crear claves de datos específicos del hilo. Una clave es un identificador que permite asociar un puntero a cada hilo. Cada hilo tiene su propio valor para cada clave.

C - firma de pthread_key_create

#include 

int pthread_key_create(pthread_key_t *key, void (*destructor)(void *));
int pthread_key_delete(pthread_key_t key);

La función crea una clave nueva y la guarda en *key. El parámetro destructor es una función que se llama cuando un hilo termina, para liberar los datos asociados a la clave. Si no se necesita destructor, se pasa NULL.

Las claves son globales (compartidas por todos los hilos), pero los valores son por hilo. Es como una tabla hash donde la clave es el identificador y el valor es diferente para cada hilo.

El número de claves que se pueden crear está limitado por PTHREAD_KEYS_MAX (típicamente 1024 en Linux).

terminal - PTHREAD_KEYS_MAX

$ grep PTHREAD_KEYS_MAX /usr/include/pthread.h
#define PTHREAD_KEYS_MAX 1024

5. pthread_setspecific y pthread_getspecific

Una vez creada una clave, se usa pthread_setspecific() para asociar un valor a la clave en el hilo actual, y pthread_getspecific() para obtener el valor.

C - firma de setspecific y getspecific

#include 

int pthread_setspecific(pthread_key_t key, const void *value);
void *pthread_getspecific(pthread_key_t key);

pthread_setspecific asocia value a la clave key en el hilo actual. pthread_getspecific devuelve el valor asociado a la clave en el hilo actual (o NULL si no se ha establecido).

pthread_key_demo.c

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

pthread_key_t clave;

void destructor(void *p)
{
    printf("Destructor: liberando %p\n", p);
    free(p);
}

void *trabajo(void *arg)
{
    int id = (int)(long) arg;

    /* Asignar datos específicos del hilo */
    int *datos = malloc(sizeof *datos);
    *datos = id * 100;
    pthread_setspecific(clave, datos);

    /* Leer los datos */
    int *p = pthread_getspecific(clave);
    printf("Hilo %d: datos = %d\n", id, *p);

    sleep(1);
    return NULL;
}

int main(void)
{
    /* Crear la clave (una vez, antes de crear hilos) */
    pthread_key_create(&clave, destructor);

    pthread_t hilos[3];
    for (int i = 0; i < 3; i++)
        pthread_create(&hilos[i], NULL, trabajo, (void *)(long)(i + 1));

    for (int i = 0; i < 3; i++)
        pthread_join(hilos[i], NULL);

    /* Eliminar la clave */
    pthread_key_delete(clave);
    return 0;
}
terminal - ejecutar pthread_key_demo

$ gcc -std=c17 -Wall -Wextra -pthread -o pthread_key_demo pthread_key_demo.c
$ ./pthread_key_demo
Hilo 1: datos = 100
Hilo 2: datos = 200
Hilo 3: datos = 300
Destructor: liberando 0x... (hilo 1)
Destructor: liberando 0x... (hilo 2)
Destructor: liberando 0x... (hilo 3)

Cada hilo tiene su propio valor para la clave. El destructor se llama cuando cada hilo termina, liberando la memoria asignada.

6. Destructores de claves

Cuando un hilo termina, el núcleo (o pthreads) llama al destructor de cada clave que tiene un valor no NULL. Esto permite liberar los datos asociados a la clave sin que el hilo tenga que hacerlo explícitamente.

El destructor se llama:

Si el destructor asigna un nuevo valor a la clave (con pthread_setspecific), el destructor se llama de nuevo, hasta un máximo de 4 veces (PTHREAD_DESTRUCTOR_ITERATIONS). Esto permite limpiar datos que se reasignan durante la destrucción.

El destructor debe ser async-signal-safe

Si el hilo termina por pthread_cancel, el destructor se ejecuta en un contexto de cancelación. Solo se pueden llamar funciones async-signal-safe en el destructor. En particular, no se debe llamar a pthread_setspecific en el destructor si el hilo fue cancelado.

7. Por qué errno es distinto en cada hilo

errno es una variable global que se usa para reportar errores de las funciones de la biblioteca de C. En un programa con un solo hilo, errno funciona bien: cada función que falla establece errno, y el programa lo consulta.

Pero en un programa con múltiples hilos, si errno fuera una variable global compartida, los hilos se pisarían: un hilo podría establecer errno y otro lo sobrescribiría antes de que el primero lo consultara.

La solución es que errno sea una variable thread-local. En Linux (con glibc y NPTL), errno se define como una macro que accede a una variable thread-local:

C - cómo se define errno en glibc

/* En glibc, errno se define así (simplificado): */
#define errno (*__errno_location())

/* __errno_location devuelve un puntero a la variable errno del hilo actual */
int *__errno_location(void);

Cuando se compila con -pthread, se define _REENTRANT, que hace que errno sea thread-local. Sin -pthread, errno es una variable global, y el programa puede tener bugs de concurrencia.

Esto es un ejemplo de cómo los datos propios del hilo se usan en la práctica: la biblioteca de C usa thread-local storage para hacer que errno sea seguro en programas con hilos.

errno_hilos.c

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

void *trabajo(void *arg)
{
    int id = (int)(long) arg;

    /* Provocar un error: abrir un archivo inexistente */
    int fd = open("/archivo/inexistente", O_RDONLY);
    if (fd < 0) {
        printf("Hilo %d: errno = %d (%s)\n", id, errno, strerror(errno));
    }

    /* Establecer errno manualmente */
    errno = id * 10;
    printf("Hilo %d: errno establecido a %d\n", id, errno);

    sleep(1);
    printf("Hilo %d: errno después de sleep = %d\n", id, errno);
    return NULL;
}

int main(void)
{
    pthread_t hilos[3];
    for (int i = 0; i < 3; i++)
        pthread_create(&hilos[i], NULL, trabajo, (void *)(long)(i + 1));
    for (int i = 0; i < 3; i++)
        pthread_join(hilos[i], NULL);

    printf("Main: errno = %d\n", errno);
    return 0;
}
terminal - ejecutar errno_hilos

$ gcc -std=c17 -Wall -Wextra -pthread -o errno_hilos errno_hilos.c
$ ./errno_hilos
Hilo 1: errno = 2 (No such file or directory)
Hilo 1: errno establecido a 10
Hilo 2: errno = 2 (No such file or directory)
Hilo 2: errno establecido a 20
Hilo 3: errno = 2 (No such file or directory)
Hilo 3: errno establecido a 30
Hilo 1: errno después de sleep = 10
Hilo 2: errno después de sleep = 20
Hilo 3: errno después de sleep = 30
Main: errno = 0

Cada hilo tiene su propio errno. El hilo 1 establece errno a 10, y sigue siendo 10 después de sleep. El main tiene errno = 0 (no hubo errores en el main). Esto demuestra que errno es thread-local.

8. Comparación: _Thread_local vs pthread_key_create

Característica_Thread_local (C11)pthread_key_create (POSIX)
SintaxisDeclaración de variable: _Thread_local int x;API: pthread_key_create(&key, destructor)
AccesoDirecto: x = 5;Indirecto: pthread_setspecific(key, &x)
RendimientoRápido (acceso directo a TLS)Más lento (llamada a función + búsqueda)
DestructoresNo (el compilador puede generarlos)Sí (se pasa un destructor a pthread_key_create)
PortabilidadC11 (no disponible en todos los compiladores)POSIX (disponible en Linux, macOS, etc.)
Uso típicoVariables simples, contadores, buffersDatos complejos, objetos con destructores

En la práctica, _Thread_local es preferible para variables simples porque es más rápido y más fácil de usar. pthread_key_create se usa cuando se necesita un destructor o cuando se trabaja con bibliotecas que no soportan C11.

9. Thread-Local Storage (TLS)

Thread-Local Storage (TLS) es el mecanismo del sistema operativo y del compilador para implementar datos propios del hilo. En Linux, TLS se implementa con el modelo "TLS inicial-exec" o "TLS local-dynamic":

En ELF (el formato de ejecutables en Linux), las variables thread-local se colocan en secciones especiales: .tdata (datos inicializados) y .tbss (datos no inicializados). El núcleo reserva espacio para estas secciones por hilo.

El acceso a una variable TLS usa el registro FS (en x86-64) o GS (en x86-32) como base. El compilador genera código que accede a la variable como un desplazamiento desde FS, que es diferente para cada hilo.

terminal - ver secciones TLS en ELF

$ readelf -S programa | grep -E "tdata|tbss"
  [XX] .tdata            PROGBITS         ...
  [XX] .tbss             NOBITS           ...

Las variables _Thread_local y pthread_key_create usan el mismo mecanismo subyacente (TLS), pero con interfaces diferentes. _Thread_local es más directo (el compilador lo gestiona), mientras que pthread_key_create es más flexible (permite destructores y datos dinámicos).

10. Un programa que demuestra datos propios de cada hilo

El siguiente programa demuestra _Thread_local y pthread_key_create en acción:

datos_propios.c

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

/* _Thread_local: variable privada de cada hilo */
_Thread_local int contador_tl = 0;

/* pthread_key: clave para datos dinámicos */
pthread_key_t clave;

void destructor(void *p)
{
    printf("  Destructor: liberando %p\n", p);
    free(p);
}

void *trabajo(void *arg)
{
    int id = (int)(long) arg;

    /* _Thread_local: modificar la copia propia */
    contador_tl = id * 100;
    printf("Hilo %d: _Thread_local = %d\n", id, contador_tl);

    /* pthread_key: asignar datos dinámicos */
    char *buf = malloc(64);
    snprintf(buf, 64, "Hilo %d: buffer dinamico", id);
    pthread_setspecific(clave, buf);

    /* Leer los datos */
    char *p = pthread_getspecific(clave);
    printf("Hilo %d: pthread_key = %s\n", id, p);

    /* Verificar que son independientes */
    sleep(1);
    printf("Hilo %d: después de sleep, _Thread_local = %d\n", id, contador_tl);
    return NULL;
}

int main(void)
{
    pthread_key_create(&clave, destructor);

    pthread_t hilos[3];
    for (int i = 0; i < 3; i++)
        pthread_create(&hilos[i], NULL, trabajo, (void *)(long)(i + 1));

    for (int i = 0; i < 3; i++)
        pthread_join(hilos[i], NULL);

    printf("Main: _Thread_local = %d (no cambió)\n", contador_tl);
    pthread_key_delete(clave);
    return 0;
}
terminal - ejecutar datos_propios

$ gcc -std=c17 -Wall -Wextra -pthread -o datos_propios datos_propios.c
$ ./datos_propios
Hilo 1: _Thread_local = 100
Hilo 1: pthread_key = Hilo 1: buffer dinamico
Hilo 2: _Thread_local = 200
Hilo 2: pthread_key = Hilo 2: buffer dinamico
Hilo 3: _Thread_local = 300
Hilo 3: pthread_key = Hilo 3: buffer dinamico
Hilo 1: después de sleep, _Thread_local = 100
Hilo 2: después de sleep, _Thread_local = 200
Hilo 3: después de sleep, _Thread_local = 300
Destructor: liberando 0x... (hilo 1)
Destructor: liberando 0x... (hilo 2)
Destructor: liberando 0x... (hilo 3)
Main: _Thread_local = 0 (no cambió)

El programa demuestra que cada hilo tiene su propia copia de _Thread_local (el main ve 0, no 300) y sus propios datos con pthread_key (cada hilo tiene su propio buffer). Los destructores se ejecutan al terminar cada hilo.

11. Resumen

12. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. Qué es _Thread_local?

Una palabra clave de C11 que declara una variable como local al hilo. Cada hilo tiene su propia copia.

2. Cómo funciona _Thread_local?

El compilador coloca la variable en una sección especial del ELF (.tdata/.tbss). El núcleo reserva espacio por hilo. El acceso usa el registro FS como base.

3. Qué es pthread_key_create?

Una función POSIX que crea una clave para datos específicos del hilo. Cada hilo tiene su propio valor para cada clave.

4. Para qué sirve el destructor en pthread_key_create?

Se llama cuando el hilo termina, para liberar los datos asociados a la clave. Evita fugas de memoria.

5. Por qué errno es distinto en cada hilo?

Porque errno es thread-local: cada hilo tiene su propia copia. Sin -pthread, errno es global y puede causar bugs de concurrencia.

6. Qué es TLS?

Thread-Local Storage: el mecanismo del SO y del compilador para implementar datos propios del hilo.

7. Que es más rápido: _Thread_local o pthread_key_create?

_Thread_local es más rápido porque el acceso es directo (a través del registro FS). pthread_key_create requiere una llamada a función.

8. Que es PTHREAD_KEYS_MAX?

El número máximo de claves que se pueden crear (típicamente 1024 en Linux).

9. Que es PTHREAD_DESTRUCTOR_ITERATIONS?

El número máximo de veces que se llama al destructor si reasigna datos durante la destrucción (típicamente 4).

10. Como se implementa TLS en x86-64?

Con el registro FS como base. El compilador genera código que accede a la variable como un desplazamiento desde FS, que es diferente para cada hilo.

Para practicar

  1. Compila y ejecuta pthread_key_demo.c. Observa cómo cada hilo tiene sus propios datos.
  2. Compila y ejecuta errno_hilos.c. Verifica que errno es distinto en cada hilo.
  3. Compila y ejecuta datos_propios.c. Observa _Thread_local y pthread_key en acción.
  4. Escribe un programa que use _Thread_local para un contador de errores por hilo.
  5. Escribe un programa que use pthread_key_create con un destructor que libera memoria.
  6. Usa readelf -S para ver las secciones .tdata y .tbss de un programa.