¿Qué es exactamente std :: atomic?

173

Entiendo que std::atomic<>es un objeto atómico. ¿Pero atómico hasta qué punto? A mi entender, una operación puede ser atómica. ¿Qué se entiende exactamente por hacer un objeto atómico? Por ejemplo, si hay dos subprocesos que ejecutan simultáneamente el siguiente código:

a = a + 12;

Entonces, ¿toda la operación (por ejemplo add_twelve_to(int)) es atómica? ¿O se realizan cambios en la variable atómica (so operator=())?

curioso
fuente
9
Necesitas usar algo como a.fetch_add(12)si quieres un RMW atómico.
Kerrek SB
Sí, eso es lo que no entiendo. Qué se entiende por hacer un objeto atómico. Si hubiera una interfaz, simplemente podría haberse hecho atómica con un mutex o un monitor.
2
@AaryamanSagar resuelve un problema de eficiencia. Mutexes y monitores llevan sobrecarga computacional. El uso std::atomicpermite que la biblioteca estándar decida qué se necesita para lograr la atomicidad.
Drew Dormann
1
@AaryamanSagar: std::atomic<T>es un tipo que permite operaciones atómicas. No mágicamente mejora tu vida, todavía tienes que saber qué quieres hacer con ella. Es para un caso de uso muy específico, y los usos de las operaciones atómicas (en el objeto) son generalmente muy sutiles y deben considerarse desde una perspectiva no local. Entonces, a menos que ya lo sepa y por qué desea operaciones atómicas, el tipo probablemente no sea de mucha utilidad para usted.
Kerrek SB

Respuestas:

188

Cada instanciación y especialización completa de std :: atomic <> representa un tipo en el que diferentes subprocesos pueden operar simultáneamente (sus instancias), sin generar un comportamiento indefinido:

Los objetos de tipo atómico son los únicos objetos de C ++ que están libres de razas de datos; es decir, si un hilo escribe en un objeto atómico mientras otro hilo lee de él, el comportamiento está bien definido.

Además, los accesos a objetos atómicos pueden establecer sincronización entre subprocesos y ordenar accesos de memoria no atómica según lo especificado por std::memory_order.

std::atomic<>envuelve operaciones que, antes de C ++ 11 veces, tenían que realizarse utilizando (por ejemplo) funciones entrelazadas con MSVC o bultinas atómicas en el caso de CCG.

Además, std::atomic<>le brinda más control al permitir varias órdenes de memoria que especifican restricciones de sincronización y orden. Si desea leer más sobre el modelo atómico y de memoria de C ++ 11, estos enlaces pueden ser útiles:

Tenga en cuenta que, para casos de uso típicos, probablemente usaría operadores aritméticos sobrecargados u otro conjunto de ellos :

std::atomic<long> value(0);
value++; //This is an atomic op
value += 5; //And so is this

Debido a que la sintaxis del operador no le permite especificar el orden de la memoria, estas operaciones se realizarán con std::memory_order_seq_cst, ya que este es el orden predeterminado para todas las operaciones atómicas en C ++ 11. Garantiza la coherencia secuencial (orden global total) entre todas las operaciones atómicas.

Sin embargo, en algunos casos, esto puede no ser necesario (y nada es gratis), por lo que es posible que desee utilizar una forma más explícita:

std::atomic<long> value {0};
value.fetch_add(1, std::memory_order_relaxed); // Atomic, but there are no synchronization or ordering constraints
value.fetch_add(5, std::memory_order_release); // Atomic, performs 'release' operation

Ahora, tu ejemplo:

a = a + 12;

no evaluará a una única operación atómica: dará como resultado a.load()(que es atómico en sí mismo), luego la suma entre este valor 12y a.store()(también atómico) del resultado final. Como señalé anteriormente, std::memory_order_seq_cstse usará aquí.

Sin embargo, si escribe a += 12, será una operación atómica (como señalé antes) y es aproximadamente equivalente a a.fetch_add(12, std::memory_order_seq_cst).

En cuanto a tu comentario:

Un regular inttiene cargas atómicas y tiendas. ¿Cuál es el punto de envolverlo atomic<>?

Su declaración solo es cierta para las arquitecturas que brindan dicha garantía de atomicidad para tiendas y / o cargas. Hay arquitecturas que no hacen esto. Además, generalmente se requiere que las operaciones se realicen en una dirección alineada palabra / palabra para que sea atómico, std::atomic<>es algo que se garantiza que sea atómico en todas las plataformas, sin requisitos adicionales. Además, te permite escribir código como este:

void* sharedData = nullptr;
std::atomic<int> ready_flag = 0;

// Thread 1
void produce()
{
    sharedData = generateData();
    ready_flag.store(1, std::memory_order_release);
}

// Thread 2
void consume()
{
    while (ready_flag.load(std::memory_order_acquire) == 0)
    {
        std::this_thread::yield();
    }

    assert(sharedData != nullptr); // will never trigger
    processData(sharedData);
}

Tenga en cuenta que la condición de aserción siempre será verdadera (y, por lo tanto, nunca se activará), por lo que siempre puede estar seguro de que los datos están listos después de las whilesalidas de bucle. Eso es porque:

  • store()al indicador se realiza después de que sharedDatase establece (suponemos que generateData()siempre devuelve algo útil, en particular, nunca regresa NULL) y utiliza el std::memory_order_releaseorden:

memory_order_release

Una operación de almacenamiento con este orden de memoria realiza la operación de liberación : no se pueden reordenar lecturas o escrituras en el hilo actual después de este almacenamiento. Todas las escrituras en el hilo actual son visibles en otros hilos que adquieren la misma variable atómica

  • sharedDatase usa después de las whilesalidas de bucle y, por lo tanto, después load()del indicador devolverá un valor distinto de cero. load()usa std::memory_order_acquireorden:

std::memory_order_acquire

Una operación de carga con este orden de memoria realiza la operación de adquisición en la ubicación de memoria afectada: no se pueden reordenar lecturas o escrituras en el subproceso actual antes de esta carga. Todas las escrituras en otros hilos que liberan la misma variable atómica son visibles en el hilo actual .

Esto le brinda un control preciso sobre la sincronización y le permite especificar explícitamente cómo su código puede / no puede / no / se comportará. Esto no sería posible si la única garantía fuera la atomicidad misma. Especialmente cuando se trata de modelos de sincronización muy interesantes como el pedido de lanzamiento de consumo .

Mateusz Grzejek
fuente
2
¿Existen realmente arquitecturas que no tienen cargas atómicas y almacenes de primitivas como ints?
77
No se trata solo de atomicidad. también se trata de pedidos, comportamiento en sistemas de múltiples núcleos, etc. Es posible que desee leer este artículo .
Mateusz Grzejek
44
@AaryamanSagar Si no me equivoco, incluso en x86 las lecturas y escrituras son atómicas SOLAMENTE si están alineadas en los límites de las palabras.
v.shashenko
@MateuszGrzejek He tomado una referencia a un tipo atómico. ¿Podría verificar amablemente si lo siguiente aún garantizaría la operación atómica en la asignación de objetos ideone.com/HpSwqo
xAditya3393
3
@TimMB Sí, normalmente, tendría (al menos) dos situaciones en las que se podría alterar el orden de ejecución: (1) el compilador puede reordenar las instrucciones (tanto como lo permita el estándar) para proporcionar un mejor rendimiento del código de salida (según el uso de registros de CPU, predicciones, etc.) y (2) la CPU puede ejecutar instrucciones en un orden diferente para, por ejemplo, minimizar el número de puntos de sincronización de caché. Las restricciones de pedido proporcionadas para std::atomic( std::memory_order) sirven exactamente para limitar los reordenamientos que se permiten.
Mateusz Grzejek
20

Entiendo que eso std::atomic<>hace que un objeto sea atómico.

Eso es una cuestión de perspectiva ... no puede aplicarlo a objetos arbitrarios y hacer que sus operaciones se vuelvan atómicas, pero se pueden utilizar las especializaciones proporcionadas para (la mayoría) tipos y punteros integrales.

a = a + 12;

std::atomic<>no (utiliza expresiones de plantilla para) simplificar esto a una sola operación atómica, en su lugar, el operator T() const volatile noexceptmiembro hace un atómico load()de a, luego se agrega doce y operator=(T t) noexcepthace un store(t).

Tony Delroy
fuente
Eso era lo que quería preguntar. Un int regular tiene cargas atómicas y tiendas. ¿Cuál es el punto de envolverlo con atómica <>
8
@AaryamanSagar Simplemente modificar una normal intno garantiza de manera portátil que el cambio sea visible desde otros subprocesos, ni leerlo asegura que vea los cambios de otros subprocesos, y algunas cosas como my_int += 3no se garantiza que se realicen atómicamente a menos que lo use std::atomic<>, pueden involucrar una búsqueda, luego agregue, luego almacene la secuencia, en la que algún otro hilo que intente actualizar el mismo valor podría aparecer después de la búsqueda y antes de la tienda, y cerrar la actualización de su hilo.
Tony Delroy
"La simple modificación de un int normal no garantiza que el cambio sea visible desde otros hilos " Es peor que eso: cualquier intento de medir esa visibilidad daría como resultado UB.
curioso
8

std::atomic existe porque muchos ISA tienen soporte directo de hardware para ello

Lo que dice el estándar C ++ sobre std::atomicha sido analizado en otras respuestas.

Así que ahora veamos qué std::atomiccompila para obtener un tipo diferente de información.

La conclusión principal de este experimento es que las CPU modernas tienen soporte directo para operaciones de números enteros atómicos, por ejemplo, el prefijo LOCK en x86, y std::atomicbásicamente existe como una interfaz portátil para esas instrucciones: ¿Qué significa la instrucción "lock" en el ensamblaje x86? En aarch64, se usaría LDADD .

Este soporte permite alternativas más rápidas a métodos más generales como std::mutex, por ejemplo , que puede hacer que las secciones de múltiples instrucciones más complejas sean atómicas, a costa de ser más lento que std::atomicporque std::mutexhace futexllamadas al sistema en Linux, que es mucho más lento que las instrucciones de usuario emitidas por std::atomic, vea también: ¿std :: mutex crea una cerca?

Consideremos el siguiente programa de subprocesos múltiples que incrementa una variable global en varios subprocesos, con diferentes mecanismos de sincronización según la definición de preprocesador utilizada.

main.cpp

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

size_t niters;

#if STD_ATOMIC
std::atomic_ulong global(0);
#else
uint64_t global = 0;
#endif

void threadMain() {
    for (size_t i = 0; i < niters; ++i) {
#if LOCK
        __asm__ __volatile__ (
            "lock incq %0;"
            : "+m" (global),
              "+g" (i) // to prevent loop unrolling
            :
            :
        );
#else
        __asm__ __volatile__ (
            ""
            : "+g" (i) // to prevent he loop from being optimized to a single add
            : "g" (global)
            :
        );
        global++;
#endif
    }
}

int main(int argc, char **argv) {
    size_t nthreads;
    if (argc > 1) {
        nthreads = std::stoull(argv[1], NULL, 0);
    } else {
        nthreads = 2;
    }
    if (argc > 2) {
        niters = std::stoull(argv[2], NULL, 0);
    } else {
        niters = 10;
    }
    std::vector<std::thread> threads(nthreads);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i] = std::thread(threadMain);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i].join();
    uint64_t expect = nthreads * niters;
    std::cout << "expect " << expect << std::endl;
    std::cout << "global " << global << std::endl;
}

GitHub aguas arriba .

Compilar, ejecutar y desmontar:

comon="-ggdb3 -O3 -std=c++11 -Wall -Wextra -pedantic main.cpp -pthread"
g++ -o main_fail.out                    $common
g++ -o main_std_atomic.out -DSTD_ATOMIC $common
g++ -o main_lock.out       -DLOCK       $common

./main_fail.out       4 100000
./main_std_atomic.out 4 100000
./main_lock.out       4 100000

gdb -batch -ex "disassemble threadMain" main_fail.out
gdb -batch -ex "disassemble threadMain" main_std_atomic.out
gdb -batch -ex "disassemble threadMain" main_lock.out

Salida de condición de carrera extremadamente "incorrecta" para main_fail.out:

expect 400000
global 100000

y salida determinista "correcta" de los otros:

expect 400000
global 400000

Desmontaje de main_fail.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     mov    0x29b5(%rip),%rcx        # 0x5140 <niters>
   0x000000000000278b <+11>:    test   %rcx,%rcx
   0x000000000000278e <+14>:    je     0x27b4 <threadMain()+52>
   0x0000000000002790 <+16>:    mov    0x29a1(%rip),%rdx        # 0x5138 <global>
   0x0000000000002797 <+23>:    xor    %eax,%eax
   0x0000000000002799 <+25>:    nopl   0x0(%rax)
   0x00000000000027a0 <+32>:    add    $0x1,%rax
   0x00000000000027a4 <+36>:    add    $0x1,%rdx
   0x00000000000027a8 <+40>:    cmp    %rcx,%rax
   0x00000000000027ab <+43>:    jb     0x27a0 <threadMain()+32>
   0x00000000000027ad <+45>:    mov    %rdx,0x2984(%rip)        # 0x5138 <global>
   0x00000000000027b4 <+52>:    retq

Desmontaje de main_std_atomic.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a6 <threadMain()+38>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock addq $0x1,0x299f(%rip)        # 0x5138 <global>
   0x0000000000002799 <+25>:    add    $0x1,%rax
   0x000000000000279d <+29>:    cmp    %rax,0x299c(%rip)        # 0x5140 <niters>
   0x00000000000027a4 <+36>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a6 <+38>:    retq   

Desmontaje de main_lock.out:

Dump of assembler code for function threadMain():
   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a5 <threadMain()+37>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock incq 0x29a0(%rip)        # 0x5138 <global>
   0x0000000000002798 <+24>:    add    $0x1,%rax
   0x000000000000279c <+28>:    cmp    %rax,0x299d(%rip)        # 0x5140 <niters>
   0x00000000000027a3 <+35>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a5 <+37>:    retq

Conclusiones:

  • la versión no atómica guarda el global en un registro e incrementa el registro.

    Por lo tanto, al final, es muy probable que cuatro escrituras vuelvan a ser globales con el mismo valor "incorrecto" 100000.

  • std::atomiccompila a lock addq. El prefijo LOCK realiza la siguiente incbúsqueda, modificación y actualización de memoria atómicamente.

  • nuestro prefijo LOCK de ensamblaje en línea explícito se compila casi de la misma manera que std::atomic, excepto que incse usa nuestro en lugar de add. No estoy seguro de por qué eligió GCC add, teniendo en cuenta que nuestro INC generó una decodificación de 1 byte más pequeño.

ARMv8 podría usar LDAXR + STLXR o LDADD en las CPU más nuevas: ¿Cómo inicio hilos en C simple?

Probado en Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51.

Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
fuente