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:
- _Thread_local (C11): una palabra clave del lenguaje que hace que una variable sea privada de cada hilo.
- pthread_key_create (POSIX): una API que permite asociar datos a un hilo a través de una clave.
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.
#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.
#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:
- 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).
- 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.
- 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.
- 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.
#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).
$ 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.
#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).
#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;
}
$ 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:
- Cuando el hilo termina (por return, pthread_exit, o pthread_cancel).
- Después de que las funciones pthread_cleanup se han ejecutado.
- En el orden inverso al de creación de las claves.
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.
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:
/* 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.
#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;
}
$ 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) |
|---|---|---|
| Sintaxis | Declaración de variable: _Thread_local int x; | API: pthread_key_create(&key, destructor) |
| Acceso | Directo: x = 5; | Indirecto: pthread_setspecific(key, &x) |
| Rendimiento | Rápido (acceso directo a TLS) | Más lento (llamada a función + búsqueda) |
| Destructores | No (el compilador puede generarlos) | Sí (se pasa un destructor a pthread_key_create) |
| Portabilidad | C11 (no disponible en todos los compiladores) | POSIX (disponible en Linux, macOS, etc.) |
| Uso típico | Variables simples, contadores, buffers | Datos 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":
- TLS inicial-exec: las variables thread-local se asignan cuando se carga el módulo (programa o biblioteca). Es el más rápido pero solo funciona para el ejecutable principal y las bibliotecas cargadas al inicio.
- TLS local-dynamic: las variables thread-local se asignan cuando se carga el módulo dinámicamente (dlopen). Es más lento pero funciona para bibliotecas cargadas en tiempo de ejecución.
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.
$ 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:
#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;
}
$ 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
- Los datos propios del hilo evitan condiciones de carrera porque cada hilo tiene su propia copia.
- _Thread_local (C11) es la forma más simple y rápida de declarar variables thread-local.
- pthread_key_create crea claves para datos dinámicos asociados a cada hilo.
- pthread_setspecific asocia un valor a una clave en el hilo actual.
- pthread_getspecific obtiene el valor asociado a una clave en el hilo actual.
- Los destructores se llaman al terminar el hilo para liberar los datos.
- errno es thread-local en Linux (con -pthread), implementado con __errno_location().
- _Thread_local es más rápido que pthread_key_create (acceso directo vs llamada a función).
- TLS se implementa con secciones .tdata/.tbss en ELF y el registro FS en x86-64.
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
- Compila y ejecuta pthread_key_demo.c. Observa cómo cada hilo tiene sus propios datos.
- Compila y ejecuta errno_hilos.c. Verifica que errno es distinto en cada hilo.
- Compila y ejecuta datos_propios.c. Observa _Thread_local y pthread_key en acción.
- Escribe un programa que use _Thread_local para un contador de errores por hilo.
- Escribe un programa que use pthread_key_create con un destructor que libera memoria.
- Usa readelf -S para ver las secciones .tdata y .tbss de un programa.