¿Puede num ++ ser atómico para 'int num'?

153

En general, for int num, num++(or ++num), como una operación de lectura-modificación-escritura, no es atómica . Pero a menudo veo que los compiladores, por ejemplo GCC , generan el siguiente código ( intente aquí ):

Ingrese la descripción de la imagen aquí

Dado que la línea 5, que corresponde a num++una instrucción, ¿podemos concluir que num++ es atómica en este caso?

Y si es así, ¿significa que así generado num++puede usarse en escenarios concurrentes (multiproceso) sin ningún peligro de carreras de datos (es decir, no necesitamos hacerlo, por ejemplo, std::atomic<int>e imponer los costos asociados, ya que es atómico de todos modos)?

ACTUALIZAR

Tenga en cuenta que esta pregunta no es si el incremento es atómico (no lo es y esa fue y es la línea de apertura de la pregunta). Es si puede ser en escenarios particulares, es decir, si la naturaleza de una instrucción se puede explotar en ciertos casos para evitar la sobrecarga del lockprefijo. Y, como la respuesta aceptada menciona en la sección sobre máquinas uniprocesadoras, así como esta respuesta , la conversación en sus comentarios y otros explican que puede (aunque no con C o C ++).

Leo Heinsaar
fuente
65
¿Quién te dijo que eso addes atómico?
Slava
66
dado que una de las características de las atómicas es la prevención de determinados tipos de reordenamiento durante la optimización, no, independientemente de la atomicidad de la operación real
jaggedSpire
19
También me gustaría señalar que si esto es atómico en su plataforma, no hay garantía de que esté en otra plataforma. Sea independiente de la plataforma y exprese su intención utilizando a std::atomic<int>.
NathanOliver
8
Durante la ejecución de esa addinstrucción, otro núcleo podría robar esa dirección de memoria del caché de este núcleo y modificarla. En una CPU x86, la addinstrucción necesita un lockprefijo si la dirección debe bloquearse en la memoria caché durante la operación.
David Schwartz
21
Es posible que cualquier operación sea "atómica". Todo lo que tiene que hacer es tener suerte y nunca ejecutar algo que revele que no es atómico. Atomic solo es valioso como garantía . Dado que está viendo el código de ensamblaje, la pregunta es si esa arquitectura particular le proporciona la garantía y si el compilador le garantiza que esa es la implementación de nivel de ensamblaje que eligen.
Cort Ammon

Respuestas:

197

Esto es absolutamente lo que C ++ define como una carrera de datos que causa un comportamiento indefinido, incluso si un compilador produce código que hizo lo que esperaba en alguna máquina de destino. Debe usarlo std::atomicpara obtener resultados confiables, pero puede usarlo memory_order_relaxedsi no le importa reordenar. Vea a continuación algunos ejemplos de código y salida asm usando fetch_add.


Pero primero, el lenguaje ensamblador parte de la pregunta:

Dado que num ++ es una instrucción ( add dword [num], 1), ¿podemos concluir que num ++ es atómico en este caso?

Las instrucciones de destino de memoria (que no sean almacenes puros) son operaciones de lectura-modificación-escritura que ocurren en múltiples pasos internos . No se modifica ningún registro arquitectónico, pero la CPU debe retener los datos internamente mientras los envía a través de su ALU . El archivo de registro real es solo una pequeña parte del almacenamiento de datos dentro de incluso la CPU más simple, con pestillos que contienen salidas de una etapa como entradas para otra etapa, etc., etc.

Las operaciones de memoria de otras CPU pueden hacerse visibles globalmente entre la carga y el almacén. Es decir, dos subprocesos que se ejecutan add dword [num], 1en un bucle pisarían las tiendas del otro. (Ver la respuesta de @ Margaret para un buen diagrama). Después de incrementos de 40k de cada uno de los dos subprocesos, el contador podría haber subido ~ 60k (no 80k) en hardware x86 real de múltiples núcleos.


"Atómico", de la palabra griega que significa indivisible, significa que ningún observador puede ver la operación como pasos separados. Suceder física / eléctricamente instantáneamente para todos los bits simultáneamente es solo una forma de lograr esto para una carga o almacenamiento, pero eso ni siquiera es posible para una operación ALU. Entré en muchos más detalles sobre cargas puras y tiendas puras en mi respuesta a Atomicity en x86 , mientras que esta respuesta se enfoca en lectura-modificación-escritura.

El lockprefijo se puede aplicar a muchas instrucciones de lectura-modificación-escritura (destino de memoria) para hacer que toda la operación sea atómica con respecto a todos los posibles observadores en el sistema (otros núcleos y dispositivos DMA, no un osciloscopio conectado a los pines de la CPU). Por eso existe. (Ver también estas preguntas y respuestas ).

Entonces lock add dword [num], 1 es atómico . Un núcleo de CPU que ejecuta esa instrucción mantendría la línea de caché anclada en estado Modificado en su caché L1 privada desde que la carga lee los datos de la caché hasta que la tienda confirma su resultado nuevamente en la caché. Esto evita que cualquier otra caché en el sistema tenga una copia de la línea de caché en cualquier punto de la carga al almacén, de acuerdo con las reglas del protocolo de coherencia de caché MESI (o las versiones MOESI / MESIF de él utilizadas por AMD multinúcleo / CPU de Intel, respectivamente). Por lo tanto, las operaciones de otros núcleos parecen ocurrir antes o después, no durante.

Sin el lockprefijo, otro núcleo podría tomar posesión de la línea de caché y modificarla después de nuestra carga, pero antes de nuestra tienda, para que otra tienda se vuelva globalmente visible entre nuestra carga y la tienda. Varias otras respuestas se equivocan y afirman que sin lockobtener copias en conflicto de la misma línea de caché. Esto nunca puede suceder en un sistema con cachés coherentes.

(Si una lockinstrucción ed funciona en memoria que abarca dos líneas de caché, se necesita mucho más trabajo para asegurarse de que los cambios en ambas partes del objeto permanezcan atómicos mientras se propagan a todos los observadores, de modo que ningún observador pueda ver desgarros. La CPU podría tiene que bloquear todo el bus de memoria hasta que los datos lleguen a la memoria. ¡No desalinee sus variables atómicas!)

Tenga en cuenta que el lockprefijo también convierte una instrucción en una barrera de memoria completa (como MFENCE ), deteniendo todo el reordenamiento en tiempo de ejecución y, por lo tanto, brinda coherencia secuencial. (Vea la excelente publicación de blog de Jeff Preshing . Sus otras publicaciones también son excelentes, y explican claramente muchas cosas buenas sobre programación sin bloqueo , desde x86 y otros detalles de hardware hasta reglas de C ++).


En una máquina de un solo procesador, o en un proceso de subproceso único, una sola instrucción RMW en realidad es atómica sin un lockprefijo. La única forma de que otro código acceda a la variable compartida es que la CPU realice un cambio de contexto, lo que no puede suceder en medio de una instrucción. Por lo tanto, un plano dec dword [num]puede sincronizarse entre un programa de subproceso único y sus controladores de señal, o en un programa de subprocesos múltiples que se ejecuta en una máquina de un solo núcleo. Vea la segunda mitad de mi respuesta sobre otra pregunta , y los comentarios debajo, donde explico esto con más detalle.


De vuelta a C ++:

Es totalmente falso usarlo num++sin decirle al compilador que necesita compilarlo en una sola implementación de lectura-modificación-escritura:

;; Valid compiler output for num++
mov   eax, [num]
inc   eax
mov   [num], eax

Esto es muy probable si usa el valor de numlater: el compilador lo mantendrá en vivo en un registro después del incremento. Entonces, incluso si verifica cómo se num++compila por sí mismo, cambiar el código circundante puede afectarlo.

(Si no se necesita el valor más adelante, inc dword [num]se prefiere; las CPU modernas x86 ejecutarán una instrucción RMW de destino de memoria al menos tan eficientemente como usando tres instrucciones separadas. Dato curioso: en gcc -O3 -m32 -mtune=i586realidad emitirá esto , porque la tubería superescalar de (Pentium) P5 no No decodifique instrucciones complejas para múltiples microoperaciones simples como lo hacen P6 y microarquitecturas posteriores. Consulte las tablas de instrucciones / guía de microarquitectura de Agner Fog para obtener más información, y etiqueta wiki para muchos enlaces útiles (incluidos los manuales ISA x86 de Intel, que están disponibles gratuitamente como PDF).


No confunda el modelo de memoria de destino (x86) con el modelo de memoria C ++

Se permite la reordenación en tiempo de compilación . La otra parte de lo que obtienes con std :: atomic es el control sobre el reordenamiento en tiempo de compilación, para asegurarte de que tunum++visibilidad sea global solo después de alguna otra operación.

Ejemplo clásico: almacenar algunos datos en un búfer para que los vea otro subproceso, y luego configurar una bandera. A pesar de que x86 adquiere las tiendas de carga / liberación de forma gratuita, aún tiene que decirle al compilador que no reordene usando flag.store(1, std::memory_order_release);.

Es posible que espere que este código se sincronice con otros hilos:

// flag is just a plain int global, not std::atomic<int>.
flag--;       // This isn't a real lock, but pretend it's somehow meaningful.
modify_a_data_structure(&foo);    // doesn't look at flag, and the compilers knows this.  (Assume it can see the function def).  Otherwise the usual don't-break-single-threaded-code rules come into play!
flag++;

Pero no lo hará. El compilador es libre de mover la flag++llamada a la función (si alinea la función o sabe que no se ve flag). Entonces puede optimizar la modificación por completo, porque flagni siquiera es volatile. (Y no, C ++ volatileno es un sustituto útil para std :: atomic. Std :: atomic hace que el compilador suponga que los valores en la memoria se pueden modificar de forma asíncrona de forma similar volatile, pero hay mucho más que eso. Además, volatile std::atomic<int> foono es el igual que std::atomic<int> foo, como se discutió con @ Richard Hodges.)

La definición de carreras de datos en variables no atómicas como Comportamiento indefinido es lo que permite al compilador elevar cargas y hundir almacenes fuera de los bucles, y muchas otras optimizaciones para la memoria a las que pueden hacer referencia múltiples subprocesos. (Consulte este blog de LLVM para obtener más información sobre cómo UB habilita las optimizaciones del compilador).


Como mencioné, el prefijo x86lock es una barrera de memoria completa, por lo que el uso num.fetch_add(1, std::memory_order_relaxed);genera el mismo código en x86 que num++(el valor predeterminado es la coherencia secuencial), pero puede ser mucho más eficiente en otras arquitecturas (como ARM). Incluso en x86, relajado permite más reordenamiento en tiempo de compilación.

Esto es lo que GCC realmente hace en x86, para algunas funciones que operan en una std::atomicvariable global.

Vea el código fuente + lenguaje ensamblador formateado en el explorador del compilador Godbolt . Puede seleccionar otras arquitecturas de destino, incluidos ARM, MIPS y PowerPC, para ver qué tipo de código de lenguaje ensamblador obtiene de los atómicos para esos objetivos.

#include <atomic>
std::atomic<int> num;
void inc_relaxed() {
  num.fetch_add(1, std::memory_order_relaxed);
}

int load_num() { return num; }            // Even seq_cst loads are free on x86
void store_num(int val){ num = val; }
void store_num_release(int val){
  num.store(val, std::memory_order_release);
}
// Can the compiler collapse multiple atomic operations into one? No, it can't.

# g++ 6.2 -O3, targeting x86-64 System V calling convention. (First argument in edi/rdi)
inc_relaxed():
    lock add        DWORD PTR num[rip], 1      #### Even relaxed RMWs need a lock. There's no way to request just a single-instruction RMW with no lock, for synchronizing between a program and signal handler for example. :/ There is atomic_signal_fence for ordering, but nothing for RMW.
    ret
inc_seq_cst():
    lock add        DWORD PTR num[rip], 1
    ret
load_num():
    mov     eax, DWORD PTR num[rip]
    ret
store_num(int):
    mov     DWORD PTR num[rip], edi
    mfence                          ##### seq_cst stores need an mfence
    ret
store_num_release(int):
    mov     DWORD PTR num[rip], edi
    ret                             ##### Release and weaker doesn't.
store_num_relaxed(int):
    mov     DWORD PTR num[rip], edi
    ret

Observe cómo se necesita MFENCE (una barrera completa) después de un almacenamiento de consistencia secuencial. x86 está fuertemente ordenado en general, pero se permite la reordenación de StoreLoad. Tener un búfer de tienda es esencial para un buen rendimiento en una CPU fuera de servicio canalizada. El reordenamiento de memoria de Jeff Preshing atrapado en la ley muestra las consecuencias de no usar MFENCE, con código real para mostrar que el reordenamiento ocurre en hardware real.


Re: discusión en comentarios sobre la respuesta de @Richard Hodges sobre compiladores que fusionan num++; num-=2;operaciones std :: atomic en una sola num--;instrucción :

Preguntas y respuestas separadas sobre este mismo tema: ¿Por qué los compiladores no fusionan las escrituras redundantes std :: atomic? , donde mi respuesta repite mucho de lo que escribí a continuación.

Los compiladores actuales en realidad no hacen esto (todavía), pero no porque no se les permita. C ++ WG21 / P0062R1: ¿Cuándo deberían los compiladores optimizar los atómicos? analiza la expectativa que muchos programadores tienen de que los compiladores no harán optimizaciones "sorprendentes", y qué puede hacer el estándar para dar control a los programadores. N4455 analiza muchos ejemplos de cosas que pueden optimizarse, incluido este. Señala que la alineación y la propagación constante pueden introducir cosas como las fetch_or(0)que pueden convertirse en solo una load()(pero aún tiene semántica de adquisición y liberación), incluso cuando la fuente original no tenía ninguna operación atómica obviamente redundante.

Las razones reales por las que los compiladores no lo hacen (todavía) son: (1) nadie ha escrito el código complicado que permitiría al compilador hacerlo de manera segura (sin nunca equivocarse), y (2) potencialmente viola el principio de lo menos sorpresa . El código sin bloqueo es lo suficientemente difícil de escribir correctamente en primer lugar. Así que no seas casual en el uso de armas atómicas: no son baratas y no se optimizan mucho. Sin std::shared_ptr<T>embargo, no siempre es fácil evitar operaciones atómicas redundantes , ya que no hay una versión no atómica (aunque una de las respuestas aquí proporciona una manera fácil de definir un shared_ptr_unsynchronized<T>para gcc).


Volviendo a num++; num-=2;compilar como si fuera así num--: los compiladores pueden hacer esto, a menos que numseavolatile std::atomic<int> . Si es posible un reordenamiento, la regla as-if le permite al compilador decidir en tiempo de compilación que siempre sucede de esa manera. Nada garantiza que un observador pueda ver los valores intermedios (el num++resultado).

Es decir, si el orden en el que nada se vuelve globalmente visible entre estas operaciones es compatible con los requisitos de orden de la fuente (de acuerdo con las reglas de C ++ para la máquina abstracta, no la arquitectura de destino), el compilador puede emitir un solo en lock dec dword [num]lugar delock inc dword [num] / lock sub dword [num], 2.

num++; num--no puede desaparecer, porque todavía tiene una relación Sincronizar con con otros subprocesos que se ven num, y es a la vez una carga de adquisición y un almacén de liberación que no permite la reordenación de otras operaciones en este hilo. Para x86, esto podría ser capaz de compilarse en un MFENCE, en lugar de un lock add dword [num], 0(es decir num += 0).

Como se discutió en PR0062 , la fusión más agresiva de operaciones atómicas no adyacentes en tiempo de compilación puede ser mala (por ejemplo, un contador de progreso solo se actualiza una vez al final en lugar de cada iteración), pero también puede ayudar al rendimiento sin inconvenientes (por ejemplo, omitir el el valor atómico inc / dec de ref cuenta cuando shared_ptrse crea y destruye una copia de a , si el compilador puede probar que shared_ptrexiste otro objeto durante toda la vida útil del temporal).

Incluso la num++; num--fusión podría dañar la imparcialidad de una implementación de bloqueo cuando un hilo se desbloquea y vuelve a bloquear de inmediato. Si nunca se libera en el asm, incluso los mecanismos de arbitraje de hardware no le darán a otro hilo la oportunidad de agarrar el bloqueo en ese punto.


Con gcc6.2 y clang3.9 actuales, aún obtiene lockoperaciones de edición separadas , incluso memory_order_relaxeden el caso más obviamente optimizable. ( Explorador del compilador Godbolt para que pueda ver si las últimas versiones son diferentes).

void multiple_ops_relaxed(std::atomic<unsigned int>& num) {
  num.fetch_add( 1, std::memory_order_relaxed);
  num.fetch_add(-1, std::memory_order_relaxed);
  num.fetch_add( 6, std::memory_order_relaxed);
  num.fetch_add(-5, std::memory_order_relaxed);
  //num.fetch_add(-1, std::memory_order_relaxed);
}

multiple_ops_relaxed(std::atomic<unsigned int>&):
    lock add        DWORD PTR [rdi], 1
    lock sub        DWORD PTR [rdi], 1
    lock add        DWORD PTR [rdi], 6
    lock sub        DWORD PTR [rdi], 5
    ret
Peter Cordes
fuente
1
"[el uso de instrucciones separadas] solía ser más eficiente ... pero las CPU modernas x86 una vez más manejan las operaciones RMW al menos tan eficientemente", aún es más eficiente en el caso en que el valor actualizado se use más adelante en la misma función y hay un registro gratuito disponible para que el compilador lo almacene (y la variable no está marcada como volátil, por supuesto). Esto significa que es muy probable que si el compilador genera una sola instrucción o múltiples para la operación depende del resto del código en la función, no solo de la línea en cuestión.
Periata Breatta
@PeriataBreatta: sí, buen punto. En asm, podría usar mov eax, 1 xadd [num], eax(sin prefijo de bloqueo) para implementar post-incremento num++, pero eso no es lo que hacen los compiladores.
Peter Cordes
3
@ DavidC.Rankin: Si tiene alguna edición que le gustaría hacer, siéntase libre. Sin embargo, no quiero hacer este CW. Sigue siendo mi trabajo (y mi desorden: P). Voy a ordenar algunos después de mi último juego [frisbee] :)
Peter Cordes
1
Si no es el wiki de la comunidad, entonces tal vez un enlace en el wiki de la etiqueta correspondiente. (¿tanto las etiquetas x86 como las atómicas?). Vale la pena un vínculo adicional en lugar de un retorno esperanzador mediante una búsqueda genérica en SO (si supiera mejor dónde debería encajar en ese sentido, lo haría. Tendré que profundizar en la etiqueta de hacer y no hacer) enlace de wiki)
David C. Rankin
1
Como siempre, ¡gran respuesta! Buena distinción entre coherencia y atomicidad (donde otros se equivocaron)
Leeor
39

... y ahora habilitemos optimizaciones:

f():
        rep ret

OK, demos una oportunidad:

void f(int& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

resultado:

f(int&):
        mov     DWORD PTR [rdi], 0
        ret

otro hilo de observación (incluso ignorando los retrasos de sincronización de caché) no tiene oportunidad de observar los cambios individuales.

comparar con:

#include <atomic>

void f(std::atomic<int>& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

donde el resultado es:

f(std::atomic<int>&):
        mov     DWORD PTR [rdi], 0
        mfence
        lock add        DWORD PTR [rdi], 1
        lock sub        DWORD PTR [rdi], 1
        lock add        DWORD PTR [rdi], 6
        lock sub        DWORD PTR [rdi], 5
        lock sub        DWORD PTR [rdi], 1
        ret

Ahora, cada modificación es: -

  1. observable en otro hilo, y
  2. respetuoso de modificaciones similares que ocurren en otros hilos.

La atomicidad no es solo en el nivel de instrucción, sino que involucra toda la tubería desde el procesador, a través de los cachés, hasta la memoria y viceversa.

Informacion adicional

Respecto al efecto de optimizaciones de actualizaciones de std::atomic s.

El estándar c ++ tiene la regla 'como si', por la cual está permitido que el compilador reordene el código, e incluso reescriba el código siempre que el resultado tenga exactamente el mismo observable efectos (incluidos los efectos secundarios) como si simplemente hubiera ejecutado su código.

La regla as-if es conservadora, particularmente involucra atómica.

considerar:

void incdec(int& num) {
    ++num;
    --num;
}

Debido a que no hay bloqueos mutex, atómicos o cualquier otra construcción que influya en la secuencia entre hilos, diría que el compilador es libre de reescribir esta función como NOP, por ejemplo:

void incdec(int&) {
    // nada
}

Esto se debe a que en el modelo de memoria c ++, no hay posibilidad de que otro hilo observe el resultado del incremento. Por supuesto, sería diferente si numfuera volatile(podría influir en el comportamiento del hardware). Pero en este caso, esta función será la única función que modifica esta memoria (de lo contrario, el programa está mal formado).

Sin embargo, este es un juego de pelota diferente:

void incdec(std::atomic<int>& num) {
    ++num;
    --num;
}

numEs un atómico. Los cambios a la misma deben ser observables para otros hilos que están viendo. Los cambios que realicen esos subprocesos (como establecer el valor en 100 entre el incremento y la disminución) tendrán efectos de largo alcance en el valor eventual de num.

Aquí hay una demostración:

#include <thread>
#include <atomic>

int main()
{
    for (int iter = 0 ; iter < 20 ; ++iter)
    {
        std::atomic<int> num = { 0 };
        std::thread t1([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                ++num;
                --num;
            }
        });
        std::thread t2([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                num = 100;
            }
        });
        
        t2.join();
        t1.join();
        std::cout << num << std::endl;
    }
}

salida de muestra:

99
99
99
99
99
100
99
99
100
100
100
100
99
99
100
99
99
100
100
99
Richard Hodges
fuente
55
Esto no explica que add dword [rdi], 1es no atómica (sin el lockprefijo). La carga es atómica y la tienda es atómica, pero nada impide que otro hilo modifique los datos entre la carga y la tienda. Entonces la tienda puede pisar una modificación hecha por otro hilo. Ver jfdube.wordpress.com/2011/11/30/understanding-atomic-operations . Además, los artículos sin bloqueo de Jeff Preshing son extremadamente buenos , y él menciona el problema básico de RMW en ese artículo de introducción.
Peter Cordes
3
Lo que realmente está sucediendo aquí es que nadie ha implementado esta optimización en gcc, porque sería casi inútil y probablemente más peligroso que útil. (Principio de la menor sorpresa. Tal vez alguien está esperando un estado temporal para ser visible a veces, y están bien con el probabilty estadística. O ellos están utilizando el hardware del reloj puntos interrumpir en la modificación.) Necesidades de codificación sin bloqueo para estar bien escritas, así que no habrá nada que optimizar. ¡Puede ser útil buscarlo e imprimir una advertencia para alertar al codificador de que su código puede no significar lo que piensan!
Peter Cordes
2
Esa es quizás una razón para que los compiladores no implementen esto (principio de menor sorpresa, etc.). Observando que sería posible en la práctica en hardware real. Sin embargo, las reglas de orden de memoria de C ++ no dicen nada acerca de ninguna garantía de que las cargas de un hilo se mezclen "uniformemente" con las operaciones de otro hilo en la máquina abstracta de C ++. Sigo pensando que sería legal, pero programador hostil.
Peter Cordes
2
Experimento de pensamiento: considere una implementación de C ++ en un sistema cooperativo multitarea. Implementa std :: thread insertando puntos de rendimiento donde sea necesario para evitar puntos muertos, pero no entre cada instrucción. Supongo que argumentaría que algo en el estándar C ++ requiere un punto de rendimiento entre num++y num--. Si puede encontrar una sección en el estándar que lo requiera, lo resolvería. Estoy bastante seguro de que solo requiere que ningún observador pueda ver una reordenación incorrecta, lo que no requiere un rendimiento allí. Así que creo que es solo un problema de calidad de implementación.
Peter Cordes
55
En aras de la finalidad, pregunté en la lista de correo de discusión estándar. Esta pregunta arrojó 2 documentos que parecen coincidir con Peter, y abordan las preocupaciones que tengo sobre tales optimizaciones: wg21.link/p0062 y wg21.link/n4455. Muchas gracias a Andy, que me llamó la atención.
Richard Hodges
38

Sin muchas complicaciones, una instrucción como add DWORD PTR [rbp-4], 1es muy estilo CISC.

Realiza tres operaciones: cargar el operando de la memoria, incrementarlo, almacenar el operando nuevamente en la memoria.
Durante estas operaciones, la CPU adquiere y libera el bus dos veces, entre cualquier otro agente también puede adquirirlo y esto viola la atomicidad.

AGENT 1          AGENT 2

load X              
inc C
                 load X
                 inc C
                 store X
store X

X se incrementa solo una vez.

Margaret Bloom
fuente
77
@LeoHeinsaar Para que ese sea el caso, cada chip de memoria necesitaría su propia Unidad de lógica aritmética (ALU). En efecto, requeriría que cada chip de memoria fuera un procesador.
Richard Hodges
66
@LeoHeinsaar: las instrucciones de destino de memoria son operaciones de lectura-modificación-escritura. No se modifica ningún registro arquitectónico, pero la CPU debe retener los datos internamente mientras los envía a través de su ALU. El archivo de registro real es solo una pequeña parte del almacenamiento de datos dentro de incluso la CPU más simple, con pestillos que sostienen las salidas de una etapa como entradas para otra etapa, etc., etc.
Peter Cordes
@PeterCordes Su comentario es exactamente la respuesta que estaba buscando. La respuesta de Margaret me hizo sospechar que algo así debe pasar dentro.
Leo Heinsaar
Convirtió ese comentario en una respuesta completa, que incluyó abordar la parte C ++ de la pregunta.
Peter Cordes
1
@PeterCordes Gracias, muy detallado y en todos los puntos. Obviamente, fue una carrera de datos y, por lo tanto, un comportamiento indefinido según el estándar C ++, tenía curiosidad de saber si en los casos en que el código generado era lo que publiqué, uno podría suponer que eso podría ser atómico, etc., etc. También verifiqué que al menos el desarrollador de Intel Los manuales definen muy claramente la atomicidad con respecto a las operaciones de memoria y no la indivisibilidad de la instrucción, como supuse: "Las operaciones bloqueadas son atómicas con respecto a todas las demás operaciones de memoria y todos los eventos visibles externamente".
Leo Heinsaar
11

La instrucción add no es atómica. Hace referencia a la memoria, y dos núcleos de procesador pueden tener una memoria caché local diferente de esa memoria.

IIRC, la variante atómica de la instrucción add se llama lock xadd

Sven Nilsson
fuente
3
lock xaddimplementa C ++ std :: atomic fetch_add, devolviendo el valor anterior. Si no lo necesita, el compilador usará las instrucciones normales de destino de memoria con un lockprefijo. lock addo lock inc.
Peter Cordes
1
add [mem], 1todavía no sería atómico en una máquina SMP sin caché, vea mis comentarios sobre otras respuestas.
Peter Cordes
Vea mi respuesta para obtener más detalles sobre cómo no es atómico. También el final de mi respuesta sobre esta pregunta relacionada .
Peter Cordes
10

Dado que la línea 5, que corresponde a num ++ es una instrucción, ¿podemos concluir que num ++ es atómico en este caso?

Es peligroso sacar conclusiones basadas en el ensamblaje generado por "ingeniería inversa". Por ejemplo, parece que compiló su código con la optimización deshabilitada; de lo contrario, el compilador habría desechado esa variable o cargado 1 directamente sin invocarlo operator++. Debido a que el ensamblaje generado puede cambiar significativamente, según los indicadores de optimización, la CPU de destino, etc., su conclusión se basa en arena.

Además, su idea de que una instrucción de ensamblaje significa que una operación es atómica también está mal. Esto addno será atómico en sistemas con múltiples CPU, incluso en la arquitectura x86.

Slava
fuente
9

Incluso si su compilador siempre emitió esto como una operación atómica, acceder num desde cualquier otro subproceso al mismo tiempo constituiría una carrera de datos de acuerdo con los estándares C ++ 11 y C ++ 14 y el programa tendría un comportamiento indefinido.

Pero es peor que eso. Primero, como se ha mencionado, la instrucción generada por el compilador al incrementar una variable puede depender del nivel de optimización. En segundo lugar, el compilador puede reordenar otros accesos de memoria ++numsi numno es atómico, p. Ej.

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  int ready = 0;
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

Incluso si suponemos optimistamente que ++readyes "atómico", y que el compilador genera el bucle de verificación según sea necesario (como dije, es UB y, por lo tanto, el compilador es libre de eliminarlo, reemplazarlo con un bucle infinito, etc.), el el compilador aún puede mover la asignación del puntero, o incluso peor, la inicialización del vectorpunto a un punto después de la operación de incremento, causando caos en el nuevo hilo. En la práctica, no me sorprendería en absoluto si un compilador de optimización eliminaraready variable y el bucle de verificación, ya que esto no afecta el comportamiento observable bajo las reglas del lenguaje (a diferencia de sus esperanzas privadas).

De hecho, en la conferencia Meeting C ++ del año pasado, escuché de dos desarrolladores de compiladores que con mucho gusto implementan optimizaciones que hacen que los programas multihilo escritos ingenuamente se comporten mal, siempre y cuando las reglas del lenguaje lo permitan, incluso si se observa una mejora de rendimiento menor. en programas escritos correctamente.

Por último, incluso si no le importaba la portabilidad, y su compilador era mágicamente bueno, la CPU que está utilizando es muy probable que sea del tipo CISC superescalar y desglosará las instrucciones en micro-operaciones, las reordenará y / o las ejecutará especulativamente, hasta cierto punto solo limitado por la sincronización de primitivas como (en Intel) el LOCKprefijo o las vallas de memoria, para maximizar las operaciones por segundo.

Para resumir, las responsabilidades naturales de la programación segura para subprocesos son:

  1. Su deber es escribir código que tenga un comportamiento bien definido bajo las reglas del idioma (y en particular el modelo de memoria estándar del idioma).
  2. El deber de su compilador es generar código de máquina que tenga el mismo comportamiento bien definido (observable) bajo el modelo de memoria de la arquitectura de destino.
  3. El deber de su CPU es ejecutar este código para que el comportamiento observado sea compatible con el modelo de memoria de su propia arquitectura.

Si desea hacerlo a su manera, podría funcionar en algunos casos, pero comprenda que la garantía es nula y que usted será el único responsable de los resultados no deseados . :-)

PD: ejemplo escrito correctamente:

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  std::atomic<int> ready{0}; // NOTE the use of the std::atomic template
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

Esto es seguro porque:

  1. Los cheques de ready no se pueden optimizar de acuerdo con las reglas del idioma.
  2. El ++ready suceda antes del cheque que ve readycomo no es cero, y otras operaciones no se pueden reordenar en torno a estas operaciones. Esto se debe a que ++readyla verificación es secuencialmente consistente , lo cual es otro término descrito en el modelo de memoria C ++ y que prohíbe este reordenamiento específico. Por lo tanto, el compilador no debe reordenar las instrucciones, y también debe decirle a la CPU que no debe, por ejemplo, posponer la escritura vecen después del incremento de ready. Secuencialmente consistente es la garantía más fuerte con respecto a los atómicos en el lenguaje estándar. Las garantías menores (y teóricamente más baratas) están disponibles, por ejemplo, a través de otros métodos destd::atomic<T>, pero definitivamente son solo para expertos y es posible que los desarrolladores del compilador no los optimicen mucho, porque rara vez se usan.
Arne Vogel
fuente
1
Si el compilador no pudiera ver todos los usos ready, probablemente se compilaría while (!ready);en algo más parecido if(!ready) { while(true); }. Upvoted: una parte clave de std :: atomic es cambiar la semántica para asumir una modificación asincrónica en cualquier momento. Tenerlo como UB normalmente es lo que permite a los compiladores levantar cargas y hundir las tiendas fuera de los bucles.
Peter Cordes
9

En una máquina x86 de un solo núcleo, una addinstrucción generalmente será atómica con respecto a otro código en la CPU 1 . Una interrupción no puede dividir una sola instrucción por la mitad.

Se requiere una ejecución fuera de orden para preservar la ilusión de que las instrucciones se ejecuten una a la vez en orden dentro de un solo núcleo, por lo que cualquier instrucción que se ejecute en la misma CPU ocurrirá completamente antes o completamente después de la adición.

Los sistemas x86 modernos son multinúcleo, por lo que no se aplica el caso especial de un solo procesador.

Si uno apunta a una pequeña PC integrada y no tiene planes de mover el código a otra cosa, la naturaleza atómica de la instrucción "agregar" podría ser explotada. Por otro lado, las plataformas donde las operaciones son inherentemente atómicas son cada vez más escasas.

(Esto no le ayudará si usted está escribiendo en C ++, sin embargo. Los compiladores no tienen una opción de requerir num++para compilar a un complemento de memoria de destino o xadd sin un lockprefijo. Podían optar por cargar numen un registro y almacenar el resultado del incremento con una instrucción separada, y probablemente lo hará si usa el resultado).


Nota 1: El lockprefijo existía incluso en el 8086 original porque los dispositivos de E / S funcionan simultáneamente con la CPU; los controladores en un sistema de un solo núcleo necesitan lock addincrementar atómicamente un valor en la memoria del dispositivo si el dispositivo también puede modificarlo, o con respecto al acceso DMA.

Super gato
fuente
Ni siquiera es generalmente atómico: otro hilo puede actualizar la misma variable al mismo tiempo y solo se toma una actualización.
fuz
1
Considere un sistema multinúcleo. Por supuesto, dentro de un núcleo, la instrucción es atómica, pero no es atómica con respecto a todo el sistema.
fuz
1
@FUZxxl: ¿Cuáles fueron las palabras cuarta y quinta de mi respuesta?
supercat
1
@supercat Su respuesta es muy engañosa porque solo considera el raro caso de un solo núcleo y le da a OP una falsa sensación de seguridad. Es por eso que comenté para considerar el caso de múltiples núcleos también.
fuz
1
@FUZxxl: Hice una edición para aclarar la posible confusión para los lectores que no notaron que esto no está hablando de CPU multinúcleo modernas y normales. (Y también sea más específico sobre algunas cosas de las que Supercat no estaba seguro). Por cierto, todo en esta respuesta ya está en el mío, excepto la última oración sobre cómo las plataformas donde la lectura-modificación-escritura es atómica "gratis" son raras.
Peter Cordes
7

En el pasado, cuando las computadoras x86 tenían una CPU, el uso de una sola instrucción aseguraba que las interrupciones no dividieran la lectura / modificación / escritura y si la memoria no se usaría también como un búfer DMA, era de hecho atómico (y C ++ no mencionó hilos en el estándar, por lo que esto no se abordó).

Cuando era raro tener un procesador dual (por ejemplo, Pentium Pro de doble zócalo) en el escritorio de un cliente, lo usé de manera efectiva para evitar el prefijo LOCK en una máquina de un solo núcleo y mejorar el rendimiento.

Hoy en día, solo ayudaría contra varios subprocesos que se configuraron con la misma afinidad de CPU, por lo que los subprocesos que le preocupan solo entrarían en juego a través del tiempo de expiración y la ejecución del otro subproceso en la misma CPU (núcleo). Eso no es realista.

Con los modernos procesadores x86 / x64, la única instrucción se divide en varias micro operaciones y, además, la memoria de lectura y escritura se almacena en búfer. Por lo tanto, los diferentes subprocesos que se ejecutan en diferentes CPU no solo verán esto como no atómico, sino que pueden ver resultados inconsistentes con respecto a lo que lee de la memoria y lo que supone que otros subprocesos han leído hasta ese momento: debe agregar vallas de memoria para restaurar la cordura. comportamiento.

JDługosz
fuente
1
Las interrupciones aún no dividen las operaciones de RMW, por lo que aún sincronizan un solo hilo con manejadores de señales que se ejecutan en el mismo hilo. Por supuesto, esto solo funciona si el asm usa una sola instrucción, no carga / modificación / almacenamiento por separado. C ++ 11 podría exponer esta funcionalidad de hardware, pero no lo hace (probablemente porque solo fue realmente útil en los núcleos Uniprocessor para sincronizar con los controladores de interrupción, no en el espacio de usuario con los controladores de señal). Además, las arquitecturas no tienen instrucciones de destino de memoria de lectura-modificación-escritura. Aún así, sólo podría compilar como un RSR atómica relajada en la no-x86
Peter Cordes
Aunque, según recuerdo, usar el prefijo Lock no era absurdamente costoso hasta que aparecieron los superescaladores. Por lo tanto, no había razón para notarlo, ya que ralentizaba el código importante en un 486, a pesar de que ese programa no lo necesitaba.
JDługosz
¡Si, lo siento! En realidad no leí con cuidado. Vi el comienzo del párrafo con el arenque rojo sobre la decodificación a Uops, y no terminé de leer para ver lo que realmente dijiste. re: 486: Creo que he leído que el primer SMP era una especie de Compaq 386, pero su semántica de ordenamiento de memoria no era la misma que dice actualmente el x86 ISA. Los manuales x86 actuales pueden incluso mencionar SMP 486. Sin embargo, creo que no eran comunes incluso en HPC (clústeres Beowulf) hasta los días PPro / Athlon XP.
Peter Cordes
1
@PeterCordes Ok. Claro, suponiendo que tampoco haya observadores DMA / dispositivo, no cabía en el área de comentarios para incluir ese también. Gracias JDługosz por excelente adición (respuesta, así como comentarios). Realmente completado la discusión.
Leo Heinsaar
3
@Leo: Un punto clave que no se ha mencionado: las CPU fuera de servicio reordenan las cosas internamente, pero la regla de oro es que para un solo núcleo , conservan la ilusión de instrucciones que se ejecutan una por una, en orden. (Y esto incluye interrupciones que activan cambios de contexto). Los valores pueden almacenarse eléctricamente en la memoria fuera de servicio, pero el núcleo único en el que se ejecuta todo realiza un seguimiento de todo el reordenamiento que hace, para preservar la ilusión. Es por eso que no necesita una barrera de memoria para el equivalente asm dea = 1; b = a; cargue correctamente el 1 que acaba de almacenar.
Peter Cordes
4

No. https://www.youtube.com/watch?v=31g0YE61PLQ (Eso es solo un enlace a la escena "No" de "The Office")

¿Está de acuerdo en que este sería un posible resultado para el programa:

salida de muestra:

100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100

Si es así, entonces el compilador es libre de hacer que sea la única salida posible para el programa, de cualquier forma que el compilador desee. es decir, un main () que solo produce 100s.

Esta es la regla "como si".

E independientemente de la salida, puede pensar en la sincronización de subprocesos de la misma manera: si el subproceso A lo hace num++; num--;y el subproceso B lee numrepetidamente, entonces una posible intercalación válida es que el subproceso B nunca lee entre num++y num--. Como ese entrelazado es válido, el compilador es libre de convertirlo en el único entrelazado posible. Y simplemente elimine el incr / decr por completo.

Aquí hay algunas implicaciones interesantes:

while (working())
    progress++;  // atomic, global

(es decir, imagine que otro hilo actualiza una barra de progreso basada en la interfaz de usuario progress)

¿Puede el compilador convertir esto en:

int local = 0;
while (working())
    local++;

progress += local;

Probablemente eso sea válido. Pero probablemente no sea lo que el programador esperaba :-(

El comité todavía está trabajando en esto. Actualmente "funciona" porque los compiladores no optimizan mucho los atómicos. Pero eso está cambiando.

E incluso si progresstambién fuera volátil, esto todavía sería válido:

int local = 0;
while (working())
    local++;

while (local--)
    progress++;

: - /

Tony
fuente
Esta respuesta parece solo responder a la pregunta secundaria que Richard y yo estábamos reflexionando. Finalmente se resolvieron que: Resulta que sí, el estándar de C ++ hace permiten la fusión de las operaciones en los no volatileobjetos atómicos, cuando no se rompe cualquier otra regla. Dos documentos de discusión de estándares discuten exactamente esto (enlaces en el comentario de Richard ), uno que usa el mismo ejemplo de contador de progreso. Por lo tanto, es un problema de calidad de implementación hasta que C ++ estandarice las formas de prevenirlo.
Peter Cordes
Sí, mi "No" es realmente una respuesta a toda la línea de razonamiento. Si la pregunta es simplemente "¿puede num ++ ser atómico en algún compilador / implementación", la respuesta es segura. Por ejemplo, un compilador podría decidir agregar locka cada operación. O alguna combinación de compilador + uniprocesador donde ninguno de los reordenamientos (es decir, "los viejos tiempos") todo es atómico. Pero, ¿qué sentido tiene eso? Realmente no puedes confiar en eso. A menos que sepa que ese es el sistema para el que está escribiendo. (Incluso entonces, mejor sería que atomic <int> no agregue operaciones adicionales en ese sistema. Por lo tanto, aún debe escribir código estándar ...)
Tony
1
Tenga en cuenta que eso And just remove the incr/decr entirely.no está del todo bien. Sigue siendo una operación de adquisición y lanzamiento num. En x86, num++;num--podría compilarse solo para MFENCE, pero definitivamente no es nada. (A menos que el análisis de todo el programa del compilador pueda probar que nada se sincroniza con esa modificación de num, y que no importa si algunas tiendas de antes se retrasan hasta después de las cargas de después). Por ejemplo, si esto fue un desbloqueo y re caso de uso de bloqueo inmediato, todavía tiene dos secciones críticas separadas (tal vez usando mo_relaxed), no una grande.
Peter Cordes
@PeterCordes ah sí, de acuerdo.
Tony
2

Sí, pero...

Atómico no es lo que querías decir. Probablemente estés preguntando algo incorrecto.

El incremento es ciertamente atómico . A menos que el almacenamiento esté desalineado (y dado que dejó la alineación al compilador, no lo está), necesariamente está alineado dentro de una sola línea de caché. A falta de instrucciones especiales de transmisión sin almacenamiento en caché, todas y cada una de las escrituras pasan por el caché. Las líneas de caché completas se leen y escriben atómicamente, nunca hay nada diferente.
Los datos más pequeños que la línea de caché, por supuesto, también se escriben atómicamente (ya que la línea de caché que lo rodea es).

¿Es seguro para subprocesos?

Esta es una pregunta diferente, y hay al menos dos buenas razones para responder con un claro "¡No!" .

Primero, existe la posibilidad de que otro núcleo tenga una copia de esa línea de caché en L1 (L2 y hacia arriba generalmente se comparte, ¡pero L1 es normalmente por núcleo!), Y al mismo tiempo modifica ese valor. Por supuesto, eso también ocurre atómicamente, pero ahora tiene dos valores "correctos" (correctamente, atómicamente, modificados): ¿cuál es el verdaderamente correcto ahora?
La CPU lo resolverá de alguna manera, por supuesto. Pero el resultado puede no ser lo que espera.

En segundo lugar, hay pedidos de memoria, o redactados de manera diferente antes de las garantías. Lo más importante sobre las instrucciones atómicas no es tanto que sean atómicas. . Está ordenando.

Tiene la posibilidad de hacer cumplir una garantía de que todo lo que sucede en cuanto a memoria se realiza en un orden bien definido y garantizado en el que tiene una garantía de "sucedió antes". Este pedido puede ser tan "relajado" (leído como: ninguno en absoluto) o tan estricto como sea necesario.

Por ejemplo, puede establecer un puntero en algún bloque de datos (por ejemplo, los resultados de algún cálculo) y luego liberar atómicamente el indicador "datos listos". Ahora, quien adquiera esta bandera será llevado a pensar que el puntero es válido. Y, de hecho, siempre será un puntero válido, nunca algo diferente. Esto se debe a que la escritura en el puntero ocurrió antes de la operación atómica.

Damon
fuente
2
La carga y la tienda son atómicas por separado, pero toda la operación de lectura-modificación-escritura en su conjunto definitivamente no es atómica. Los cachés son coherentes, por lo que nunca pueden contener copias en conflicto de la misma línea ( en.wikipedia.org/wiki/MESI_protocol ). Otro núcleo ni siquiera puede tener una copia de solo lectura mientras este núcleo la tenga en el estado Modificado. Lo que lo hace no atómico es que el núcleo que hace el RMW puede perder la propiedad de la línea de caché entre la carga y la tienda.
Peter Cordes
2
Además, no, las líneas de caché completas no siempre se transfieren atómicamente. Vea esta respuesta , donde se demostró experimentalmente que un Opteron de múltiples sockets hace que las tiendas SSE 16B no sean atómicas mediante la transferencia de líneas de caché en trozos de 8B con hipertransporte, a pesar de que son atómicas para CPU de un solo socket del mismo tipo (porque la carga / el hardware de la tienda tiene una ruta 16B al caché L1). x86 solo garantiza la atomicidad para cargas separadas o almacena hasta 8B.
Peter Cordes
Dejar la alineación en el compilador no significa que la memoria se alineará en el límite de 4 bytes. Los compiladores pueden tener opciones o pragmas para cambiar el límite de alineación. Esto es útil, por ejemplo, para operar con datos muy empaquetados en flujos de red.
Dmitry Rubanovich
2
Sofismas, nada más. Un número entero con almacenamiento automático que no es parte de una estructura como se muestra en el ejemplo estará absolutamente correctamente alineado. Reclamar algo diferente es simplemente una tontería. Las líneas de caché y todos los POD tienen un tamaño y alineación de PoT (potencia de dos), en cualquier arquitectura no ilusoria del mundo. Math dice que cualquier PoT alineado correctamente se ajusta exactamente a uno (nunca más) de cualquier otro PoT del mismo tamaño o mayor. Mi afirmación es, por lo tanto, correcta.
Damon
1
@Damon, el ejemplo dado en la pregunta no menciona una estructura, pero no limita la pregunta solo a las situaciones en las que los enteros no son parte de las estructuras. Los POD definitivamente pueden tener un tamaño de PoT y no estar alineados con PoT. Eche un vistazo a esta respuesta para ver ejemplos de sintaxis: stackoverflow.com/a/11772340/1219722 . Por lo tanto, no es un "sofisma" porque los POD declarados de esa manera se usan bastante en el código de red en el código de la vida real.
Dmitry Rubanovich
2

Que la producción de un único compilador, en una arquitectura específica de la CPU, con optimizaciones desactivadas (ya que gcc ni siquiera compilar ++a addla hora de optimizar en un ejemplo rápido y sucio ), parece implicar incrementando de esta manera es atómica no quiere decir que esto es estándar compatible ( causaría un comportamiento indefinido al intentar acceder numen un hilo), y de todos modos está equivocado, porque noadd es atómico en x86.

Tenga en cuenta que los atómicos (usando el lockprefijo de instrucción) son relativamente pesados ​​en x86 ( vea esta respuesta relevante ), pero aún notablemente menos que un mutex, que no es muy apropiado en este caso de uso.

Los siguientes resultados se toman de clang ++ 3.8 al compilar con -Os.

Incrementando un int por referencia, la forma "regular":

void inc(int& x)
{
    ++x;
}

Esto se compila en:

inc(int&):
    incl    (%rdi)
    retq

Incrementando un int pasado por referencia, la forma atómica:

#include <atomic>

void inc(std::atomic<int>& x)
{
    ++x;
}

Este ejemplo, que no es mucho más complejo que la forma habitual, solo lockagrega el prefijo a la inclinstrucción, pero precaución, como se dijo anteriormente, esto no es barato. El hecho de que el montaje parezca corto no significa que sea rápido.

inc(std::atomic<int>&):
    lock            incl    (%rdi)
    retq
Asu
fuente
-2

Cuando su compilador usa solo una sola instrucción para el incremento y su máquina tiene un solo subproceso, su código está seguro. ^^

Bonita Montero
fuente
-3

Intente compilar el mismo código en una máquina que no sea x86, y verá rápidamente resultados de ensamblaje muy diferentes.

La razón num++ parece ser atómica porque en máquinas x86, incrementar un número entero de 32 bits es, de hecho, atómico (suponiendo que no tenga lugar la recuperación de memoria). Pero esto no está garantizado por el estándar c ++, ni es probable que sea el caso en una máquina que no utiliza el conjunto de instrucciones x86. Por lo tanto, este código no es multiplataforma a salvo de las condiciones de carrera.

Tampoco tiene una garantía sólida de que este código esté a salvo de las Condiciones de carrera, incluso en una arquitectura x86, porque x86 no configura cargas y almacena en la memoria a menos que se le indique específicamente. Entonces, si varios subprocesos intentaron actualizar esta variable simultáneamente, pueden terminar incrementando los valores en caché (obsoletos)

La razón, entonces, que tenemos, std::atomic<int>y así sucesivamente, es que cuando trabajas con una arquitectura donde la atomicidad de los cálculos básicos no está garantizada, tienes un mecanismo que obligará al compilador a generar código atómico.

Xirema
fuente
"es porque en máquinas x86, incrementar un número entero de 32 bits es, de hecho, atómico". ¿Puedes proporcionar un enlace a la documentación que lo prueba?
Slava
8
Tampoco es atómico en x86. Es seguro para un solo núcleo, pero si hay múltiples núcleos (y los hay) no es atómico en absoluto.
Harold
¿Está x86 addrealmente garantizado atómico? No me sorprendería si los incrementos de registro fueran atómicos, pero eso no es útil; para que el incremento del registro sea visible para otro subproceso, debe estar en la memoria, lo que requeriría instrucciones adicionales para cargarlo y almacenarlo, eliminando la atomicidad. Entiendo que es por eso lockque existe el prefijo para las instrucciones; el único atómico útil se addaplica a la memoria desreferenciada y usa el lockprefijo para garantizar que la línea de caché esté bloqueada durante la operación .
ShadowRanger
@Slava @Harold @ShadowRanger He actualizado la respuesta. addes atómico, pero dejé claro que eso no implica que el código sea seguro para las condiciones de carrera, porque los cambios no se vuelven visibles globalmente de inmediato.
Xirema
3
@Xirema que lo hace "no atómico" por definición, sin embargo
harold