CodeGym /Cursos /C++ SELF /Operaciones std::atomic: load/ store/ exchange/ fetch_*...

Operaciones std::atomic: load/ store/ exchange/ fetch_*

C++ SELF
Nivel 72, Lección 1
Disponible

1. ¿Por qué hace falta un «diccionario de operaciones» de std::atomic?

Si abres la referencia de <atomic>, puedes tener la sensación de que has entrado por error en la facultad de hechicería: fetch_add, exchange, compare_exchange_weak, unos «expected», unos “spurious”… Y eso está bien. En esta lección haremos exactamente una cosa: reuniremos un mapa claro de operaciones y aprenderemos a elegirlas según su sentido, sin convertir el código en un ritual mágico.

Para que los ejemplos no estén desconectados de la práctica, seguiremos con nuestro mini-proyecto educativo JobRunner: una aplicación de consola que lanza varios hilos de trabajo y procesa «tareas». La cola de tareas y la espera ya las podemos implementar con std::mutex + std::condition_variable (es más simple y honesto para colas), y hoy haremos estadísticas y algunas banderas de estado pequeñas usando atómicos.

load() y store() — lectura y escritura atómica

Cuando ves por primera vez std::atomic<int>, la mente intenta simplificar: «¡Si esto es solo un int, pero honesto!». Lamentablemente (o afortunadamente), casi es así: un atomic es una variable cuyas lecturas y escrituras están sincronizadas entre hilos y no constituyen data race. Por eso las operaciones básicas son lectura (load) y escritura (store). Parecen aburridas, pero sobre ellas se sostiene toda la construcción.

Ejemplo: bandera atómica «paramos»

Imaginemos que nuestro JobRunner ejecuta tareas en varios hilos, y el hilo principal en algún momento dice «stop». La opción más simple es una bandera atómica.

#include <atomic>

int main() {
    std::atomic<bool> stop{false};

    stop.store(true);                 // marcamos "detenerse"
    bool need_stop = stop.load();     // leemos
    (void)need_stop;
}

La idea principal: store es escritura atómica, load es lectura atómica. Es decir, dos hilos pueden hacer load() en paralelo de forma segura, y un hilo puede hacer store() mientras otro hace load(). Con un bool normal habría data race.

Matiz: asignación y comodidades implícitas

std::atomic<bool> y std::atomic<int> tienen operadores que se parecen a los normales:

#include <atomic>

int main() {
    std::atomic<int> x{0};

    x = 10;          // esencialmente store(10)
    int v = x;       // esencialmente load()
    (void)v;
}

En código didáctico se puede escribir así, pero en la práctica muchos prefieren load()/store(), porque deja claro: «aquí hay un atomic, estoy haciendo una operación atómica». En código multihilo la claridad no es pedantería, es prevención del insomnio.

2. Operaciones RMW: fetch_add y compañeros

Tras load/store, la siguiente clase importante de operaciones son las read-modify-write (RMW): «leer → modificar → escribir» como una sola operación atómica. Esto es clave: no se puede «pegar» atomicidad a partir de dos operaciones atómicas si entre ellas otro hilo puede interferir.

Antiejemplo: incremento vía load + store

Este código parece lógico, pero en multihilo suele estropear el conteo:

#include <atomic>

int main() {
    std::atomic<int> counter{0};

    int old = counter.load();
    counter.store(old + 1); // entre load y store otro hilo pudo también incrementar
}

El problema es que otro hilo pudo hacer lo mismo entre tu load() y tu store(). Al final una de las incrementaciones se pierde. Esto se llama «lost update» y es un clásico.

La manera correcta: fetch_add

fetch_add(1) hace «incrementar en 1» de forma atómica. Y otro bonus importante: devuelve el valor antiguo.

#include <atomic>
#include <iostream>

int main() {
    std::atomic<int> processed{0};

    int before = processed.fetch_add(1);
    std::cout << before << '\n';           // 0 (si fue el primer incremento)
    std::cout << processed.load() << '\n'; // 1
}

Si necesitas «sacar un número de tarea», un “ticket”, o «obtener un id único», fetch_add es una de las herramientas más sencillas.

Qué fetch_* existen y qué hacen

La familia fetch_* depende del tipo T. Para enteros suelen estar disponibles:

Operación Sentido Devuelve
fetch_add(v)
sumar v valor antiguo
fetch_sub(v)
restar v valor antiguo
fetch_and(mask)
AND bit a bit valor antiguo
fetch_or(mask)
OR bit a bit valor antiguo
fetch_xor(mask)
XOR bit a bit valor antiguo

En materiales editoriales del borrador del estándar se apunta que las operaciones fetch_* se consideran junto con compare_exchange, porque pueden verse como un caso especial de actualización condicional del estado. Es una buena «modelo mental»: fetch_add es como un CAS que «siempre intenta sumar hasta que lo consigue».

Mini-integración en nuestro JobRunner: estadísticas sin mutex

Supongamos que cada worker tras procesar una tarea incrementa el contador processed, y en caso de error — failed.

#include <atomic>

struct Stats {
    std::atomic<int> processed{0};
    std::atomic<int> failed{0};
};

void on_task_done(Stats& s, bool ok) {
    s.processed.fetch_add(1);
    if (!ok) {
        s.failed.fetch_add(1);
    }
}

Este es un escenario típico correcto para atómicos: contadores independientes, sin invariantes complejos «entre ellos», solo estadísticas.

4. exchange() — reemplazo atómico con retorno del valor antiguo

A veces quieres no «incrementar», sino «reemplazar el valor» de forma que nadie pueda colarse entre lectura y escritura, y además te importa saber qué había antes. Para eso existe exchange(new_value): escribe atómicamente new_value y devuelve el valor antiguo.

Ejemplo: «quién activó primero el modo»

Supongamos que en JobRunner hay un modo de “verbose logging”, y queremos que el mensaje "Verbose enabled" se imprima exactamente una vez, incluso si varios hilos intentan «activar».

#include <atomic>
#include <iostream>

int main() {
    std::atomic<bool> verbose{false};

    bool was = verbose.exchange(true);
    if (!was) {
        std::cout << "Verbose enabled\n"; // Verbose enabled
    }
}

Si was era false, significa que tú eres el primero en poner true. Si was ya era true, alguien activó antes.

Por qué esto es mejor que if (!load()) store(true)

Porque ese código no es atómico como «comprobar y setear»:

#include <atomic>

void enable(std::atomic<bool>& flag) {
    if (!flag.load()) {   // otro hilo pudo cambiar entre las líneas
        flag.store(true);
    }
}

Si dos hilos ven simultáneamente false, ambos harán store(true) y ambos creerán que fueron «los primeros». exchange resuelve esto de forma clara y breve.

5. CAS: compare_exchange_* — actualización condicional

Si fetch_add es un botón cómodo “+1”, CAS (compare-and-swap) es el bloque constructivo universal. Permite decir al átomo: «Actualiza el valor solo si ahora es igual a lo que espero». Esto sirve para actualizaciones concurrentes donde no queremos perder cambios de otro hilo.

Idea básica

CAS es una operación del tipo:

  • tenemos un atomic x
  • tenemos una variable expected (valor esperado)
  • tenemos desired (valor que queremos poner)

Entonces CAS hace algo así: «si x == expected, entonces x = desired y éxito; si no, fallo».

Matiz crucial: expected — entrada y salida

El parámetro expected se pasa por referencia. Y no es un capricho: cuando CAS falla escribe en expected el valor actual del atomic. Esto es muy útil para bucles.

#include <atomic>
#include <iostream>

int main() {
    std::atomic<int> state{0};

    int expected = 0;
    if (state.compare_exchange_strong(expected, 1)) {
        std::cout << "changed\n";           // changed
    } else {
        std::cout << "not changed, actual=" << expected << '\n';
    }
}

Si CAS no tuvo éxito, en expected quedará «lo que realmente había en state», y puedes decidir: reintentar, salir, o usar otro desired.

compare_exchange_strong vs compare_exchange_weak

Existen dos versiones:

  • compare_exchange_strong — «fuerte»: si falla, casi siempre es porque el valor realmente no coincidía.
  • compare_exchange_weak — «débil»: puede fallar a veces incluso si el valor coincidía (los llamados spurious failures).

Suena raro, pero en algunas arquitecturas «weak» puede ser más barato y adaptarse mejor al hardware. La regla práctica en C++ es: weak suele usarse en un bucle, y eso es normal.

Bucle típico de CAS: incremento manual

Mostramos un incremento mediante CAS. En la práctica se suele usar fetch_add, pero para aprender CAS es un buen ejemplo.

#include <atomic>
#include <iostream>

int main() {
    std::atomic<int> x{0};

    int old = x.load();
    while (!x.compare_exchange_weak(old, old + 1)) {
        // si CAS falla, old ya se actualiza al valor actual de x
    }

    std::cout << x.load() << '\n'; // 1
}

Fíjate en un detalle psicológicamente extraño: la variable old en el bucle «se altera por sí misma» cuando CAS falla — porque ese es el contrato de expected.

CAS como state machine: transición de estado

En JobRunner puedes tener un estado de ejecución: por ejemplo, 0 — «Idle», 1 — «Running», 2 — «Stopping». Y queremos permitir la transición «Idle → Running» sólo a un hilo.

#include <atomic>

bool try_start(std::atomic<int>& st) {
    int expected = 0;                      // esperamos Idle
    return st.compare_exchange_strong(expected, 1); // ponemos Running
}

Si dos hilos llaman simultáneamente a try_start, sólo uno obtendrá true. El segundo obtendrá false, y en su expected quedará el estado actual (probablemente 1).

Patrón CAS: máximo atómico (max)

Otro ejemplo clásico: «recordar el máximo entre muchos hilos». No existe fetch_max en la base estándar, pero se puede implementar con CAS.

#include <atomic>

void update_max(std::atomic<int>& m, int value) {
    int cur = m.load();
    while (cur < value && !m.compare_exchange_weak(cur, value)) {
        // si falla, cur se actualizará al valor actual de m
    }
}

La lógica importante: si cur ya es >= value, salimos inmediatamente. Si no, intentamos poner value. Si otro hilo ya elevó el máximo, CAS fallará, cur se actualizará y volveremos a comprobar la condición.

Puedes imaginar esto como un pequeño diagrama de flujo:

flowchart TD
    A[load cur] --> B{cur < value?}
    B -- no --> E[salir]
    B -- yes --> C{"¿CAS(cur -> value) exitoso?"}
    C -- yes --> E
    C -- no --> A

Matiz: atomic<float> y la comparación

Para enteros compare_exchange suele ser intuitivo: comparas números y coinciden o no. Pero con float/double aparecen sutilezas como NaN y diferencias finas de representación (por ejemplo, -0.0 y 0.0). En anotaciones del estándar se menciona que hubo preguntas y aclaraciones sobre compare_exchange para puntos flotantes precisamente por estos casos.

Recomendación práctica para quien empieza: si realmente necesitas una actualización lock-free de double, piensa muy claro qué significa «igualdad» en tu protocolo. Muchas veces es más sencillo guardar un entero en «milli-unidades», o proteger todo con un mutex.

Cómo elegir la operación correcta y no caer en el culto al CAS

Tras conocer compare_exchange a muchos les entra el enamoramiento por CAS: «¡Genial, entonces podemos prescindir totalmente de mutex!». En ese momento conviene respirar y recordar que los mutex no fueron inventados por enemigos, sino por ingenieros cansados de arreglar nervios. Los atómicos son buenos cuando realmente trabajas con una sola variable o con un protocolo muy simple.

Si solo necesitas contar eventos, fetch_add dará un código corto y claro. Si necesitas «activar una vez», exchange suele leerse más fácil que CAS. Si necesitas actualizar condicionalmente el estado o implementar un max atómico, entonces CAS es la herramienta correcta. Pero si tienes un invariante que implica «dos campos deben cambiarse de forma consistente» (por ejemplo, count y sum, o una cola con índices), a menudo gana std::mutex, porque protege la sección crítica, no solo una variable aislada.

Y otra regla práctica: si tu bucle CAS ha crecido, dentro aparecieron cálculos complejos, accesos a contenedores, llamadas a funciones con efectos secundarios — probablemente intentas con atómicos lo que sería más simple y seguro expresar con un bloqueo.

6. Errores típicos al trabajar con load/store/exchange/fetch_*

Error nº1: incrementar vía store(load()+1) y pérdida de actualizaciones.
Esta es una de las trampas lógicas más frecuentes: ambas llamadas son atómicas, pero juntas no forman un «incremento» atómico. Entre load() y store() otro hilo puede cambiar el valor y sobrescribir su trabajo. Si necesitas un contador, usa fetch_add o un bucle CAS.

Error nº2: olvidar inicializar expected antes de compare_exchange_*.
CAS compara el valor actual con lo que pasaste en expected. Si expected no está definido, comparas el atomic con basura y luego te sorprende que «nunca pasa». expected siempre debe comenzar con un valor con sentido: «qué espero ver».

Error nº3: no entender que expected se modifica al fallar el CAS.
Muchos principiantes escriben CAS y luego en la rama else suponen que expected sigue siendo el viejo esperado. En realidad, cuando falla, expected se sobrescribe con el valor actual del atomic. No es un bug, es una característica: así es fácil construir bucles CAS.

Error nº4: usar compare_exchange_weak sin bucle.
weak puede fallar «sin motivo», incluso si el valor coincidía. Por eso una llamada aislada a compare_exchange_weak a veces crea bugs extremadamente extraños y raros. Si usas weak, casi siempre usa un bucle. Si no necesitas bucle y prefieres simplicidad — usa strong.

Error nº5: hacer trabajo pesado dentro del bucle CAS.
Un bucle CAS puede repetirse muchas veces bajo contención. Si dentro del bucle imprimes en consola, accedes a contenedores, llamas a funciones complejas o, Dios no lo quiera, duermes — puedes sufrir una caída catastrófica de rendimiento y un comportamiento muy difícil de razonar. En el bucle CAS es recomendable dejar solo cálculos cortos y la propia compare_exchange.

1
Tarea
C++ SELF, nivel 72, lección 1
Bloqueada
Cápsula atómica
Cápsula atómica
1
Tarea
C++ SELF, nivel 72, lección 1
Bloqueada
Selector de modo
Selector de modo
1
Tarea
C++ SELF, nivel 72, lección 1
Bloqueada
Cola de tickets
Cola de tickets
1
Tarea
C++ SELF, nivel 72, lección 1
Bloqueada
Récord de altura
Récord de altura
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION