c++ c++11 language-lawyer memory-model

¿Se pueden combinar las cargas atómicas en el modelo de memoria C++?



c++11 language-lawyer (2)

Considere el fragmento de código C ++ 11 a continuación. Para GCC y clang, esto se compila en dos cargas (secuencialmente consistentes) de foo. ¿El modelo de memoria C ++ permite al compilador combinar estas dos cargas en una sola carga y usar el mismo valor para x e y?

Creo que no puede combinar estas cargas, porque eso significa que el sondeo de un atómico ya no funciona, pero no puedo encontrar la parte relevante en la documentación del modelo de memoria.

#include <atomic> #include <cstdio> std::atomic<int> foo; int main(int argc, char **argv) { int x = foo; int y = foo; printf("%d %d/n", x, y); return 0; }


Sí, en su ejemplo particular (no lo contrario).

Su ejemplo particular tiene un solo hilo de ejecución, foo tiene duración de almacenamiento estático e inicialización (es decir, antes de que se ingrese main ) y, de lo contrario, nunca se modifica durante la vida útil del programa.
En otras palabras, no hay una diferencia observable externamente, y la regla de "como si" se puede aplicar legalmente. De hecho, el compilador podría acabar con las instrucciones atómicas, en conjunto. No hay forma de que el valor de x o y pueda ser algo diferente, nunca.

En un programa con concurrencia que modifica foo , este no es el caso .

No especifica un modelo de memoria, por lo que se utiliza el modelo predeterminado, que es la coherencia secuencial. La consistencia secuencial se define como dar las mismas garantías de orden de suceso antes / memoria que la liberación / adquisición y establecer un único orden de modificación total de todas las operaciones atómicas. Ese último bit es la parte importante.

Un solo orden de modificación total significa que si tiene tres operaciones (atómicas), por ejemplo, A, B y C que suceden en ese orden (tal vez concurrentemente, en dos subprocesos), y B es una operación de escritura, mientras que A y C son operaciones de lectura. entonces C debe ver el estado establecido por B, no otro estado anterior. Es decir, el valor visto en los puntos A y C será diferente .

En términos de su ejemplo de código, si otro hilo modifica foo inmediatamente después de leerlo en x (pero antes de leer el valor en y ), el valor que se coloca en y debe ser el valor que se escribió. Porque si las operaciones ocurren en ese orden, también deben realizarse en ese orden.

Por supuesto, una escritura que suceda exactamente entre dos instrucciones de carga consecutivas es algo poco probable (ya que la ventana de tiempo es muy pequeña, un simple tic tac), pero no importa si es poco probable.
El compilador debe producir un código que garantice que, si surge esta constelación, las operaciones aún se vean exactamente en el orden en que ocurrieron.


Sí, porque no podemos observar la diferencia!

Se permite una implementación para convertir su fragmento de código en lo siguiente (pseudo-implementación).

int __loaded_foo = foo; int x = __loaded_foo; int y = __loaded_foo;

La razón es que no hay manera de que usted observe la diferencia entre lo anterior y dos cargas separadas de foo dadas las garantías de coherencia secuencial.

Nota : no es solo el compilador el que puede realizar dicha optimización, el procesador puede simplemente razonar que no hay forma en que pueda observar la diferencia y cargar el valor de foo una vez, incluso aunque el compilador le haya pedido que lo haga. dos veces



Explicación

Dado un hilo que sigue actualizando foo de manera incremental, lo que se garantiza es que y tendrá el mismo valor escrito, o uno posterior, en comparación con el contenido de x .

// thread 1 - The Writer while (true) { foo += 1; }

// thread 2 - The Reader while (true) { int x = foo; int y = foo; assert (y >= x); // will never fire, unless UB (foo has reached max value) }

Imagine que el hilo de escritura por alguna razón detiene su ejecución en cada iteración (debido a un cambio de contexto u otra razón definida por la implementación); no hay forma en que pueda probar que esto es lo que está causando que tanto x como y tengan el mismo valor, o si se debe a una "optimización de fusión".


En otras palabras, tenemos resultados potenciales dado el código en esta sección:

  1. No se escribe ningún valor nuevo en foo entre las dos lecturas ( x == y ).
  2. Se escribe un nuevo valor en foo entre las dos lecturas ( x < y ).

Como cualquiera de los dos puede suceder, una implementación es libre de limitar el alcance para simplemente ejecutar siempre uno de ellos; De ninguna manera podemos observar la diferencia.



¿Qué dice la norma?

Una implementación puede realizar los cambios que desee siempre que no podamos observar ninguna diferencia entre el comportamiento que expresamos y el comportamiento durante la ejecución.

Esto está cubierto en [intro.execution]p1 :

Las descripciones semánticas en esta Norma Internacional definen una máquina abstracta no determinista parametrizada. Esta norma internacional no impone ningún requisito a la estructura de las implementaciones conformes. En particular, no es necesario que copien o emulen la estructura de la máquina abstracta. Más bien, se requiere que las implementaciones conformes emulen (solo) el comportamiento observable de la máquina abstracta como se explica a continuación.

Otra sección que lo hace aún más claro [intro.execution]p5 :

Una implementación conforme que ejecute un programa bien formado producirá el mismo comportamiento observable que una de las posibles ejecuciones de la instancia correspondiente de la máquina abstracta con el mismo programa y la misma entrada.

Lectura adicional :



¿Qué pasa con las encuestas en un bucle?

// initial state std::atomic<int> foo = 0;

// thread 1 while (true) { if (foo) break; }

// thread 2 foo = 1

Pregunta : Dado el razonamiento en las secciones anteriores, ¿podría una implementación simplemente leer foo una vez en el subproceso 1 , y luego nunca salirse del bucle incluso si el subproceso 2 escribe en foo ?

La respuesta; No.

En un entorno secuencialmente consistente, tenemos la garantía de que una escritura a foo en el subproceso 2 se hará visible en el subproceso 1 ; esto significa que cuando esa escritura ha ocurrido, el subproceso 1 debe observar este cambio de estado.

Nota : una implementación puede convertir dos lecturas en una sola porque no podemos observar la diferencia (una fence es tan efectiva como dos), pero no puede ignorar completamente una lectura que existe por sí misma.

Nota : El contenido de esta sección está garantizado por [atomics.order]p3-4 .



¿Qué pasa si realmente quiero evitar esta forma de "optimización"?

Si desea forzar a la implementación a leer realmente el valor de alguna variable en cada punto en el que la haya escrito, debe considerar el uso de volatile (tenga en cuenta que esto de ninguna manera mejora la seguridad del subproceso).