Hilos en C con pthreads: repartir trabajo, carreras de datos y mutex

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.

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 compartidosatomic_*solo operaciones simples
  • Estructuras compartidaspthread_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.