1. Qué es thread-safe
Una función es thread-safe (segura entre hilos) si puede ser llamada por varios hilos simultáneamente sin causar condiciones de carrera ni corrupción de datos. El resultado de la función es correcto independientemente del intercalado de los hilos.
Una función thread-safe no usa estado compartido mutable sin protección. Si necesita estado, lo protege con mutex o lo hace local a cada hilo.
La mayoría de las funciones de la biblioteca estándar de C son thread-safe, pero algunas no lo son. Las funciones no thread-safe usan variables estáticas internas que se comparten entre hilos.
2. Qué es reentrante
Una función es reentrante si puede ser interrumpida en cualquier punto y llamada de nuevo (por el mismo o por otro hilo) sin causar errores. Una función reentrante no usa estado mutable compartido ni variables estáticas.
La reentrancia es más fuerte que la seguridad entre hilos: toda función reentrante es thread-safe, pero no toda función thread-safe es reentrante. Una función puede ser thread-safe usando un mutex internamente, pero no reentrante si el mutex se bloquea dos veces por el mismo hilo.
Las funciones reentrantes son importantes en los manejadores de señales: un manejador de señal puede interrumpir cualquier función en cualquier punto, y si la función interrumpida no es reentrante, el manejador puede corromper su estado.
3. La diferencia entre ambos
| Propiedad | Thread-safe | Reentrante |
|---|---|---|
| Definición | Segura para llamar desde varios hilos. | Segura para interrumpir y llamar de nuevo. |
| Estado compartido | Puede usar mutex para proteger estado compartido. | No usa estado compartido mutable. |
| Manejadores de señales | No necesariamente segura. | Segura para manejadores de señales. |
| Relación | Toda función reentrante es thread-safe. | No toda función thread-safe es reentrante. |
Ejemplo: una función que usa un mutex internamente es thread-safe (varios hilos pueden llamarla simultáneamente), pero no es reentrante (si un manejador de señal la interrumpe y la llama de nuevo, el mutex se bloquea dos veces por el mismo hilo, causando deadlock).
4. El caso de strtok
strtok es la función clásica no thread-safe. Tokeniza una cadena usando un delimitador, pero usa una variable estática interna para recordar la posición actual:
#include <stdio.h>
#include <string.h>
int main(void) {
char str1[] = "a,b,c";
char str2[] = "x,y,z";
char *tok1 = strtok(str1, ",");
char *tok2 = strtok(str2, ","); /* BUG: sobrescribe el estado de str1 */
/* tok1 ahora apunta a "x" en lugar de "b" */
printf("tok1 = %s\n", tok1); /* imprime "x", no "b" */
return 0;
}
El problema es que strtok usa una variable estática para recordar la posición en la cadena. Si dos hilos llaman a strtok simultáneamente, o si se tokenizan dos cadenas a la vez, el estado se corrompe.
La solución es strtok_r (reentrante), que recibe un puntero para guardar el estado:
#include <stdio.h>
#include <string.h>
int main(void) {
char str1[] = "a,b,c";
char str2[] = "x,y,z";
char *save1, *save2;
char *tok1 = strtok_r(str1, ",", &save1);
char *tok2 = strtok_r(str2, ",", &save2);
/* Ahora tok1 y tok2 son independientes. */
printf("tok1 = %s, tok2 = %s\n", tok1, tok2);
return 0;
}
strtok_r es thread-safe y reentrante porque el estado se guarda en una variable proporcionada por el llamador, no en una variable estática.
5. El caso de rand
rand() genera números pseudoaleatorios usando un estado interno (la semilla). Si dos hilos llaman a rand() simultáneamente, el estado se corrompe y los números generados no son aleatorios:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
void *generar(void *arg) {
for (int i = 0; i < 5; i++) {
printf("Hilo %ld: %d\n", (long)arg, rand());
}
return NULL;
}
int main(void) {
pthread_t h1, h2;
srand(42); /* semilla compartida */
pthread_create(&h1, NULL, generar, (void*)1);
pthread_create(&h2, NULL, generar, (void*)2);
pthread_join(h1, NULL);
pthread_join(h2, NULL);
return 0;
}
La solución es rand_r, que recibe un puntero a la semilla:
void *generar(void *arg) {
unsigned int seed = 42; /* semilla propia del hilo */
for (int i = 0; i < 5; i++) {
printf("Hilo %ld: %d\n", (long)arg, rand_r(&seed));
}
return NULL;
}
rand_r es thread-safe y reentrante porque cada hilo tiene su propia semilla.
6. Otras funciones no thread-safe
| Función no segura | Versión segura | Problema |
|---|---|---|
strtok | strtok_r | Variable estática para la posición. |
rand | rand_r | Estado interno (semilla). |
gethostbyname | gethostbyname_r | Estructura estática para el resultado. |
getpwuid | getpwuid_r | Estructura estática para el resultado. |
localtime | localtime_r | Estructura estática para el resultado. |
asctime | asctime_r | Buffer estático para la cadena. |
ctime | ctime_r | Buffer estático para la cadena. |
strerror | strerror_r | Buffer estático para la cadena. |
La convención de POSIX es añadir _r al nombre de la función para indicar la versión reentrante. La _r significa «reentrant».
Además, algunas funciones usan variables globales implícitas:
errno: en realidad es una macro que accede a una variable thread-local en sistemas modernos, pero en sistemas antiguos era una variable global.h_errno: similar a errno, para funciones de red.
7. Almacenamiento propio del hilo (TLS)
El almacenamiento propio del hilo (Thread-Local Storage, TLS) es un mecanismo que permite a cada hilo tener su propia copia de una variable. Aunque la variable es «global» (accesible por todos los hilos), cada hilo ve su propia copia, como si fuera una variable local.
TLS es la solución general al problema de las funciones no thread-safe: en lugar de usar una variable estática compartida, se usa una variable TLS, y cada hilo tiene su propia copia.
TLS se implementa de dos formas:
- En el compilador: usando el calificador
__thread(GCC) othread_local(C11). - En el runtime: usando
pthread_key_createypthread_setspecific.
8. thread_local en C11
C11 proporciona el calificador _Thread_local (o thread_local con <threads.h>) para declarar variables TLS:
#include <stdio.h>
#include <threads.h>
thread_local int contador = 0; /* cada hilo tiene su propia copia */
void incrementar(void) {
contador++; /* solo modifica la copia del hilo actual */
}
int main(void) {
/* Hilo principal: contador = 0 */
incrementar(); /* contador = 1 (del hilo principal) */
printf("Hilo principal: %d\n", contador); /* imprime 1 */
/* Otro hilo: contador = 0 (su propia copia) */
/* incrementar(); en otro hilo: contador = 1 (del otro hilo) */
return 0;
}
La variable contador es global (accesible por todos los hilos), pero cada hilo tiene su propia copia. Modificarla en un hilo no afecta a los demás.
En GCC, se puede usar __thread en lugar de thread_local:
__thread int contador = 0; /* GCC extension */
9. pthread_key_create
Antes de C11, POSIX proporcionaba TLS a través de pthread_key_create:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
static pthread_key_t clave;
void destructor(void *ptr) {
free(ptr);
}
void init(void) {
pthread_key_create(&clave, destructor);
}
void *obtener_dato(void) {
void *ptr = pthread_getspecific(clave);
if (ptr == NULL) {
ptr = malloc(sizeof(int));
*(int *)ptr = 0;
pthread_setspecific(clave, ptr);
}
return ptr;
}
Este mecanismo es más verboso pero más flexible: permite asignar datos dinámicamente y definir un destructor que se ejecuta cuando el hilo termina.
10. Patrones seguros
Los patrones para hacer funciones thread-safe son:
- Usar variables locales: si el estado es local a la función (en la pila), no hay compartición. local
- Usar TLS: si el estado debe persistir entre llamadas, usar thread-local storage. TLS
- Usar mutex: si el estado debe ser compartido entre hilos, protegerlo con un mutex. mutex
- Pasar el estado como parámetro: en lugar de usar variables estáticas, recibir el estado como argumento (como
strtok_r). parámetro
El patrón más recomendado es el último: pasar el estado como parámetro. Es el más explícito y el más fácil de razonar. El patrón TLS es útil cuando no se puede cambiar la firma de la función.
11. Simulador interactivo
Este simulador muestra la diferencia entre strtok y strtok_r:
Simulador · strtok vs strtok_r
12. Resumen
- Una función thread-safe es segura para llamar desde varios hilos simultáneamente.
- Una función reentrante es segura para interrumpir y llamar de nuevo.
- Toda función reentrante es thread-safe, pero no al revés.
- strtok y rand no son thread-safe: usan variables estáticas internas.
- strtok_r y rand_r son las versiones thread-safe.
- TLS permite a cada hilo tener su propia copia de una variable.
- C11 proporciona thread_local para TLS.
- POSIX proporciona pthread_key_create para TLS.
- El patrón más recomendado es pasar el estado como parámetro.
13. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Qué es una función thread-safe?
Una función que puede ser llamada por varios hilos simultáneamente sin causar condiciones de carrera.
2. ¿Qué es una función reentrante?
Una función que puede ser interrumpida en cualquier punto y llamada de nuevo sin causar errores.
3. ¿Cuál es la diferencia entre thread-safe y reentrante?
Toda función reentrante es thread-safe, pero no al revés. Una función puede ser thread-safe usando un mutex pero no reentrante.
4. ¿Por qué strtok no es thread-safe?
Porque usa una variable estática interna para recordar la posición en la cadena.
5. ¿Cuál es la versión thread-safe de strtok?
strtok_r, que recibe un puntero para guardar el estado.
6. ¿Por qué rand no es thread-safe?
Porque usa un estado interno (semilla) compartido entre hilos.
7. ¿Qué es TLS?
Thread-Local Storage: un mecanismo que permite a cada hilo tener su propia copia de una variable.
8. ¿Cómo se declara una variable TLS en C11?
Con thread_local (o _Thread_local).
9. ¿Qué es pthread_key_create?
Una función POSIX para crear una clave de TLS, que permite asignar datos dinámicamente a cada hilo.
10. ¿Cuál es el patrón más recomendado para hacer funciones thread-safe?
Pasar el estado como parámetro (como strtok_r).
Para practicar
- Escribe un programa que use strtok desde dos hilos y observa el error.
- Corrige el programa usando strtok_r.
- Escribe un programa que use rand desde dos hilos y observa el error.
- Corrige el programa usando rand_r.
- Usa thread_local para hacer una función thread-safe.
- Investiga qué funciones de la biblioteca estándar de C no son thread-safe.