Hilos en C: pthreads y el hilo estándar
Las dos APIs de hilos que conviven en el C real, el contrato del punto de entrada, cómo se pasan datos y se recogen resultados sin punteros colgantes, y el ciclo de vida completo desde la creación hasta la unión o la separación.
Un hilo no es una función que corre a la vez que otra: es un flujo de ejecución con su propia pila y su propio juego de registros, pero con acceso indiscriminado a toda la memoria del proceso. Lo primero que conviene interiorizar no es la llamada que lo crea, sino dónde está exactamente la frontera entre lo privado y lo compartido, porque de esa frontera salen todos los errores de las tres lecciones siguientes.
- Distinguir
pthreadsde los hilos estándar dethreads.hy saber cuál usar en cada proyecto. - Dominar el ciclo de vida completo: creación, unión, separación y terminación.
- Pasar argumentos y recoger resultados sin punteros colgantes ni carreras de arranque.
- Usar almacenamiento local al hilo e inicialización única.
Dos APIs sobre el mismo hardware
En C conviven dos interfaces de hilos y no son alternativas equivalentes. pthreads es de 1995, vive en pthread.h, forma parte de POSIX y está en todos los Unix. Los hilos de C11 viven en threads.h, forman parte de la norma del lenguaje y son opcionales: una implementación conforme puede no ofrecerlos y anunciarlo definiendo la macro __STDC_NO_THREADS__.
#if defined(__STDC_NO_THREADS__)
# include <pthread.h> /* plan B: POSIX */
#else
# include <threads.h> /* hilos de la norma */
#endif
La diferencia práctica es de disponibilidad y de potencia. glibc implementa threads.h desde la versión 2.28 y musl también, en ambos casos como una capa finísima sobre la misma maquinaria de pthreads. La libc de Apple no lo trae, y en Windows llegó tarde. Del otro lado, pthreads ofrece atributos de creación —tamaño de pila, política de planificación, estado de separación—, cancelación, mutex robustos y afinidad de núcleo; la API de C11 no ofrece nada de eso. No tiene atributos en absoluto.
/* C11: el punto de entrada devuelve int y recibe void * */
int trabajador(void *arg);
thrd_t h;
thrd_create(&h, trabajador, &datos);
/* POSIX: el punto de entrada devuelve void * y recibe void * */
void *trabajador_posix(void *arg);
pthread_t p;
pthread_create(&p, nullptr, trabajador_posix, &datos);
Hay un detalle que confunde a casi todo el mundo la primera vez: pthread_create devuelve 0 en éxito y un número de error en caso contrario —no toca errno—, mientras que thrd_create devuelve thrd_success, y la norma no garantiza que thrd_success valga cero. Comparar contra cero es un error de portabilidad silencioso.
El ciclo de vida de un hilo
Un hilo nace ejecutable, corre y termina de una de tres formas: retornando de su función de entrada, llamando a thrd_exit, o muriendo con el proceso entero. Al terminar entra en uno de dos estados según se creara unible o separado.
flowchart LR A[thrd create] --> B[Ejecutando] B --> C[Retorno o thrd exit] C --> D[Terminado y unible] D --> E[thrd join recoge el codigo] C --> F[Terminado y separado] F --> G[Recursos liberados solos] B -.-> H[thrd detach renuncia al resultado] style E fill:#a6e3a1,color:#11111b style G fill:#89b4fa,color:#11111b
Un hilo unible que nadie une deja su bloque de control vivo para siempre: es una fuga de recursos del sistema que ni valgrind clasifica de forma obvia. Un hilo separado libera lo suyo al morir, pero pierdes toda posibilidad de saber cuándo lo hizo o qué devolvió.
thrd_t h;
if (thrd_create(&h, trabajador, &datos) != thrd_success)
return 1;
int codigo;
thrd_join(h, &codigo); /* bloquea hasta que termine; recoge el int */
Tres reglas que la norma marca como comportamiento indefinido si las rompes: unir dos veces el mismo hilo, unir un hilo separado, y separar un hilo ya unido. Y una cuarta que sorprende: thrd_t no tiene por qué ser un entero, así que no puedes imprimirlo; para compararlo existe thrd_equal.
El hilo principal es especial. Si main retorna, el proceso entero termina y todos los demás hilos mueren de golpe, en mitad de lo que estuvieran haciendo, sin desenrollar nada. Si lo que quieres es que main se aparte pero los demás sigan, la salida correcta es thrd_exit, que termina ese hilo sin terminar el proceso.
Pasar datos y recogerlos
El error clásico de todo el que empieza cabe en cuatro líneas y no es un fallo de sintaxis sino una carrera:
for (int i = 0; i < N; i++)
thrd_create(&h[i], trabajador, &i); /* MAL: todos ven la misma i */
Los hilos reciben la dirección de una variable que el bucle está modificando y que además muere al salir de él. El resultado no es que cada hilo vea un valor equivocado, sino que el programa tiene una carrera de datos y por tanto comportamiento indefinido. La corrección es dar a cada hilo un objeto propio cuya vida sobrepase claramente la del hilo:
typedef struct { int id; const double *entrada; size_t n; double parcial; } Tarea;
Tarea tareas[N]; /* vive en main, que hace el join */
for (int i = 0; i < N; i++) {
tareas[i] = (Tarea){ .id = i, .entrada = datos, .n = n };
thrd_create(&h[i], trabajador, &tareas[i]);
}
for (int i = 0; i < N; i++)
thrd_join(h[i], nullptr); /* aqui, y solo aqui, parcial es legible */
Fíjate en dónde queda el resultado: cada hilo escribe en su casilla, así que no hay dos hilos tocando la misma memoria, y el thrd_join establece la relación de orden que permite al hilo principal leer lo escrito sin sincronización adicional. Unir un hilo no es solo esperar: es un punto de sincronización con todas las garantías de visibilidad que veremos en la lección de atómicos.
La vía de retorno es más pobre de lo que parece. C11 solo te deja devolver un int; POSIX te deja devolver un void *, y ahí la tentación es devolver la dirección de una variable local, que para cuando el join la recoge ya no existe. Si vas a devolver algo grande por esa vía, tiene que estar en el montón y el que une es quien libera.
Estado propio del hilo e inicialización única
No todo el estado quiere compartirse. C23 convierte thread_local en una palabra clave del lenguaje —antes era una macro de threads.h para _Thread_local— y con ella cada hilo obtiene su propia copia de la variable, inicializada al arrancar.
thread_local int profundidad = 0; /* una copia por hilo, sin sincronizar */
static thread_local char buffer[256]; /* util para formateo temporal */
errno funciona exactamente así desde hace décadas, y por eso una llamada fallida en un hilo no pisa el código de error de otro. La limitación de thread_local en C es que no hay destructores: si la copia del hilo posee memoria del montón, nadie la libera al morir el hilo. Para eso existe el almacenamiento específico con tss_create, que sí acepta una función de limpieza, o pthread_key_create en POSIX.
El otro problema recurrente es inicializar algo compartido exactamente una vez sin que dos hilos lo hagan a la vez. Escribirlo a mano con un booleano es una carrera de manual; la norma trae la primitiva hecha:
static once_flag inicializada = ONCE_FLAG_INIT;
static Registro *registro;
static void construir(void) { registro = registro_nuevo(); }
Registro *obtener(void) {
call_once(&inicializada, construir); /* exactamente una vez, en total */
return registro;
}
Privado del hilo
Pila, registros, valor de errno, variables thread_local y el estado de tss.
Compartido
Montón, variables globales y estáticas, descriptores de fichero, mapeos de memoria.
Frontera peligrosa
Un puntero a la pila de un hilo entregado a otro. Legal, pero solo mientras ese marco viva.
Falta en C11
Tamaño de pila, prioridad, afinidad y cancelación. Todo eso solo con pthreads.
Aquí está la confusión que arruina más diseños concurrentes, y merece la pena enunciarla con precisión. Cuando creas un proceso, el sistema te da un espacio de direcciones nuevo: dos procesos que escriben en la dirección 0x4000 escriben en sitios físicamente distintos, y el aislamiento lo garantiza el hardware de paginación. Cuando creas un hilo, no ocurre nada de eso. El hilo recibe exactamente una cosa nueva —una pila— y hereda absolutamente todo lo demás: el mismo mapa de páginas, los mismos globales, el mismo montón, la misma tabla de descriptores. Lo que llamamos “hilo” es, desde el punto de vista del núcleo de Linux, la misma estructura que un proceso creada con clone y las banderas de compartición activadas; la distinción es una convención, no una barrera. La consecuencia es que la concurrencia con hilos no te da ninguna propiedad de aislamiento a cambio del paralelismo: te da paralelismo y te cobra, íntegra, la factura de razonar sobre memoria compartida. Y esa factura no la puedes pagar con cuidado ni con revisiones de código, porque los errores que produce no son deterministas y no aparecen en tu máquina. Por eso el consejo de diseño de esta lección precede a toda la mecánica de las siguientes: antes de preguntarte cómo sincronizar el acceso a un dato compartido, pregúntate si tiene que estar compartido. Particionar los datos para que cada hilo tenga los suyos, comunicar por copia en lugar de por referencia, y usar thread_local con generosidad no son trucos de rendimiento: son la única forma conocida de reducir el tamaño del problema en lugar de gestionarlo. Los sistemas concurrentes que se mantienen correctos durante años no son los que sincronizan mejor, sino los que tienen muy poco que sincronizar.
- Escribe un programa que lance ocho hilos, cada uno con su
Tareapropia, sume una porción de un array de un millón dedoubley deposite el parcial en su casilla. Únelos y suma los parciales. - Reprodúcelo mal a propósito pasando
&idel bucle. Ejecútalo cien veces y cuenta cuántas dan un resultado distinto; después córrelo bajo-fsanitize=thready lee el informe. - Sustituye un
thrd_joinporthrd_detachy comprueba convalgrindqué cambia en el recuento de bloques todavía alcanzables al terminar. - Haz que
maintermine conthrd_exiten lugar de retornar, con un hilo trabajando en un bucle largo, y compara el comportamiento con la versión que retorna. - Implementa un contador de recursión con
thread_localy demuestra que cada hilo lleva el suyo; añade después un recurso del montón en almacenamientotsscon función de limpieza y verifica que se libera al morir cada hilo.