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: -
- observable en otro hilo, y
- 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:
- 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).
- 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.
- 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:
-
Las comprobaciones de
ready
no se pueden optimizar de acuerdo con las reglas del idioma. -
El
++ready
ocurre antes de la verificación que veready
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 envec
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étodosstd::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.