Operaciones atómicas y tipos _Atomic en C11

Cuando intentas incrementar un entero compartido (contador++) en un entorno multihilo, no estás ejecutando una única instrucción de CPU. En la arquitectura del sistema, esa expresión se descompone en una secuencia de leer, incrementar y escribir. Si dos hilos ejecutan esto simultáneamente, es probable que ambos lean el mismo valor inicial, incrementen el mismo número y escriban el mismo resultado, “perdiéndose” así uno de los incrementos. Esto es una condición de carrera (race condition) que, en el estándar C, constituye un comportamiento indefinido (Undefined Behavior).

Para evitar esto sin recurrir al peso de un semáforo o un mutex, C11 introdujo el calificador de tipo _Atomic [C11]. Este calificador le indica al compilador que cualquier acceso a esa variable debe ser tratado como una operación indivisible. En lugar de una secuencia de instrucciones separadas, el compilador genera instrucciones de hardware específicas (como LOCK XADD en x86 o secuencias de load-linked/store-conditional en ARM) que garantizan que nadie pueda intervenir entre la lectura y la escritura.

Debes usar estas operaciones cuando estés implementando estructuras de datos lock-free o contadores de alto rendimiento donde la contención de un pthread_mutex_t [POSIX] degrade el rendimiento debido a las llamadas al sistema o el cambio de contexto. Si usas _Atomic de forma incorrecta, lo que rompes es la consistencia de la memoria: un hilo podría leer un valor parcialmente escrito (teoría) o, más comúnmente, el compilador podría optimizar el código asumiendo que la variable no cambia, dejando al hilo atrapado en un bucle infinito porque no “ve” los cambios realizados por otros núcleos.

Para interactuar con estos tipos, el estándar define una serie de funciones que operan de forma atómica sobre la dirección de memoria. Por ejemplo, atomic_fetch_add incrementa el valor pero devuelve el valor original (antes de la suma), una distinción crucial para algoritmos de sincronización. Para lógica más compleja, como implementar un stack lock-free, se utiliza atomic_compare_exchange_weak [C11], que compara el valor actual con uno esperado y, si coinciden, lo reemplaza por uno nuevo.

#include <stdio.h>
#include <stdatomic.h>
#include <pthread.h>
#include <stdbool.h>

/* Usamos macros de <stdatomic.h> para tipos atómicos claros */
atomic_int contador_global = 0;
atomic_int ultimo_id_procesado = 0;
atomic_bool fin = false;

#define ITERACIONES 100000

void* worker(void* arg) {
    int id = (int)(intptr_t)arg;

    for (int i = 0; i < ITERACIONES; i++) {
        /* atomic_fetch_add es una operación read-modify-write atómica.
           Devuelve el valor que tenía el contador ANTES de sumar 1. */
        atomic_fetch_add(&contador_global, 1);

        /* Implementación de un update lock-free usando Compare-and-Swap (CAS).
           Queremos que ultimo_id_procesado sea siempre el valor más alto
           visto por cualquier hilo, de forma atómica. */
        int actual = atomic_load(&ultimo_id_procesado);
        int nuevo_valor = i + (id * 1000000);

        /* atomic_compare_exchange_weak es preferible en bucles.
           Si 'actual' coincide con 'ultimo_id_procesado', se cambia por 'nuevo_valor'.
           Si no coinciden, 'actual' se actualiza automáticamente con el
           valor actual de 'ultimo_id_procesado', permitiendo reintentar de inmediato. */
        while (nuevo_valor > actual && 
               !atomic_compare_exchange_weak(&ultimo_id_procesado, &actual, nuevo_valor)) {
            /* El bucle es necesario porque la versión 'weak' puede fallar
               espuriamente (spurious failure) incluso si los valores coinciden. */
        }
    }
    return NULL;
}

int main(void) {
    /* Verificar si el hardware soporta operaciones lock-free para este tipo.
       Si es falso, el runtime usa un mutex interno (menos eficiente). */
    if (!atomic_is_lock_free(&contador_global)) {
        printf("Advertencia: Operaciones no lock-free en este hardware.\n");
    }

    pthread_t hilos[4];
    for (int i = 0; i < 4; i++) {
        pthread_create(&hilos[i], NULL, worker, (void*)(intptr_t)i);
    }

    for (int i = 0; i < 4; i++) {
        pthread_join(hilos[i], NULL);
    }

    printf("Contador final: %d (Esperado: %d)\n", 
            atomic_load(&contador_global), 4 * ITERACIONES);
    printf("Último ID: %d\n", atomic_load(&ultimo_id_procesado));

    return 0;
}

En el ejemplo, contador_global se incrementa mediante atomic_fetch_add. Si usáramos contador_global++, el compilador podría no garantizar la atomicidad de toda la operación en arquitecturas complejas. En cambio, atomic_fetch_add asegura que el incremento sea indivisible.

La parte más crítica es el uso de atomic_compare_exchange_weak dentro del while. Esta es la esencia de la programación lock-free. La función compara el contenido de ultimo_id_procesado con actual. Si son iguales, intenta escribir nuevo_valor. Si no son iguales (porque otro hilo ganó la carrera), la función actualiza actual con el valor que acaba de leer de la memoria. Esto permite que en la siguiente iteración del bucle, nuevo_valor > actual sea la única condición que deba validar, evitando re-leer la memoria explícitamente.

Es fundamental notar que atomic_compare_exchange_weak se usa dentro de un bucle debido a las “fallas espurias”. En arquitecturas como ARM, una interrupción de caché puede hacer que la instrucción falle incluso si el valor no ha cambiado. La versión strong evitaría esto, pero es más costosa en términos de ciclos de CPU al forzar una validación más estricta.

El uso de atomic_load y atomic_store es esencial para asegurar que los valores se lean y escriban con la semántica de memoria correcta, impidiendo que el compilador reordene estas instrucciones de forma que rompa la lógica de sincronización.

El error frecuente
Un error común es usar atomic_compare_exchange_weak sin un bucle while.

/* ERROR: Esto es peligroso */
int actual = atomic_load(&ultimo_id_procesado);
atomic_compare_exchange_weak(&ultimo_id_procesado, &actual, nuevo_valor);

Debido a que weak puede fallar espuriamente (aunque los valores coincidan), tu programa podría omitir una actualización legítima. Esto es difícil de detectar porque ocurre de forma intermitente y depende de la arquitectura del CPU. Herramientas como ThreadSanitizer (TSan) detectarán condiciones de carrera si intentas manipular estos valores sin las funciones atómicas adecuadas.

98

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio