En C ++ 11, normalmente nunca se usa volatile
para subprocesos, solo para MMIO
Pero TL: DR, "funciona" como atómico con mo_relaxed
hardware con cachés coherentes (es decir, todo); es suficiente detener que los compiladores mantengan vars en registros. atomic
no necesita barreras de memoria para crear atomicidad o visibilidad entre subprocesos, solo para hacer que el subproceso actual espere antes / después de una operación para crear un orden entre los accesos de este subproceso a diferentes variables. mo_relaxed
nunca necesita barreras, solo carga, almacena o RMW.
Para rodar sus propios elementos atómicos con volatile
(y en línea asm para barreras) en los viejos tiempos malos antes de C ++ 11 std::atomic
, volatile
era la única buena manera de hacer que algunas cosas funcionen . Pero dependía de muchos supuestos sobre cómo funcionaban las implementaciones y nunca fue garantizado por ningún estándar.
Por ejemplo, el kernel de Linux todavía usa sus propios átomos atómicos volatile
, pero solo admite algunas implementaciones específicas de C (GNU C, clang y quizás ICC). En parte, eso se debe a las extensiones GNU C y a la sintaxis y semántica en línea de asm, pero también porque depende de algunos supuestos sobre cómo funcionan los compiladores.
Casi siempre es la elección incorrecta para nuevos proyectos; puede usar std::atomic
(con std::memory_order_relaxed
) para obtener un compilador que emita el mismo código de máquina eficiente que podría con volatile
. std::atomic
con mo_relaxed
obsoletos volatile
para propósitos de enhebrado. (excepto tal vez para evitar errores de optimización perdidos atomic<double>
en algunos compiladores ).
La implementación interna de std::atomic
compiladores convencionales (como gcc y clang) no solo se usa volatile
internamente; los compiladores exponen directamente las funciones integradas de carga atómica, almacenamiento y RMW. (por ejemplo, GNU C __atomic
incorporados que operan en objetos "simples").
Volátil es utilizable en la práctica (pero no lo hagas)
Dicho esto, volatile
se puede usar en la práctica para cosas como un exit_now
indicador en todas las implementaciones de C ++ existentes (?) En CPU reales, debido a cómo funcionan las CPU (cachés coherentes) y los supuestos compartidos sobre cómo volatile
debería funcionar. Pero no mucho más, y no es recomendable. El propósito de esta respuesta es explicar cómo funcionan realmente las CPU existentes y las implementaciones de C ++. Si no le importa eso, todo lo que necesita saber es que std::atomic
con mo_relaxed obsoletos volatile
para subprocesos.
(El estándar ISO C ++ es bastante vago, solo dice que los volatile
accesos deben evaluarse estrictamente de acuerdo con las reglas de la máquina abstracta C ++, no optimizados. Dado que las implementaciones reales usan el espacio de direcciones de memoria de la máquina para modelar el espacio de direcciones C ++, Esto significa que las volatile
lecturas y las tareas deben compilarse para cargar / almacenar instrucciones para acceder a la representación de objetos en la memoria).
Como señala otra respuesta, un exit_now
indicador es un caso simple de comunicación entre subprocesos que no necesita ninguna sincronización : no está publicando que el contenido de la matriz esté listo ni nada de eso. Solo una tienda que se nota rápidamente por una carga no optimizada en otro hilo.
// global
bool exit_now = false;
// in one thread
while (!exit_now) { do_stuff; }
// in another thread, or signal handler in this thread
exit_now = true;
Sin volátil o atómico, la regla as-if y la suposición de que no hay carrera de datos UB permite que un compilador lo optimice en asm que solo verifica la bandera una vez , antes de ingresar (o no) un bucle infinito. Esto es exactamente lo que sucede en la vida real para los compiladores reales. (Y generalmente se optimiza mucho do_stuff
porque el bucle nunca sale, por lo que cualquier código posterior que podría haber utilizado el resultado no es accesible si ingresamos al bucle).
// Optimizing compilers transform the loop into asm like this
if (!exit_now) { // check once before entering loop
while(1) do_stuff; // infinite loop
}
El programa de subprocesos múltiples se atascó en modo optimizado pero se ejecuta normalmente en -O0 es un ejemplo (con descripción de la salida asm de GCC) de cómo sucede exactamente esto con GCC en x86-64. También la programación MCU: la optimización de C ++ O2 se rompe mientras el bucle en la electrónica. SE muestra otro ejemplo.
Normalmente queremos optimizaciones agresivas que CSE y polipastos carguen de bucles, incluso para variables globales.
Antes de C ++ 11, volatile bool exit_now
era una forma de hacer que esto funcionara según lo previsto (en implementaciones normales de C ++). Pero en C ++ 11, el UB de carrera de datos todavía se aplica, por volatile
lo que el estándar ISO no garantiza que funcione en todas partes, incluso suponiendo cachés coherentes HW.
Tenga en cuenta que para los tipos más anchos, volatile
no garantiza la falta de rasgado. Ignoré esa distinción aquí bool
porque no es un problema en las implementaciones normales. Pero eso también es parte de por qué volatile
todavía está sujeto a la carrera de datos UB en lugar de ser equivalente a atómica relajada.
Tenga en cuenta que "según lo previsto" no significa que el subproceso en exit_now
espera a que el otro subproceso salga realmente. O incluso que espera a que la exit_now=true
tienda volátil sea incluso visible globalmente antes de continuar con las operaciones posteriores en este hilo. ( atomic<bool>
con el valor predeterminado mo_seq_cst
lo haría esperar antes de que seq_cst se cargue por lo menos más tarde. En muchos ISA, simplemente obtendría una barrera completa después de la tienda).
C ++ 11 proporciona una forma no UB que compila lo mismo
Se debe usar una bandera "seguir corriendo" o "salir ahora" std::atomic<bool> flag
conmo_relaxed
Utilizando
flag.store(true, std::memory_order_relaxed)
while( !flag.load(std::memory_order_relaxed) ) { ... }
le dará exactamente el mismo asm (sin instrucciones de barrera costosas) que obtendría volatile flag
.
Además de no rasgar, atomic
también le permite almacenar en un hilo y cargar en otro sin UB, por lo que el compilador no puede levantar la carga de un bucle. (La suposición de que no hay carrera de datos UB es lo que permite las optimizaciones agresivas que queremos para los objetos no volátiles no atómicos). Esta característica de atomic<T>
es más o menos la misma que volatile
para las cargas puras y las tiendas puras.
atomic<T>
también haga, +=
y así sucesivamente, operaciones atómicas de RMW (significativamente más caro que una carga atómica en un temporal, opere, luego una tienda atómica separada. Si no desea un RMW atómico, escriba su código con un temporal local).
Con el seq_cst
pedido predeterminado que obtendría while(!flag)
, también agrega garantías de pedido wrt. accesos no atómicos y a otros accesos atómicos.
(En teoría, el estándar ISO C ++ no descarta la optimización del tiempo de compilación de los atómicos. Pero en la práctica los compiladores no lo hacen porque no hay forma de controlar cuándo eso no estaría bien. Hay algunos casos en los que incluso volatile atomic<T>
no será suficiente control sobre la optimización de los compiladores atómicas si lo hicieron a optimizar, así que por ahora no lo hacen los compiladores. Ver ¿por qué no se fusionan compiladores std :: redundante escrituras atómicas? Tenga en cuenta que WG21 / p0062 desaconseja el uso volatile atomic
de código actual para protegerse de la optimización de atomística.)
volatile
en realidad funciona para esto en CPU reales (pero aún no lo uso)
incluso con modelos de memoria con un orden débil (no x86) . ¡Pero en realidad no lo use, use atomic<T>
en su mo_relaxed
lugar! El objetivo de esta sección es abordar las ideas erróneas sobre cómo funcionan las CPU reales, no justificarlas volatile
. Si está escribiendo código sin bloqueo, probablemente le interese el rendimiento. Comprender los cachés y los costos de la comunicación entre subprocesos suele ser importante para un buen rendimiento.
Las CPU reales tienen memorias caché coherentes / memoria compartida: después de que una tienda de un núcleo se vuelve globalmente visible, ningún otro núcleo puede cargar un valor obsoleto. (Consulte también Myths Programmers Believe about CPU Caches, que habla un poco sobre los volátiles de Java, equivalente a C ++ atomic<T>
con el orden de memoria seq_cst).
Cuando digo cargar , me refiero a una instrucción asm que accede a la memoria. Eso es lo que volatile
garantiza un acceso, y no es lo mismo que la conversión lvalue-to-rvalue de una variable C ++ no atómica / no volátil. (p . ej. local_tmp = flag
o while(!flag)
).
Lo único que debe vencer son las optimizaciones en tiempo de compilación que no se vuelven a cargar después de la primera comprobación. Cualquier carga + verificación en cada iteración es suficiente, sin ningún pedido. Sin sincronización entre este subproceso y el subproceso principal, no tiene sentido hablar sobre cuándo ocurrió exactamente la tienda u ordenar la carga wrt. otras operaciones en el bucle. Solo cuando es visible para este hilo es lo que importa. Cuando vea el conjunto de banderas exit_now, saldrá. La latencia entre núcleos en un Xeon x86 típico puede ser algo así como 40ns entre núcleos físicos separados .
En teoría: subprocesos C ++ en hardware sin cachés coherentes
No veo ninguna manera de que esto pueda ser remotamente eficiente, con solo ISO C ++ puro sin requerir que el programador realice descargas explícitas en el código fuente.
En teoría, podría tener una implementación de C ++ en una máquina que no fuera así, que requiere descargas explícitas generadas por el compilador para hacer que las cosas sean visibles para otros hilos en otros núcleos . (O para que las lecturas no utilicen una copia quizás obsoleta). El estándar C ++ no lo hace imposible, pero el modelo de memoria de C ++ está diseñado para ser eficiente en máquinas coherentes de memoria compartida. Por ejemplo, el estándar C ++ incluso habla de "coherencia de lectura-lectura", "coherencia de lectura-escritura", etc. Una nota en el estándar incluso señala la conexión al hardware:
http://eel.is/c++draft/intro.races#19
[Nota: Los cuatro requisitos de coherencia anteriores no permiten efectivamente la reordenación del compilador de operaciones atómicas a un solo objeto, incluso si ambas operaciones son cargas relajadas. Esto efectivamente hace que la garantía de coherencia de caché proporcionada por la mayoría del hardware esté disponible para las operaciones atómicas de C ++. - nota final]
No hay un mecanismo para que una release
tienda solo se vacíe y algunos rangos de direcciones selectos: tendría que sincronizar todo porque no sabría qué otros hilos querrían leer si su carga de adquisición viera esta tienda de lanzamiento (formando un La secuencia de lanzamiento que establece una relación de antes de pasar a través de subprocesos, lo que garantiza que las operaciones no atómicas anteriores realizadas por el subproceso de escritura ahora sean seguras de leer. ser realmente inteligente para demostrar que solo unas pocas líneas de caché necesitaban vaciarse.
Relacionado: mi respuesta en ¿Es seguro mov + mfence en NUMA? entra en detalles sobre la inexistencia de sistemas x86 sin memoria compartida coherente. También relacionado: Reordenamiento de cargas y tiendas en ARM para obtener más información sobre cargas / tiendas en la misma ubicación.
No son Creo que las agrupaciones con memoria compartida no coherente, pero no son máquinas de sistema de una sola imagen. Cada dominio de coherencia ejecuta un núcleo separado, por lo que no puede ejecutar hilos de un solo programa C ++ a través de él. En su lugar, ejecuta instancias separadas del programa (cada una con su propio espacio de direcciones: los punteros en una instancia no son válidos en la otra).
Para que se comuniquen entre sí a través de descargas explícitas, normalmente usaría MPI u otra API de transmisión de mensajes para hacer que el programa especifique qué rangos de direcciones necesitan enjuague.
El hardware real no se ejecuta a std::thread
través de los límites de coherencia de caché:
Existen algunos chips ARM asimétricos, con espacio de direcciones físicas compartidas pero no dominios de caché compartibles en el interior. Entonces no es coherente. (por ejemplo, un hilo de comentarios con un núcleo A8 y un Cortex-M3 como TI Sitara AM335x).
Pero se ejecutarían diferentes núcleos en esos núcleos, no una sola imagen del sistema que pudiera ejecutar hilos en ambos núcleos. No conozco ninguna implementación de C ++ que ejecute std::thread
hilos a través de núcleos de CPU sin cachés coherentes.
Para ARM específicamente, GCC y clang generan código asumiendo que todos los hilos se ejecutan en el mismo dominio interno compartible. De hecho, el manual ARMv7 ISA dice
Esta arquitectura (ARMv7) está escrita con la expectativa de que todos los procesadores que usan el mismo sistema operativo o hipervisor estén en el mismo dominio de compartición compartible interno
Por lo tanto, la memoria compartida no coherente entre dominios separados es solo una cosa para el uso explícito específico del sistema de regiones de memoria compartida para la comunicación entre diferentes procesos bajo diferentes núcleos.
Vea también esta discusión de CoreCLR sobre code-gen usando dmb ish
(Barrera interna compartible) vs. dmb sy
(Sistema) barreras de memoria en ese compilador.
Afirmo que ninguna implementación de C ++ para otro ISA se ejecuta std::thread
en núcleos con cachés no coherentes. No tengo pruebas de que no exista tal implementación, pero parece muy poco probable. A menos que esté apuntando a una pieza exótica específica de HW que funcione de esa manera, su pensamiento sobre el rendimiento debe asumir una coherencia de caché similar a MESI entre todos los hilos. ( atomic<T>
¡Sin embargo, use preferiblemente de manera que garantice la corrección!)
Los cachés coherentes lo hacen simple
Pero en un sistema multinúcleo con cachés coherentes, implementar un almacén de lanzamiento solo significa ordenar el compromiso en caché para las tiendas de este hilo, sin hacer ningún vaciado explícito. ( https://preshing.com/20120913/acquire-and-release-semantics/ y https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/ ). (Y una carga de adquisición significa ordenar el acceso a la memoria caché en el otro núcleo).
Una instrucción de barrera de memoria simplemente bloquea las cargas y / o almacenes del hilo actual hasta que el búfer de almacenamiento se agota; eso siempre sucede lo más rápido posible por sí solo. ( ¿Una barrera de memoria garantiza que se haya completado la coherencia de la memoria caché? Aborda esta idea errónea). Por lo tanto, si no necesita realizar un pedido, solo tiene que ver la visibilidad en otros hilos, mo_relaxed
está bien. (Y así es volatile
, pero no hagas eso).
Consulte también asignaciones de C / C ++ 11 a procesadores
Dato curioso: en x86, cada tienda asm es una tienda de lanzamiento porque el modelo de memoria x86 es básicamente seq-cst más un búfer de tienda (con reenvío de tienda).
Semi-relacionado re: almacenar búfer, visibilidad global y coherencia: C ++ 11 garantiza muy poco. La mayoría de los ISA reales (excepto PowerPC) garantizan que todos los hilos puedan estar de acuerdo en el orden de aparición de dos tiendas por otros dos hilos. (En la terminología formal del modelo de memoria de arquitectura de computadora, son "atómicas multicopia").
Otra idea errónea es que se necesitan instrucciones valla de memoria asm para vaciar el búfer tienda para otros núcleos para ver nuestras tiendas en absoluto . En realidad, el búfer de la tienda siempre intenta agotarse (comprometerse con la caché L1d) lo más rápido posible, de lo contrario se llenaría y detendría la ejecución. Lo que hace una barrera / cerca completa es detener el subproceso actual hasta que el búfer de la tienda se drene , para que nuestras cargas posteriores aparezcan en el orden global después de nuestras tiendas anteriores.
(El modelo de memoria asm fuertemente ordenado volatile
de x86 significa que en x86 puede terminar acercándote a él mo_acq_rel
, excepto que aún puede ocurrir un reordenamiento en tiempo de compilación con variables no atómicas. Pero la mayoría de los que no son x86 tienen modelos de memoria débilmente ordenados volatile
y relaxed
son casi iguales a débil como lo mo_relaxed
permite.)