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í ):
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 lock
prefijo. 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 ++).
add
es atómico?std::atomic<int>
.add
instrucción, otro núcleo podría robar esa dirección de memoria del caché de este núcleo y modificarla. En una CPU x86, laadd
instrucción necesita unlock
prefijo si la dirección debe bloquearse en la memoria caché durante la operación.Respuestas:
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::atomic
para obtener resultados confiables, pero puede usarlomemory_order_relaxed
si no le importa reordenar. Vea a continuación algunos ejemplos de código y salida asm usandofetch_add
.Pero primero, el lenguaje ensamblador parte de la pregunta:
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], 1
en 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
lock
prefijo 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
lock
prefijo, 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 sinlock
obtener copias en conflicto de la misma línea de caché. Esto nunca puede suceder en un sistema con cachés coherentes.(Si una
lock
instrucció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
lock
prefijo 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
lock
prefijo. 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 planodec 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:Esto es muy probable si usa el valor de
num
later: el compilador lo mantendrá en vivo en un registro después del incremento. Entonces, incluso si verifica cómo senum++
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: engcc -O3 -m32 -mtune=i586
realidad 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, yx86 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 tu
num++
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:
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 veflag
). Entonces puede optimizar la modificación por completo, porqueflag
ni siquiera esvolatile
. (Y no, C ++volatile
no 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 similarvolatile
, pero hay mucho más que eso. Además,volatile std::atomic<int> foo
no es el igual questd::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 x86
lock
es una barrera de memoria completa, por lo que el usonum.fetch_add(1, std::memory_order_relaxed);
genera el mismo código en x86 quenum++
(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::atomic
variable 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.
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 solanum--;
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 unaload()
(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 unshared_ptr_unsynchronized<T>
para gcc).Volviendo a
num++; num-=2;
compilar como si fuera asínum--
: los compiladores pueden hacer esto, a menos quenum
seavolatile 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 (elnum++
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 vennum
, 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 unlock add dword [num], 0
(es decirnum += 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_ptr
se crea y destruye una copia de a , si el compilador puede probar queshared_ptr
existe 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
lock
operaciones de edición separadas , inclusomemory_order_relaxed
en el caso más obviamente optimizable. ( Explorador del compilador Godbolt para que pueda ver si las últimas versiones son diferentes).fuente
mov eax, 1
xadd [num], eax
(sin prefijo de bloqueo) para implementar post-incrementonum++
, pero eso no es lo que hacen los compiladores.... y ahora habilitemos optimizaciones:
OK, demos una oportunidad:
resultado:
otro hilo de observación (incluso ignorando los retrasos de sincronización de caché) no tiene oportunidad de observar los cambios individuales.
comparar con:
donde el resultado es:
Ahora, cada modificación es: -
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:
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:
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
num
fueravolatile
(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:
num
Es 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:
salida de muestra:
fuente
add dword [rdi], 1
es no atómica (sin ellock
prefijo). 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.num++
ynum--
. 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.Sin muchas complicaciones, una instrucción como
add DWORD PTR [rbp-4], 1
es 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.
X se incrementa solo una vez.
fuente
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
fuente
lock xadd
implementa C ++ std :: atomicfetch_add
, devolviendo el valor anterior. Si no lo necesita, el compilador usará las instrucciones normales de destino de memoria con unlock
prefijo.lock add
olock inc
.add [mem], 1
todavía no sería atómico en una máquina SMP sin caché, vea mis comentarios sobre otras respuestas.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
add
no será atómico en sistemas con múltiples CPU, incluso en la arquitectura x86.fuente
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
++num
sinum
no es atómico, p. Ej.Incluso si suponemos optimistamente 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 el compilador aún puede mover la asignación del puntero, o incluso peor, la inicialización delvector
punto 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
LOCK
prefijo o las vallas de memoria, para maximizar las operaciones por segundo.Para resumir, las responsabilidades naturales de la programación segura para subprocesos son:
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:
Esto es seguro porque:
ready
no se pueden optimizar de acuerdo con las reglas del idioma.++ready
suceda antes del cheque que veready
como no es cero, y otras operaciones no se pueden reordenar en torno a estas operaciones. Esto se debe a que++ready
la 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 escrituravec
en después del incremento deready
. 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.fuente
ready
, probablemente se compilaríawhile (!ready);
en algo más parecidoif(!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.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.
(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 unlock
prefijo. Podían optar por cargarnum
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 necesitanlock add
incrementar atómicamente un valor en la memoria del dispositivo si el dispositivo también puede modificarlo, o con respecto al acceso DMA.fuente
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.
fuente
a = 1; b = a;
cargue correctamente el 1 que acaba de almacenar.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:
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 leenum
repetidamente, entonces una posible intercalación válida es que el subproceso B nunca lee entrenum++
ynum--
. 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:
(es decir, imagine que otro hilo actualiza una barra de progreso basada en la interfaz de usuario
progress
)¿Puede el compilador convertir esto en:
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 todavía sería válido:: - /
fuente
volatile
objetos 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.lock
a 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 ...)And just remove the incr/decr entirely.
no está del todo bien. Sigue siendo una operación de adquisición y lanzamientonum
. 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.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.
fuente
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
++
aadd
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 accedernum
en un hilo), y de todos modos está equivocado, porque noadd
es atómico en x86.Tenga en cuenta que los atómicos (usando el
lock
prefijo 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":
Esto se compila en:
Incrementando un int pasado por referencia, la forma atómica:
Este ejemplo, que no es mucho más complejo que la forma habitual, solo
lock
agrega el prefijo a laincl
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.fuente
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. ^^
fuente
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.fuente
add
realmente 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 esolock
que existe el prefijo para las instrucciones; el único atómico útil seadd
aplica a la memoria desreferenciada y usa ellock
prefijo para garantizar que la línea de caché esté bloqueada durante la operación .add
es 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.