← Concurrencia y Sincronización Bloque 3 · Operaciones atómicas · Tema 11 de 47

Tema 11

Operaciones atómicas con stdatomic.h

Las operaciones atómicas son el fundamento del hardware para la sincronización: una operación que se ejecuta de forma indivisible, sin que otro hilo pueda observar un estado intermedio. C11 proporciona stdatomic.h, que abstrae las instrucciones atómicas del procesador y garantiza atomicidad y visibilidad entre hilos. En este tema verás qué garantiza una operación atómica, los tipos atómicos de C11 y cómo corregir el contador sin bloqueos.

1. Qué es una operación atómica

Una operación atómica es una operación que se ejecuta de forma indivisible: o se ejecuta completamente o no se ejecuta en absoluto. No hay punto intermedio en el que otro hilo pueda observar un estado parcial. Además, garantiza la visibilidad: los cambios son visibles para otros hilos inmediatamente.

En el tema 4 viste que counter++ no es atómico: son tres instrucciones (LOAD, ADD, STORE), y el planificador puede interrumpir entre ellas. Una operación atómica de incremento ejecuta las tres instrucciones de forma indivisible, sin que otro hilo pueda interrumpir.

Las operaciones atómicas son el fundamento del hardware para la sincronización. Todas las herramientas de sincronización (mutex, semáforos, variables de condición) se construyen sobre operaciones atómicas.

Atomicidad + visibilidad

Una operación atómica garantiza dos cosas: (1) atomicidad: la operación se ejecuta de forma indivisible, y (2) visibilidad: los cambios son visibles para otros hilos inmediatamente. Sin visibilidad, un hilo podría escribir un valor que otro hilo no ve, incluso si la escritura es atómica.

2. Qué garantiza

Una operación atómica garantiza:

Estas garantías son las que faltan en counter++ normal: no es indivisible (se puede interrumpir) ni garantiza visibilidad (el compilador o el procesador pueden reordenar).

3. stdatomic.h en C11

C11 introdujo stdatomic.h, que proporciona tipos y operaciones atómicas portables. El compilador traduce las operaciones atómicas de C11 en instrucciones atómicas del procesador (como LOCK ADD en x86 o LDREX/STREX en ARM).

Para usar stdatomic.h, se incluye el header y se usa el calificador _Atomic o los tipos atómicos predefinidos:

stdatomic básico
#include <stdatomic.h>

_Atomic int counter = 0;           /* variable atómica */
atomic_int counter2 = 0;           /* tipo atómico predefinido */

atomic_store(&counter, 5);         /* escritura atómica */
int val = atomic_load(&counter);   /* lectura atómica */
atomic_fetch_add(&counter, 1);     /* incremento atómico */

Las operaciones atómicas de C11 son portables: funcionan en cualquier procesador que soporte el estándar C11, sin necesidad de conocer las instrucciones del procesador.

4. Los tipos atómicos

C11 proporciona tipos atómicos para todos los tipos básicos:

Tipo atómicoTipo baseDescripción
atomic_bool_BoolBooleano atómico.
atomic_charcharCarácter atómico.
atomic_intintEntero atómico.
atomic_longlongLong atómico.
atomic_uintunsigned intEntero sin signo atómico.
atomic_size_tsize_tSize atómico.
atomic_intptr_tintptr_tPuntero atómico.
_Atomic TCualquier tipo TTipo atómico genérico.

También se puede usar el calificador _Atomic con cualquier tipo: _Atomic int, _Atomic struct foo, etc. Pero los tipos atómicos predefinidos son más convenientes.

5. Las operaciones atómicas

C11 proporciona varias operaciones atómicas:

OperaciónDescripciónEjemplo
atomic_loadLeer atómicamente.atomic_load(&x)
atomic_storeEscribir atómicamente.atomic_store(&x, 5)
atomic_fetch_addSumar y devolver el valor anterior.atomic_fetch_add(&x, 1)
atomic_fetch_subRestar y devolver el valor anterior.atomic_fetch_sub(&x, 1)
atomic_fetch_andAND bit a bit y devolver el valor anterior.atomic_fetch_and(&x, mask)
atomic_fetch_orOR bit a bit y devolver el valor anterior.atomic_fetch_or(&x, mask)
atomic_fetch_xorXOR bit a bit y devolver el valor anterior.atomic_fetch_xor(&x, mask)
atomic_exchangeIntercambiar y devolver el valor anterior.atomic_exchange(&x, 5)
atomic_compare_exchange_strongCompare-and-swap.atomic_compare_exchange_strong(&x, &expected, desired)
atomic_compare_exchange_weakCompare-and-swap (puede fallar espuriamente).atomic_compare_exchange_weak(&x, &expected, desired)

Las operaciones atomic_fetch_* devuelven el valor anterior a la operación. Las operaciones atomic_compare_exchange_* devuelven true si el intercambio se realizó.

6. El contador corregido

El contador del tema 3 se puede corregir usando atomic_fetch_add:

atomic_counter.c
#define _POSIX_C_SOURCE 200809L
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdatomic.h>

static atomic_int counter = 0;          /* atómico */

static void *incrementar(void *arg)
{
    int n = *(int *)arg;
    for (int i = 0; i < n; i++)
        atomic_fetch_add(&counter, 1);  /* atómico */
    return NULL;
}

int main(int argc, char *argv[])
{
    int n = argc > 1 ? atoi(argv[1]) : 100000;
    int hilos = argc > 2 ? atoi(argv[2]) : 4;
    if (hilos < 1 || hilos > 64)
        return 1;
    pthread_t t[64];

    for (int i = 0; i < hilos; i++)
        if (pthread_create(&t[i], NULL, incrementar, &n) != 0) {
            fprintf(stderr, "no se pudo crear el hilo %d\n", i);
            return 1;
        }
    for (int i = 0; i < hilos; i++)
        pthread_join(t[i], NULL);

    printf("esperado: %d   obtenido: %d\n", hilos * n, counter);
    return 0;
}

Compilamos y ejecutamos:

terminal · el contador atómico siempre da el resultado correcto
$ gcc -std=c17 -Wall -Wextra -pthread -o atomic_counter atomic_counter.c
$ for i in 1 2 3 4 5; do ./atomic_counter 100000 4; done
esperado: 400000   obtenido: 400000
esperado: 400000   obtenido: 400000
esperado: 400000   obtenido: 400000
esperado: 400000   obtenido: 400000
esperado: 400000   obtenido: 400000

El resultado es siempre 400.000, sin importar el número de hilos o de incrementos. La operación atómica garantiza que cada incremento se cuenta correctamente.

También se puede usar el operador ++ con variables _Atomic:

operador atómico
_Atomic int counter = 0;
counter++;  /* equivalente a atomic_fetch_add(&counter, 1) */

El compilador traduce counter++ en la instrucción atómica correspondiente (lock incl en x86).

7. atomic_fetch_add

atomic_fetch_add es la operación más usada para contadores. Incrementa el valor y devuelve el valor anterior:

atomic_fetch_add
atomic_int counter = 0;

/* Incrementar y obtener el valor anterior. */
int anterior = atomic_fetch_add(&counter, 1);
/* anterior = 0, counter = 1 */

/* Incrementar por 5. */
atomic_fetch_add(&counter, 5);
/* counter = 6 */

/* Decrementar. */
atomic_fetch_sub(&counter, 2);
/* counter = 4 */

El valor de retorno es útil para asignar identificadores únicos: cada hilo obtiene un número único sin necesidad de un mutex.

8. Compare-and-swap

Compare-and-swap (CAS) es la operación atómica más poderosa: compara el valor actual con un valor esperado y, si son iguales, lo reemplaza por un nuevo valor. Todo esto de forma atómica.

La firma es:

CAS
bool atomic_compare_exchange_strong(atomic_int *obj, int *expected, int desired);

La operación hace esto:

  1. Leer el valor actual de *obj. leer
  2. Comparar con *expected. comparar
  3. Si son iguales: escribir desired a *obj y devolver true. éxito
  4. Si son distintos: escribir el valor actual en *expected y devolver false. fallo

CAS es la base de muchas estructuras de datos sin bloqueos: pilas, colas, listas enlazadas. Permite implementar operaciones complejas de forma atómica.

CAS en acción
atomic_int x = 5;

int expected = 5;
int desired = 10;
bool ok = atomic_compare_exchange_strong(&x, &expected, desired);
/* ok = true, x = 10, expected = 5 */

/* Si x cambió entre medio: */
atomic_int y = 5;
int exp2 = 3;  /* valor incorrecto */
bool ok2 = atomic_compare_exchange_strong(&y, &exp2, 10);
/* ok2 = false, y = 5, exp2 = 5 (se actualiza con el valor actual) */

La versión weak puede fallar espuriamente (devolver false aunque los valores sean iguales). Se usa en bucles donde el fallo espurio no importa:

CAS en bucle
/* Incrementar x de forma atómica usando CAS. */
int old = atomic_load(&x);
while (!atomic_compare_exchange_weak(&x, &old, old + 1)) {
    /* old se actualiza con el valor actual si falla. */
    /* Reintentar. */
}

9. Sin bloqueos (lock-free)

Un algoritmo es sin bloqueos (lock-free) si garantiza que al menos un hilo progresa en un número finito de pasos, sin usar mutex ni otros mecanismos de bloqueo. Las operaciones atómicas (especialmente CAS) permiten implementar algoritmos lock-free.

Las ventajas de los algoritmos lock-free son:

Las desventajas son:

El problema ABA y las estructuras lock-free se estudian en el tema 43.

10. El costo de las operaciones atómicas

Las operaciones atómicas tienen un costo mayor que las no atómicas:

OperaciónCosto típicoNotas
Lectura/escritura normal1-4 ciclosAcceso a caché L1.
Lectura/escritura atómica10-50 ciclosDepende del procesador y la contención.
atomic_fetch_add10-30 ciclosInstrucción LOCK ADD.
CAS exitoso10-50 ciclosInstrucción LOCK CMPXCHG.
CAS fallido5-20 ciclosSolo lectura, sin escritura.

El costo adicional se debe a la coherencia de caché: la operación atómica debe asegurar que ningún otro procesador acceda a la misma línea de caché simultáneamente.

A pesar del costo, las operaciones atómicas son mucho más rápidas que un mutex (que implica una syscall al núcleo en caso de contención).

11. Simulador interactivo

Este simulador compara el contador con y sin atomicidad. Ejecuta ambos y observa la diferencia:

Simulador · contador atómico vs. no atómico

VersiónEsperadoObtenidoPerdidos

12. Resumen

13. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué es una operación atómica?

Una operación que se ejecuta de forma indivisible y garantiza visibilidad.

2. ¿Qué garantiza una operación atómica?

Indivisibilidad, visibilidad y orden.

3. ¿Qué header proporciona operaciones atómicas en C11?

stdatomic.h.

4. ¿Qué operación se usa para incrementar atómicamente?

atomic_fetch_add.

5. ¿Qué devuelve atomic_fetch_add?

El valor anterior a la operación.

6. ¿Qué es compare-and-swap (CAS)?

Una operación que compara el valor actual con un valor esperado y, si son iguales, lo reemplaza por un nuevo valor, todo de forma atómica.

7. ¿Qué es un algoritmo lock-free?

Un algoritmo que garantiza que al menos un hilo progresa sin usar mutex ni bloqueos.

8. ¿Cuál es el costo de una operación atómica?

10-50 ciclos (vs. 1-4 ciclos para una operación normal), debido a la coherencia de caché.

9. ¿Qué es el problema ABA?

Un valor cambia y vuelve al original, engañando al CAS.

10. ¿Cómo se usa _Atomic en C11?

Se declara la variable como _Atomic int x = 0; y luego se usa normalmente (x++ genera instrucción atómica).

Para practicar

  1. Compila y ejecuta atomic_counter.c varias veces. ¿El resultado es siempre correcto?
  2. Usa objdump para ver la instrucción generada por atomic_fetch_add.
  3. Implementa un contador usando CAS en un bucle.
  4. Compila con -fsanitize=thread y verifica que no hay carreras.
  5. Investiga el problema ABA y cómo se resuelve.
  6. Compara el rendimiento de un contador atómico vs. uno con mutex.