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 usarlo memory_order_relaxed
si 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], 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 sin lock
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 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 num
later: 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=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 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 flag
ni siquiera es volatile
. (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 similar volatile
, pero hay mucho más que eso. Además, volatile std::atomic<int> foo
no 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::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.
#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 num
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 (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_ptr
se crea y destruye una copia de a , si el compilador puede probar que shared_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 , incluso 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
add
es atómico?