Hilos en C con pthread: cómo repartir un cálculo entre varios núcleos sin que los hilos compartan nada que se modifique, qué es una carrera de datos y cómo la encuentra ThreadSanitizer, y las dos formas de evitarla, un mutex o una variable atómica de C11.
También te puede interesar
Un hilo es una línea de ejecución dentro de un proceso. Todos los hilos de un programa comparten la misma memoria, lo que permite repartir un trabajo entre varios núcleos del procesador sin copiar datos, y a la vez es la fuente de los errores más difíciles de encontrar. En C se usan los hilos de POSIX (pthread), disponibles en Linux y macOS; C11 añadió <threads.h>, con funciones parecidas.
Repartir un cálculo entre hilos
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define N 1000000
#define HILOS 4
typedef struct {
const int *datos;
size_t desde, hasta;
long long suma;
} Tramo;
static void *sumar_tramo(void *arg)
{
Tramo *t = arg;
t->suma = 0;
for (size_t i = t->desde; i < t->hasta; i++) {
t->suma += t->datos[i];
}
return nullptr;
}
int main(void)
{
int *datos = malloc(N * sizeof *datos);
if (datos == nullptr) return EXIT_FAILURE;
for (int i = 0; i < N; i++) datos[i] = i % 100;
pthread_t hilos[HILOS];
Tramo tramos[HILOS];
for (int i = 0; i < HILOS; i++) {
tramos[i] = (Tramo) {datos, (size_t) N * i / HILOS, (size_t) N * (i + 1) / HILOS, 0};
if (pthread_create(&hilos[i], nullptr, sumar_tramo, &tramos[i]) != 0) {
fprintf(stderr, "No se pudo crear el hilo %d\n", i);
return EXIT_FAILURE;
}
}
long long total = 0;
for (int i = 0; i < HILOS; i++) {
pthread_join(hilos[i], nullptr);
printf("hilo %d: elementos %zu a %zu, suma %lld\n", i, tramos[i].desde, tramos[i].hasta - 1, tramos[i].suma);
total += tramos[i].suma;
}
printf("total: %lld\n", total);
free(datos);
return 0;
}gcc -Wall -Wextra -pthread repartir.c -o repartir
salidahilo 0: elementos 0 a 249999, suma 12375000 hilo 1: elementos 250000 a 499999, suma 12375000 hilo 2: elementos 500000 a 749999, suma 12375000 hilo 3: elementos 750000 a 999999, suma 12375000 total: 49500000
pthread_create lanza un hilo que ejecuta la función indicada, que recibe un void * con lo que necesite y devuelve otro. pthread_join espera a que termine. La clave del diseño: cada hilo trabaja con su propio tramo y escribe en su propia estructura, así que no comparten nada que se modifique, y no hace falta ninguna sincronización. El hilo principal suma los resultados solo después de esperar a cada uno.
Una carrera de datos
Ahora lo contrario: cuatro hilos suman en la misma variable global:
#include <stdio.h>
#include <pthread.h>
static long contador = 0;
static void *sumar(void *arg)
{
(void) arg;
for (int i = 0; i < 100000; i++) {
contador++;
}
return nullptr;
}
int main(void)
{
pthread_t h[4];
for (int i = 0; i < 4; i++) pthread_create(&h[i], nullptr, sumar, nullptr);
for (int i = 0; i < 4; i++) pthread_join(h[i], nullptr);
printf("contador = %ld (debería ser 400000)\n", contador);
return 0;
}contador++ parece una operación, pero son tres: leer el valor, sumarle uno y escribirlo. Si dos hilos leen a la vez el mismo valor, los dos escriben el mismo resultado y se pierde un incremento. Es una carrera de datos (data race), y en C es comportamiento indefinido. El resultado cambia de una ejecución a otra, y en una máquina con un solo núcleo puede que nunca falle. Para encontrarlas existe ThreadSanitizer:
gcc -Wall -Wextra -g -fsanitize=thread carrera.c -o carrera ./carrera
salida con errorWARNING: ThreadSanitizer: data race (pid=18)
Read of size 8 at 0x000000404058 by thread T2:
#0 sumar /curso/carrera_tsan.c:10 (carrera_tsan+0x4011c1)
…
Previous write of size 8 at 0x000000404058 by thread T1:
#0 sumar /curso/carrera_tsan.c:10 (carrera_tsan+0x4011d6)
…
Location is global 'contador' of size 8 at 0x000000404058 (carrera_tsan+0x404058)El informe, del que aquí va el primer aviso, dice qué variable es (contador), los dos accesos de hilos distintos que chocan (la lectura de uno y la escritura anterior de otro) y en qué línea: la 10, el contador++. Con -fsanitize=thread, el programa va mucho más lento, pero encuentra carreras que pasarían años sin manifestarse.
Arreglarla: un mutex o un atómico
#include <stdio.h>
#include <pthread.h>
#include <stdatomic.h>
static long con_mutex = 0;
static pthread_mutex_t cerrojo = PTHREAD_MUTEX_INITIALIZER;
static atomic_long atomico = 0;
static void *sumar(void *arg)
{
(void) arg;
for (int i = 0; i < 100000; i++) {
pthread_mutex_lock(&cerrojo);
con_mutex++;
pthread_mutex_unlock(&cerrojo);
atomic_fetch_add(&atomico, 1);
}
return nullptr;
}
int main(void)
{
pthread_t h[4];
for (int i = 0; i < 4; i++) pthread_create(&h[i], nullptr, sumar, nullptr);
for (int i = 0; i < 4; i++) pthread_join(h[i], nullptr);
printf("con mutex: %ld\natómico: %ld\n", con_mutex, atomic_load(&atomico));
return 0;
}salidacon mutex: 400000 atómico: 400000
Dos soluciones, compiladas con ThreadSanitizer, que ya no protesta. Un mutex (mutual exclusion) deja pasar a un solo hilo cada vez por la zona entre lock y unlock: sirve para proteger cualquier operación, por larga que sea. Un atómico (<stdatomic.h>, de C11) hace la lectura, la suma y la escritura como una sola operación indivisible del procesador: es más rápido, pero solo vale para operaciones simples sobre una variable. Aun así, lo más rápido es lo del primer ejemplo: que cada hilo acumule en su propia variable y se sumen al final.
- ParaUsaOjo
- Repartir trabajo independienteun tramo y un resultado por hilolo más rápido: sin sincronizar
- Contadores y banderas compartidos
atomic_*solo operaciones simples - Estructuras compartidas
pthread_mutex_tbloquear siempre en el mismo orden para evitar interbloqueos - Encontrar carreras
-fsanitize=threadno se combina con-fsanitize=address
Todo el código de esta página se compiló con GCC 15.3 (C23, -Wall -Wextra, sin avisos) y se ejecutó en Linux, en un contenedor limpio sin acceso a la red, antes de publicarla; las salidas están copiadas de esa ejecución.