Saltar al contenido

¿Qué es exactamente std :: atomic?

Anduvimos recabado en el mundo on line para regalarte la respuesta a tu dilema, si continúas con dudas déjanos tu pregunta y te respondemos con mucho gusto, porque estamos para ayudarte.

Solución:

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 carreras 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 los objetos atómicos pueden establecer la sincronización entre subprocesos y ordenar los accesos a la memoria no atómica según lo especificado por std::memory_order.

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

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

  • Ordenamiento atómico y de memoria en C ++
  • Comparación: programación sin bloqueo con atomics en C ++ 11 frente a mutex y bloqueos RW
  • C ++ 11 introdujo un modelo de memoria estandarizado. ¿Qué significa? ¿Y cómo afectará a la programación en C ++?
  • Concurrencia en C ++ 11

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

std::atomic 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.

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

std::atomic 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 sola operación atómica: resultará en a.load() (que es atómico en sí mismo), luego la suma entre este valor y 12 y a.store() (también atómico) del resultado final. Como señalé anteriormente, std::memory_order_seq_cst se utilizará 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 habitual int tiene cargas atómicas y almacenes. ¿Cuál es el punto de envolverlo con atomic<>?

Tu declaración es solo true para arquitecturas que brinden 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 con palabra / palabra para ser atómica. std::atomic<> es algo que está garantizado para ser atómico en cada plataforma, sin requisitos adicionales. Además, te permite escribir código como este:

void* sharedData = nullptr;
std::atomic 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á true (y, por lo tanto, nunca se activará), por lo que siempre puede estar seguro de que los datos están listos después while salidas de bucle. Eso es porque:

  • store() a la bandera se realiza después sharedData está establecido (asumimos que generateData() siempre devuelve algo útil, en particular, nunca devuelve NULL) y usos std::memory_order_release pedido:

memory_order_release

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

  • sharedData se usa después de while salidas de bucle, y por lo tanto después de load() from flag devolverá un valor distinto de cero. load() usos std::memory_order_acquire pedido:

std::memory_order_acquire

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

Esto le da un control preciso sobre la sincronización y le permite especificar explícitamente cómo su código puede / no puede / no se comportará / 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 liberación-consumo.

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

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

a = a + 12;

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

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

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

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

La principal conclusión de este experimento es que las CPU modernas tienen soporte directo para operaciones de enteros atómicos, por ejemplo, LOCK prefix en x86, y std::atomic básicamente existe como una interfaz portátil para esas instrucciones: ¿Qué significa la instrucción “bloquear” 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, que puede hacer que las secciones de múltiples instrucciones más complejas sean atómicas, a costa de ser más lentas que std::atomic porque std::mutex hace futex llamadas 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 múltiples subprocesos que incrementa una variable global a través de múltiples subprocesos, con diferentes mecanismos de sincronización según la definición de preprocesador que se utilice.

main.cpp

#include 
#include 
#include 
#include 

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 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 en sentido ascendente.

Compilar, ejecutar y desensamblar:

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 "incorrecta" extremadamente probable para main_fail.out:

expect 400000
global 100000

y salida determinista "correcta" de los demás:

expect 400000
global 400000

Desmontaje de main_fail.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     mov    0x29b5(%rip),%rcx        # 0x5140 
   0x000000000000278b <+11>:    test   %rcx,%rcx
   0x000000000000278e <+14>:    je     0x27b4 
   0x0000000000002790 <+16>:    mov    0x29a1(%rip),%rdx        # 0x5138 
   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 
   0x00000000000027ad <+45>:    mov    %rdx,0x2984(%rip)        # 0x5138 
   0x00000000000027b4 <+52>:    retq

Desmontaje de main_std_atomic.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 
   0x000000000000278c <+12>:    je     0x27a6 
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock addq $0x1,0x299f(%rip)        # 0x5138 
   0x0000000000002799 <+25>:    add    $0x1,%rax
   0x000000000000279d <+29>:    cmp    %rax,0x299c(%rip)        # 0x5140 
   0x00000000000027a4 <+36>:    ja     0x2790 
   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 
   0x000000000000278c <+12>:    je     0x27a5 
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock incq 0x29a0(%rip)        # 0x5138 
   0x0000000000002798 <+24>:    add    $0x1,%rax
   0x000000000000279c <+28>:    cmp    %rax,0x299d(%rip)        # 0x5140 
   0x00000000000027a3 <+35>:    ja     0x2790 
   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 regresen a global con el mismo valor "incorrecto" de 100000.

  • std::atomic compila a lock addq. La cerradura prefix hace lo siguiente inc buscar, modificar y actualizar la memoria de forma atómica.

  • nuestro BLOQUEO explícito del ensamblaje en línea prefix compila casi lo mismo que std::atomic, excepto que nuestro inc se usa en lugar de add. No estoy seguro de por qué GCC eligió add, considerando que nuestro INC generó una decodificación 1 byte menor.

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

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

Eres capaz de añadir valor a nuestra información asistiendo con tu veteranía en las interpretaciones.

¡Haz clic para puntuar esta entrada!
(Votos: 0 Promedio: 0)



Utiliza Nuestro Buscador

Deja una respuesta

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