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.
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:
- Indivisibilidad: la operación se ejecuta completamente o no se ejecuta. No hay estados intermedios observables.
- Visibilidad: los cambios son visibles para todos los hilos inmediatamente. No hay caché obsoleta.
- Orden: las operaciones atómicas tienen un orden definido (sequentially consistent por defecto).
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:
#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ómico | Tipo base | Descripción |
|---|---|---|
atomic_bool | _Bool | Booleano atómico. |
atomic_char | char | Carácter atómico. |
atomic_int | int | Entero atómico. |
atomic_long | long | Long atómico. |
atomic_uint | unsigned int | Entero sin signo atómico. |
atomic_size_t | size_t | Size atómico. |
atomic_intptr_t | intptr_t | Puntero atómico. |
_Atomic T | Cualquier tipo T | Tipo 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ón | Descripción | Ejemplo |
|---|---|---|
atomic_load | Leer atómicamente. | atomic_load(&x) |
atomic_store | Escribir atómicamente. | atomic_store(&x, 5) |
atomic_fetch_add | Sumar y devolver el valor anterior. | atomic_fetch_add(&x, 1) |
atomic_fetch_sub | Restar y devolver el valor anterior. | atomic_fetch_sub(&x, 1) |
atomic_fetch_and | AND bit a bit y devolver el valor anterior. | atomic_fetch_and(&x, mask) |
atomic_fetch_or | OR bit a bit y devolver el valor anterior. | atomic_fetch_or(&x, mask) |
atomic_fetch_xor | XOR bit a bit y devolver el valor anterior. | atomic_fetch_xor(&x, mask) |
atomic_exchange | Intercambiar y devolver el valor anterior. | atomic_exchange(&x, 5) |
atomic_compare_exchange_strong | Compare-and-swap. | atomic_compare_exchange_strong(&x, &expected, desired) |
atomic_compare_exchange_weak | Compare-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:
#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:
$ 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:
_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_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:
bool atomic_compare_exchange_strong(atomic_int *obj, int *expected, int desired);
La operación hace esto:
- Leer el valor actual de
*obj. leer - Comparar con
*expected. comparar - Si son iguales: escribir
desireda*objy devolvertrue. éxito - Si son distintos: escribir el valor actual en
*expectedy devolverfalse. fallo
CAS es la base de muchas estructuras de datos sin bloqueos: pilas, colas, listas enlazadas. Permite implementar operaciones complejas de forma atómica.
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:
/* 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:
- Sin deadlock: no hay candados que se puedan olvidar de liberar.
- Sin inversión de prioridades: no hay candados que causen inversión.
- Progreso garantizado: al menos un hilo siempre progresa.
- Mejor rendimiento bajo baja contención: no hay overhead de adquirir/liberar candados.
Las desventajas son:
- Complejidad: los algoritmos lock-free son difíciles de diseñar y verificar.
- Problema ABA: un valor puede cambiar y volver al original, engañando al CAS.
- Rendimiento bajo alta contención: los reintentos de CAS pueden ser costosos.
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ón | Costo típico | Notas |
|---|---|---|
| Lectura/escritura normal | 1-4 ciclos | Acceso a caché L1. |
| Lectura/escritura atómica | 10-50 ciclos | Depende del procesador y la contención. |
| atomic_fetch_add | 10-30 ciclos | Instrucción LOCK ADD. |
| CAS exitoso | 10-50 ciclos | Instrucción LOCK CMPXCHG. |
| CAS fallido | 5-20 ciclos | Solo 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ón | Esperado | Obtenido | Perdidos |
|---|
12. Resumen
- Una operación atómica es indivisible y garantiza visibilidad.
- C11 proporciona stdatomic.h con tipos y operaciones atómicas portables.
- Los tipos atómicos incluyen atomic_int, atomic_long, atomic_bool, etc.
- Las operaciones incluyen load, store, fetch_add, fetch_sub, compare_exchange, etc.
- atomic_fetch_add incrementa y devuelve el valor anterior.
- CAS es la operación más poderosa: compara y reemplaza atómicamente.
- Los algoritmos lock-free usan CAS para evitar mutex.
- Las operaciones atómicas tienen un costo mayor que las normales (coherencia de caché).
- En C11, _Atomic genera instrucciones atómicas automáticamente.
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
- Compila y ejecuta atomic_counter.c varias veces. ¿El resultado es siempre correcto?
- Usa objdump para ver la instrucción generada por atomic_fetch_add.
- Implementa un contador usando CAS en un bucle.
- Compila con -fsanitize=thread y verifica que no hay carreras.
- Investiga el problema ABA y cómo se resuelve.
- Compara el rendimiento de un contador atómico vs. uno con mutex.