c++ c multithreading assembly atomic

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



multithreading assembly (13)

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 invocar el 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. Este add no será atómico en sistemas con múltiples CPU, incluso en la arquitectura x86.

En general, para int num , num++ (o ++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í ):

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

Y si es así, ¿significa que num++ así generado 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 prefijo de lock . 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 ++).


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 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 un orden de memoria, o redactado 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.


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. ^^


En el pasado, cuando las computadoras x86 tenían una CPU, el uso de una sola instrucción aseguraba que las interrupciones no dividirían 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 utilicé efectivamente 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 múltiples subprocesos que se configuraron con la misma afinidad de CPU, por lo que los subprocesos que le preocupan solo entrarían en juego cuando expire el segmento de tiempo y ejecute el otro subproceso en la misma CPU (núcleo). Eso no es realista.

Con los modernos procesadores x86 / x64, la instrucción individual 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.


En una máquina x86 de un solo núcleo, una add instrucció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.

(Sin embargo, esto no le ayuda si está escribiendo en C ++. Los compiladores no tienen la opción de exigir num++ compilar en un destino de memoria agregar o xadd sin un lock prefijo. Podrían elegir cargar num en un registro y almacenar el resultado del incremento con una instrucción separada, y probablemente lo hará si usa el resultado).

Nota 1: El lock prefijo 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 add incrementar atómicamente un valor en la memoria del dispositivo si el dispositivo también puede modificarlo, o con respecto al acceso DMA.


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.


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 esto 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 del hilo de la misma manera: si el hilo A lo hace num++; num--; y el hilo B lee num repetidamente, entonces una posible intercalación válida es que el hilo 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 progress también fuera volátil, esto seguiría siendo válido:

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

: - /


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 add la 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 num en un hilo), y de todos modos está equivocado, porque no add es atómico en x86.

Tenga en cuenta que los elementos atómicos (que usan el lock prefijo de instrucción) son relativamente pesados ​​en x86 ( vea esta respuesta relevante ), pero aún notablemente menos que un mutex, lo 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 lock agrega el prefijo a la incl instrucció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


... 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 los mismos efectos observables (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 de c ++, no hay posibilidad de que otro hilo observe el resultado del incremento. Por supuesto, sería diferente si num fuera 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; }

num es un atómico. Los cambios 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 gran 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


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. memory_order_relaxed usar std::atomic para obtener resultados confiables, pero puede usarlo con memory_order_relaxed si no le importa reordenar. Vea a continuación algunos ejemplos de código y salida de 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 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], 1 en un ciclo pisarían las tiendas de los demás. (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 mi respuesta a Atomicity en x86 con muchos más detalles sobre las cargas puras y las tiendas puras, mientras que esta respuesta se centra en lectura-modificación-escritura.

El prefijo de lock 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 desde la carga hasta el 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 prefijo de lock , 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 lock obtendría copias en conflicto de la misma línea de caché. Esto nunca puede suceder en un sistema con cachés coherentes.

(Si una instrucción lock funciona en la 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, para que ningún observador pueda ver desgarros. La CPU podría tener 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 prefijo de lock también convierte una instrucción en una barrera de memoria completa (como MFENCE ), deteniendo todo el reordenamiento en tiempo de ejecución y dando 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 las reglas de C ++).

En una máquina de un solo procesador, o en un proceso de subproceso único, una sola instrucción RMW realidad es atómica sin un prefijo de lock . 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, una dec dword [num] simple dec dword [num] puede sincronizarse entre un programa de subproceso único y sus manejadores 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 usar num++ sin decirle al compilador que lo necesita para compilar 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 num más tarde: el compilador lo mantendrá activo en un registro después del incremento. Entonces, incluso si verifica cómo num++ compila por sí mismo, cambiar el código circundante puede afectarlo.

(Si el valor no se necesita más tarde, se prefiere inc dword [num] ; las CPU modernas x86 ejecutarán una instrucción RMW de destino de memoria al menos tan eficientemente como con tres instrucciones separadas. Dato gcc -O3 -m32 -mtune=i586 : gcc -O3 -m32 -mtune=i586 realmente emitirá esto , porque la tubería superescalar de (Pentium) P5 no decodificó instrucciones complejas para múltiples microoperaciones simples como lo hacen P6 y las microarquitecturas posteriores. Consulte las tablas de instrucciones / guía de microarquitectura de Agner Fog para obtener más información y la etiqueta x86 wiki para muchos enlaces útiles (incluidos los manuales x86 ISA de Intel, que están disponibles gratuitamente en 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 tu num++ vuelva globalmente visible 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 un indicador. 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 el flag++ través de la llamada a la función (si alinea la función o sabe que no mira el flag ). Entonces puede optimizar la modificación por completo, porque la flag ni siquiera es volatile . (Y no, C ++ volatile no es un sustituto útil para std :: atomic. Std :: atomic sí hace que el compilador suponga que los valores en la memoria pueden modificarse de forma asíncrona similar a volatile , pero hay mucho más que eso. También, volatile std::atomic<int> foo no es lo mismo 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 que el compilador aún levante cargas y elimine almacenes de bucles, y muchas otras optimizaciones para la memoria a las que múltiples hilos podrían hacer referencia. (Consulte este blog de LLVM para obtener más información sobre cómo UB habilita las optimizaciones del compilador).

Como mencioné, el prefijo de lock x86 es una barrera de memoria completa, por lo que se usa 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 variable global std::atomic .

Vea el código fuente + lenguaje ensamblador bien 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 std :: atomic num++; num-=2; num++; num-=2; operaciones en un 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 lo hacen (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 darles 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 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 obviamente operaciones atómicas redundantes.

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 que el compilador lo haga de manera segura (sin equivocarse nunca), y (2) potencialmente viola el principio de lo menos sorpresa El código sin bloqueo es lo suficientemente difícil como para 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 embargo, no siempre es fácil evitar operaciones atómicas redundantes con std::shared_ptr<T> , ya que no existe 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; num++; num-=2; compilando como si fuera num-- : los compiladores pueden hacer esto, a menos que num sea volatile 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 resultado num++ ).

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 lock dec dword [num] en lugar de lock inc dword [num] / lock sub dword [num], 2 .

num++; num-- num++; num-- no puede desaparecer, porque todavía tiene una relación Sincronizar con con otros hilos que miran 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 , una 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 atomic inc / dec of ref cuenta cuando se crea y destruye una copia de shared_ptr , si el compilador puede probar que existe otro objeto shared_ptr para toda la vida útil del temporal).

Incluso num++; num-- 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 operaciones de lock separadas incluso con memory_order_relaxed en 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


Incluso si su compilador siempre emitió esto como una operación atómica, acceder a num desde cualquier otro hilo simultáneamente 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 alrededor de ++num si num no es atómico, por ejemplo

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 asumimos con optimismo que ++ready es "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 compilador aún podría mover la asignación del puntero, o peor aún, la inicialización del vector 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 optimizador eliminara por completo la variable ready 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é por parte 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, en una medida solo limitada por la sincronización de primitivas como (en Intel) el prefijo LOCK o las cercas 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 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. Las comprobaciones de ready no se pueden optimizar de acuerdo con las reglas del idioma.
  2. El ++ready ocurre antes de la verificación que ve ready como no cero, y otras operaciones no se pueden reordenar alrededor de estas operaciones. Esto se debe a que ++ready y la verificación son secuencialmente consistentes , que 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 en vec 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 std::atomic<T> , pero definitivamente son solo para expertos, y los desarrolladores del compilador pueden no optimizarlas mucho, porque rara vez se usan.

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


Sin muchas complicaciones, una instrucción como add DWORD PTR [rbp-4], 1 tiene un estilo muy 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.