¿La palabra clave volátil de C ++ introduce una barrera de memoria?

85

Entiendo que volatilele informa al compilador que el valor puede cambiar, pero para lograr esta funcionalidad, ¿el compilador necesita introducir una barrera de memoria para que funcione?

Según tengo entendido, la secuencia de operaciones en objetos volátiles no se puede reordenar y debe conservarse. Esto parece implicar que son necesarias algunas barreras de memoria y que realmente no hay una forma de evitar esto. ¿Estoy en lo correcto al decir esto?


Hay una discusión interesante en esta pregunta relacionada

Jonathan Wakely escribe :

... Los accesos a distintas variables volátiles no pueden ser reordenados por el compilador siempre que ocurran en expresiones completas separadas ... cierto que volátil es inútil para la seguridad de los subprocesos, pero no por las razones que él da. No es porque el compilador pueda reordenar los accesos a objetos volátiles, sino porque la CPU puede reordenarlos. Las operaciones atómicas y las barreras de memoria evitan que el compilador y la CPU reordenen

A lo que David Schwartz responde en los comentarios :

... No hay diferencia, desde el punto de vista del estándar C ++, entre el compilador haciendo algo y el compilador emitiendo instrucciones que hacen que el hardware haga algo. Si la CPU puede reordenar los accesos a los volátiles, entonces el estándar no requiere que se mantenga su orden. ...

... El estándar C ++ no hace ninguna distinción sobre lo que hace el reordenamiento. Y no se puede argumentar que la CPU puede reordenarlos sin ningún efecto observable, por lo que está bien: el estándar C ++ define su orden como observable. Un compilador cumple con el estándar C ++ en una plataforma si genera código que hace que la plataforma haga lo que requiere el estándar. Si el estándar requiere que los accesos a los volátiles no se reordenen, entonces una plataforma que los reordena no los cumple. ...

Mi punto es que si el estándar C ++ prohíbe al compilador reordenar los accesos a distintos volátiles, basándose en la teoría de que el orden de dichos accesos es parte del comportamiento observable del programa, entonces también requiere que el compilador emita código que prohíba a la CPU hacer entonces. El estándar no diferencia entre lo que hace el compilador y lo que el código de generación del compilador hace que haga la CPU.

Lo que da lugar a dos preguntas: ¿Alguno de ellos es "correcto"? ¿Qué hacen realmente las implementaciones reales?

Nathan Doromal
fuente
9
Principalmente significa que el compilador no debería mantener esa variable en un registro. Cada asignación y lectura en el código fuente debe corresponder a los accesos a la memoria en el código binario.
Basile Starynkevitch
1
Sospecho que el punto es que cualquier valla de memoria sería ineficaz si el valor se almacenara en un registro interno. Creo que aún necesita tomar otras medidas de protección en una situación concurrente.
Galik
Hasta donde yo sé, volatile se usa para variables que se pueden cambiar por hardware (a menudo se usa con microcontroladores). Simplemente significa que la lectura de la variable no se puede hacer en un orden diferente y no se puede optimizar. Sin embargo, eso es C, pero debería ser el mismo en ++.
Mástil
1
@Mast Todavía no he visto un compilador que evite que volatilelas memorias caché de la CPU optimicen las lecturas de variables. O todos estos compiladores no son conformes o el estándar no significa lo que usted cree que significa. (El estándar no distingue entre lo que hace el compilador y lo que el compilador hace que haga la CPU. El trabajo del compilador es emitir código que, cuando se ejecuta, cumple con el estándar.)
David Schwartz

Respuestas:

58

En lugar de explicar qué volatilehace, permítame explicarle cuándo debe usar volatile.

  • Cuando está dentro de un manejador de señales. Porque escribir en una volatilevariable es prácticamente lo único que el estándar le permite hacer desde un manejador de señales. Desde C ++ 11 puede usarlo std::atomicpara ese propósito, pero solo si el atómico no tiene bloqueos.
  • Cuando se trata de setjmp acuerdo con Intel .
  • Cuando se trata directamente de hardware y desea asegurarse de que el compilador no optimice sus lecturas o escrituras.

Por ejemplo:

volatile int *foo = some_memory_mapped_device;
while (*foo)
    ; // wait until *foo turns false

Sin el volatileespecificador, el compilador puede optimizar completamente el bucle. El volatileespecificador le dice al compilador que no puede asumir que 2 lecturas posteriores devuelven el mismo valor.

Tenga en cuenta que volatileno tiene nada que ver con los hilos. El ejemplo anterior no funciona si se ha escrito un hilo diferente *fooporque no hay ninguna operación de adquisición involucrada.

En todos los demás casos, el uso de volatiledebe considerarse no portátil y ya no debe pasar la revisión del código, excepto cuando se trata de compiladores anteriores a C ++ 11 y extensiones de compilador (como el /volatile:msconmutador de msvc , que está habilitado de forma predeterminada en X86 / I64).

Stefan
fuente
5
Es más estricto que "no se puede asumir que 2 lecturas posteriores devuelven el mismo valor". Incluso si solo lee una vez y / o tira los valores, la lectura debe realizarse.
philipxy
1
El uso en manejadores de señales y setjmpson las dos garantías que hace el estándar. Por otro lado, la intención , al menos al principio, era admitir IO mapeadas en memoria. Lo que en algunos procesadores puede requerir una valla o un membar.
James Kanze
@philipxy Excepto que nadie sabe lo que significa "la lectura". Por ejemplo, nadie cree que se deba realizar una lectura real de la memoria; ningún compilador que conozca intenta eludir los cachés de la CPU en los volatileaccesos.
David Schwartz
@JamesKanze: No es así. Con respecto a los manejadores de señales, el estándar dice que durante el manejo de señales solo los objetos atómicos volátiles std :: sig_atomic_t y sin bloqueo tienen valores definidos. Pero también dice que los accesos a objetos volátiles son efectos secundarios observables.
philipxy
1
@DavidSchwartz Algunos pares compilador-arquitectura mapean la secuencia de accesos estándar especificada a los efectos reales y los programas de trabajo acceden a los volátiles para obtener esos efectos. El hecho de que algunos de estos pares no tengan un mapeo o un mapeo trivial que no ayude es relevante para la calidad de las implementaciones, pero no al punto en cuestión.
philipxy
25

¿La palabra clave volátil de C ++ introduce una barrera de memoria?

No se requiere un compilador de C ++ que cumpla con la especificación para introducir una barrera de memoria. Su compilador particular podría; dirija su pregunta a los autores de su compilador.

La función de "volátil" en C ++ no tiene nada que ver con el subproceso. Recuerde, el propósito de "volatile" es deshabilitar las optimizaciones del compilador para que la lectura de un registro que está cambiando debido a condiciones exógenas no se optimice. ¿Es una dirección de memoria en la que está escribiendo un hilo diferente en una CPU diferente un registro que está cambiando debido a condiciones exógenas? No. Nuevamente, si algunos autores de compiladores han optado por tratar las direcciones de memoria en las que se escriben diferentes subprocesos en diferentes CPU como si fueran registros que cambian debido a condiciones exógenas, ese es su problema; no están obligados a hacerlo. Tampoco son necesarios, incluso si introduce una barrera de memoria, para, por ejemplo, garantizar que cada hilo ve una constante ordenación de lecturas y escrituras volátiles.

De hecho, volátil es bastante inútil para subprocesos en C / C ++. La mejor práctica es evitarlo.

Además: las barreras de memoria son un detalle de implementación de arquitecturas de procesador particulares. En C #, donde volatile está diseñado explícitamente para múltiples subprocesos, la especificación no dice que se introducirán medias vallas, porque el programa podría estar ejecutándose en una arquitectura que no tiene vallas en primer lugar. Más bien, nuevamente, la especificación ofrece ciertas garantías (extremadamente débiles) sobre qué optimizaciones evitará el compilador, el tiempo de ejecución y la CPU para poner ciertas restricciones (extremadamente débiles) sobre cómo se ordenarán algunos efectos secundarios. En la práctica, estas optimizaciones se eliminan mediante el uso de medias vallas, pero ese es un detalle de implementación sujeto a cambios en el futuro.

El hecho de que le importe la semántica de volatile en cualquier idioma en lo que respecta al subproceso múltiple indica que está pensando en compartir memoria entre subprocesos. Considere simplemente no hacer eso. Hace que su programa sea mucho más difícil de comprender y mucho más probable que contenga errores sutiles imposibles de reproducir.

Eric Lippert
fuente
19
"volátil es bastante inútil en C / C ++". ¡De ningún modo! Tiene una visión del mundo muy centrada en el modo de usuario y el escritorio ... pero la mayoría del código C y C ++ se ejecuta en sistemas integrados donde la volatilidad es muy necesaria para las E / S mapeadas en memoria.
Ben Voigt
12
Y la razón por la que se conserva el acceso volátil no es simplemente porque las condiciones exógenas pueden cambiar las ubicaciones de la memoria. El propio acceso puede desencadenar más acciones. Por ejemplo, es muy común que una lectura avance un FIFO o borre una bandera de interrupción.
Ben Voigt
3
@BenVoigt: Inútil para lidiar de manera efectiva con los problemas de enhebrado fue mi significado previsto.
Eric Lippert
4
@DavidSchwartz El estándar obviamente no puede garantizar cómo funciona la E / S mapeada en memoria. Pero la E / S con mapa de memoria es la razón por la que volatilese introdujo en el estándar C. Aún así, debido a que el estándar no puede especificar cosas como lo que realmente sucede en un "acceso", dice que "Lo que constituye un acceso a un objeto que tiene un tipo calificado de volátil está definido por la implementación". Demasiadas implementaciones en la actualidad no proporcionan una definición útil de un acceso, lo que en mi humilde opinión viola el espíritu del estándar, incluso si se ajusta a la letra.
James Kanze
8
Esa edición es una mejora definitiva, pero su explicación todavía está demasiado centrada en "la memoria podría cambiarse de forma exógena". volatileLa semántica es más fuerte que eso, el compilador tiene que generar cada acceso solicitado (1.9 / 8, 1.9 / 12), no simplemente garantizar que los cambios exógenos sean eventualmente detectados (1.10 / 27). En el mundo de las E / S mapeadas en memoria, una lectura de memoria puede tener una lógica asociada arbitraria, como un captador de propiedades. No optimizaría las llamadas a los captadores de propiedades de acuerdo con las reglas que ha establecido volatile, ni el Estándar lo permite.
Ben Voigt
13

Lo que David está pasando por alto es el hecho de que el estándar C ++ especifica el comportamiento de varios subprocesos que interactúan solo en situaciones específicas y todo lo demás da como resultado un comportamiento indefinido. Una condición de carrera que involucre al menos una escritura no está definida si no usa variables atómicas.

En consecuencia, el compilador está perfectamente en su derecho a renunciar a cualquier instrucción de sincronización ya que su CPU solo notará la diferencia en un programa que exhibe un comportamiento indefinido debido a la falta de sincronización.

Voo
fuente
5
Bien explicado, gracias. El estándar solo define la secuencia de accesos a los volátiles como observables siempre que el programa no tenga un comportamiento indefinido .
Jonathan Wakely
4
Si el programa tiene una carrera de datos, entonces el estándar no establece requisitos sobre el comportamiento observable del programa. No se espera que el compilador agregue barreras a los accesos volátiles para evitar carreras de datos presentes en el programa, ese es el trabajo del programador, ya sea mediante el uso de barreras explícitas o operaciones atómicas.
Jonathan Wakely
¿Por qué crees que lo estoy pasando por alto? ¿Qué parte de mi argumento cree que invalida? Estoy 100% de acuerdo en que el compilador está perfectamente en su derecho a renunciar a cualquier sincronización.
David Schwartz
2
Esto es simplemente incorrecto, o al menos, ignora lo esencial. volatileno tiene nada que ver con hilos; su propósito original era soportar IO mapeado en memoria. Y al menos en algunos procesadores, admitir E / S mapeadas en memoria requeriría vallas. (Los compiladores no hacen esto, pero ese es un problema diferente).
James Kanze
@JamesKanze volatiletiene mucho que ver con los subprocesos: se volatileocupa de la memoria a la que se puede acceder sin que el compilador sepa que se puede acceder a ella, y eso cubre muchos usos del mundo real de datos compartidos entre subprocesos en una CPU específica.
Curioso
12

En primer lugar, los estándares C ++ no garantizan las barreras de memoria necesarias para ordenar correctamente las lecturas / escrituras que no son atómicas. Se recomiendan variables volátiles para usar con MMIO, manejo de señales, etc. En la mayoría de las implementaciones, volatile no es útil para multi-threading y generalmente no se recomienda.

En cuanto a la implementación de accesos volátiles, esta es la elección del compilador.

Este artículo , que describe el comportamiento de gcc , muestra que no puede usar un objeto volátil como barrera de memoria para ordenar una secuencia de escrituras en la memoria volátil.

Con respecto al comportamiento de icc , encontré que esta fuente también dice que volatile no garantiza ordenar accesos a la memoria.

El compilador de Microsoft VS2013 tiene un comportamiento diferente. Esta documentación explica cómo volatile aplica la semántica Release / Acquire y permite que los objetos volátiles se utilicen en bloqueos / liberaciones en aplicaciones multiproceso.

Otro aspecto que debe tenerse en cuenta es que el mismo compilador puede tener un comportamiento diferente wrt. volátil dependiendo de la arquitectura de hardware de destino . Esta publicación sobre el compilador de MSVS 2013 establece claramente los detalles de la compilación con volátiles para plataformas ARM.

Entonces mi respuesta a:

¿La palabra clave volátil de C ++ introduce una barrera de memoria?

sería: No garantizado, probablemente no, pero algunos compiladores podrían hacerlo. No debe confiar en el hecho de que lo hace.

VAndrei
fuente
2
No evita la optimización, solo evita que el compilador altere cargas y almacenes más allá de ciertas restricciones.
Dietrich Epp
No está claro lo que estás diciendo. ¿Está diciendo que sucede que es el caso de algunos compiladores no especificados que volatileevita que el compilador reordene cargas / almacenes? ¿O está diciendo que el estándar C ++ requiere que lo haga? Y si es lo último, ¿puede responder a mi argumento en sentido contrario citado en la pregunta original?
David Schwartz
@DavidSchwartz El estándar evita un reordenamiento (de cualquier fuente) de accesos a través de un volatilelvalue. Dado que deja la definición de "acceso" a la implementación, sin embargo, esto no nos compra mucho si a la implementación no le importa.
James Kanze
Creo que algunas versiones de los compiladores de MSC implementaron semántica de vallas volatile, pero no hay vallas en el código generado por el compilador en Visual Studios 2012.
James Kanze
@JamesKanze Lo que básicamente significa que el único comportamiento portátil de volatilees el específicamente enumerado por el estándar. ( setjmp, señales, etc.)
David Schwartz
7

El compilador solo inserta una barrera de memoria en la arquitectura Itanium, hasta donde yo sé.

La volatilepalabra clave se usa mejor para cambios asincrónicos, por ejemplo, manejadores de señales y registros mapeados en memoria; Por lo general, es la herramienta incorrecta para utilizar en la programación multiproceso.

Dietrich Epp
fuente
1
Algo así como. 'el compilador' (msvc) inserta una barrera de memoria cuando se apunta a una arquitectura diferente a ARM y se usa el modificador / volatile: ms (el valor predeterminado). Consulte msdn.microsoft.com/en-us/library/12a04hfd.aspx . Otros compiladores no insertan vallas en variables volátiles que yo sepa. Se debe evitar el uso de volátiles a menos que se trate directamente de hardware, manejadores de señales o compiladores que no cumplan con C ++ 11.
Stefan
@Stefan No. volatilees extremadamente útil para muchos usos que nunca tratan con hardware. Siempre que desee que la implementación genere un código de CPU que siga de cerca el código C / C ++, utilice volatile.
curioso
7

Depende de qué compilador sea "el compilador". Visual C ++ lo hace, desde 2005. Pero el estándar no lo requiere, por lo que algunos otros compiladores no lo hacen.

Ben Voigt
fuente
VC ++ 2012 no parece que inserte una valla: int volatile i; int main() { return i; }genera una principal con exactamente dos instrucciones: mov eax, i; ret 0;.
James Kanze
@JamesKanze: ¿Qué versión, exactamente? ¿Está utilizando alguna opción de compilación no predeterminada? Confío en la documentación (primera versión afectada) y (última versión) , que definitivamente mencionan la semántica de adquisición y liberación.
Ben Voigt
cl /helpdice la versión 18.00.21005.1. El directorio en el que se encuentra es C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC. El encabezado de la ventana de comandos dice VS 2013. Entonces, con respecto a la versión ... Las únicas opciones que usé fueron /c /O2 /Fa. (Sin el /O2, también configura el marco de pila local. Pero todavía no hay instrucciones de valla.)
James Kanze
@JamesKanze: Estaba más interesado en la arquitectura, por ejemplo, "Microsoft (R) C / C ++ Optimizing Compiler Version 18.00.30723 para x64". Quizás no haya una barrera porque x86 y x64 tienen garantías de coherencia de caché bastante sólidas en su modelo de memoria para empezar. ?
Ben Voigt
Tal vez. Realmente no lo sé. El hecho de que hice esto main, para que el compilador pudiera ver todo el programa y saber que no había otros hilos, o al menos ningún otro acceso a la variable antes que la mía (por lo que no podría haber problemas de caché) podría afectar esto. también, pero de alguna manera, lo dudo.
James Kanze
5

Esto se debe principalmente a la memoria y se basa en versiones anteriores a C ++ 11, sin subprocesos. Pero habiendo participado en las discusiones sobre el hilo en el comité, puedo decir que el comité nunca tuvo la intención devolatile pudiera usarse para la sincronización entre subprocesos. Microsoft lo propuso, pero la propuesta no se llevó a cabo.

La especificación clave de volatilees que el acceso a un volátil representa un "comportamiento observable", al igual que IO. De la misma manera, el compilador no puede reordenar o eliminar E / S específicas, no puede reordenar o eliminar accesos a un objeto volátil (o más correctamente, accesos a través de una expresión lvalue con tipo calificado volátil). La intención original de volatile era, de hecho, soportar IO mapeado en memoria. El "problema" con esto, sin embargo, es que es la implementación definida lo que constituye un "acceso volátil". Y muchos compiladores lo implementan como si la definición fuera "se ha ejecutado una instrucción que lee o escribe en la memoria". Cuál es una definición legal, aunque inútil, si la implementación lo especifica. (Todavía tengo que encontrar la especificación real de ningún compilador.

Podría decirse (y es un argumento que acepto), esto viola la intención del estándar, ya que a menos que el hardware reconozca las direcciones como E / S mapeadas en memoria e inhiba cualquier reordenación, etc., ni siquiera puede usar volatile para E / S mapeadas en memoria, al menos en arquitecturas Sparc o Intel. Sin embargo, ninguno de los compiladores que he visto (Sun CC, g ++ y MSC) genera instrucciones de cerca o membar. (Para el momento en que Microsoft propuso extender las reglas volatile, creo que algunos de sus compiladores implementaron su propuesta y emitieron instrucciones de cerca para accesos volátiles. No he verificado qué hacen los compiladores recientes, pero no me sorprendería si dependiera en alguna opción del compilador. Sin embargo, la versión que verifiqué (creo que fue VS6.0) no emitió vallas).

James Kanze
fuente
¿Por qué simplemente dice que el compilador no puede reordenar o eliminar accesos a objetos volátiles? Seguramente si los accesos tienen un comportamiento observable, entonces seguramente es precisamente igualmente importante evitar que la CPU, los búferes de publicación de escritura, el controlador de memoria y todo lo demás los reordenen también.
David Schwartz
@DavidSchwartz Porque eso es lo que dice el estándar. Ciertamente, desde un punto de vista práctico, lo que hacen los compiladores que he verificado es totalmente inútil, pero las palabras de comadreja estándar son suficientes para que aún puedan reclamar conformidad (o podrían, si realmente lo documentaron).
James Kanze
1
@DavidSchwartz: Para E / S exclusivas (o mutex'd) mapeadas en memoria a periféricos, la volatilesemántica es perfectamente adecuada. Generalmente, estos periféricos informan que sus áreas de memoria no se pueden almacenar en caché, lo que ayuda a reordenar a nivel de hardware.
Ben Voigt
@BenVoigt De alguna manera me preguntaba sobre eso: la idea de que el procesador de alguna manera "sabe" que la dirección con la que está tratando es E / S mapeada en memoria. Hasta donde yo sé, Sparcs no tiene soporte para esto, por lo que aún haría que Sun CC y g ++ en un Sparc sean inutilizables para E / S mapeadas en memoria. (Cuando miré esto, estaba principalmente interesado en un Sparc.)
James Kanze
@JamesKanze: Por la poca búsqueda que hice, parece que Sparc tiene rangos de direcciones dedicados para "vistas alternativas" de memoria que no se pueden almacenar en caché. Siempre que sus puntos de acceso volátiles entren en la ASI_REAL_IOparte del espacio de direcciones, creo que debería estar bien. (Altera NIOS usa una técnica similar, con altos bits de la dirección que controlan el bypass de MMU; estoy seguro de que también hay otros)
Ben Voigt
5

No es necesario. Volátil no es una primitiva de sincronización. Simplemente deshabilita las optimizaciones, es decir, obtiene una secuencia predecible de lecturas y escrituras dentro de un hilo en el mismo orden que prescribe la máquina abstracta. Pero las lecturas y escrituras en diferentes hilos no tienen orden en primer lugar, no tiene sentido hablar de preservar o no preservar su orden. El orden entre los cabezales se puede establecer mediante primitivas de sincronización, se obtiene UB sin ellas.

Un poco de explicación sobre las barreras de la memoria. Una CPU típica tiene varios niveles de acceso a la memoria. Hay una canalización de memoria, varios niveles de caché, luego RAM, etc.

Las instrucciones de Membar descargan la tubería. No cambian el orden en el que se ejecutan las lecturas y escrituras, solo obliga a que las pendientes se ejecuten en un momento dado. Es útil para programas multiproceso, pero no mucho más.

La (s) caché (s) normalmente son coherentes automáticamente entre las CPU. Si uno quiere asegurarse de que la caché esté sincronizada con la RAM, se necesita vaciar la caché. Es muy diferente a un membar.

n.m.
fuente
1
Entonces, ¿estás diciendo que el estándar C ++ dice que volatilesolo deshabilita las optimizaciones del compilador? Eso no tiene ningún sentido. Cualquier optimización que pueda hacer el compilador puede, al menos en principio, ser igualmente bien realizada por la CPU. Entonces, si el estándar dice que solo deshabilita las optimizaciones del compilador, eso significaría que no proporcionaría ningún comportamiento en el que uno pueda confiar en el código portátil. Pero obviamente eso no es cierto porque el código portátil puede depender de su comportamiento con respecto a las setjmpseñales.
David Schwartz
1
@DavidSchwartz No, el estándar no dice tal cosa. Deshabilitar las optimizaciones es lo que se suele hacer para implementar el estándar. El estándar requiere que el comportamiento observable ocurra en el mismo orden que requiere la máquina abstracta. Cuando la máquina abstracta no requiere ningún pedido, la implementación es libre de usar cualquier pedido o ningún pedido. El acceso a variables volátiles en diferentes subprocesos no se ordena a menos que se aplique una sincronización adicional.
n. 'pronombres' m.
1
@DavidSchwartz Pido disculpas por la redacción imprecisa. El estándar no requiere que las optimizaciones estén deshabilitadas. No tiene ninguna noción de optimización. Más bien, especifica un comportamiento que en la práctica requiere que los compiladores deshabiliten ciertas optimizaciones de tal manera que la secuencia observable de lecturas y escrituras cumpla con el estándar.
n. 'pronombres' m.
1
Excepto que no requiere eso, porque el estándar permite que las implementaciones definan la "secuencia observable de lecturas y escrituras" como quieran. Si las implementaciones optan por definir secuencias observables de manera que las optimizaciones deben desactivarse, entonces lo hacen. Si no, entonces no. Obtiene una secuencia predecible de lecturas y escrituras si, y solo si, la implementación ha decidido proporcionársela.
David Schwartz
1
No, la implementación debe definir qué constituye un acceso único. La secuencia de tales accesos la prescribe la máquina abstracta. Una implementación tiene que preservar el orden. El estándar dice explícitamente que "volátil es una pista para la implementación para evitar una optimización agresiva que involucre al objeto", aunque en una parte no normativa, pero la intención es clara.
n. 'pronombres' m.
4

El compilador necesita introducir una barrera de memoria alrededor de los volatileaccesos si, y solo si, es necesario para que los usos volatileespecificados en el estándar funcionen ( setjmp, manejadores de señales, etc.) en esa plataforma en particular.

Tenga en cuenta que algunos compiladores van mucho más allá de lo que requiere el estándar C ++ para ser volatilemás potentes o útiles en esas plataformas. El código portátil no debería depender volatilede hacer nada más allá de lo que se especifica en el estándar C ++.

David Schwartz
fuente
2

Siempre uso volátil en rutinas de servicio de interrupción, por ejemplo, el ISR (a menudo código ensamblador) modifica alguna ubicación de memoria y el código de nivel superior que se ejecuta fuera del contexto de interrupción accede a la ubicación de memoria a través de un puntero a volátil.

Hago esto para RAM así como para IO mapeado en memoria.

Según la discusión aquí, parece que este sigue siendo un uso válido de volatile pero no tiene nada que ver con múltiples subprocesos o CPU. Si el compilador de un microcontrolador "sabe" que no puede haber ningún otro acceso (por ejemplo, todo está en el chip, no hay caché y solo hay un núcleo), pensaría que una barrera de memoria no está implícita en absoluto, el compilador solo necesita evitar ciertas optimizaciones.

A medida que acumulamos más cosas en el "sistema" que ejecuta el código objeto, casi todas las apuestas se cancelan, al menos así es como leo esta discusión. ¿Cómo podría un compilador cubrir todas las bases?

Andrew Queisser
fuente
0

Creo que la confusión en torno al reordenamiento de instrucciones y volátiles proviene de las 2 nociones de reordenamiento que hacen las CPU:

  1. Ejecución fuera de servicio.
  2. Secuencia de lectura / escritura de memoria vista por otras CPU (reordenando en el sentido de que cada CPU puede ver una secuencia diferente).

Volátil afecta la forma en que un compilador genera el código asumiendo una ejecución de un solo subproceso (esto incluye interrupciones). No implica nada sobre las instrucciones de la barrera de la memoria, sino que impide que un compilador realice ciertos tipos de optimizaciones relacionadas con los accesos a la memoria.
Un ejemplo típico es recuperar un valor de la memoria, en lugar de usar uno almacenado en caché en un registro.

Ejecución fuera de orden

Las CPU pueden ejecutar instrucciones fuera de orden / especulativamente siempre que el resultado final pudiera haber ocurrido en el código original. Las CPU pueden realizar transformaciones que no están permitidas en los compiladores porque los compiladores solo pueden realizar transformaciones que sean correctas en todas las circunstancias. Por el contrario, las CPU pueden comprobar la validez de estas optimizaciones y retirarse de ellas si resultan ser incorrectas.

Secuencia de lectura / escritura de memoria vista por otras CPU

El resultado final de una secuencia de instrucción, el orden efectivo, debe coincidir con la semántica del código generado por un compilador. Sin embargo, el orden de ejecución real elegido por la CPU puede ser diferente. El orden efectivo como se ve en otras CPU (cada CPU puede tener una vista diferente) puede verse limitado por barreras de memoria.
No estoy seguro de cuánto puede diferir el orden efectivo y el real porque no sé hasta qué punto las barreras de memoria pueden impedir que las CPU realicen una ejecución fuera de orden.

Fuentes:

Pawel Batko
fuente
0

Mientras trabajaba en un video tutorial descargable en línea para el desarrollo de gráficos 3D y motores de juegos trabajando con OpenGL moderno. Usamos volatiledentro de una de nuestras clases. El sitio web del tutorial se puede encontrar aquí y el video que trabaja con la volatilepalabra clave se encuentra en el Shader Enginevideo de la serie 98. Estos trabajos no son míos, pero están acreditados Marek A. Krzeminski, MAScy este es un extracto de la página de descarga del video.

"Dado que ahora podemos hacer que nuestros juegos se ejecuten en varios subprocesos, es importante sincronizar los datos entre subprocesos correctamente. En este video, muestro cómo crear una clase de bloqueo volátil para garantizar que las variables volátiles estén correctamente sincronizadas ..."

Y si está suscrito a su sitio web y tiene acceso a sus videos dentro de este video, hace referencia a este artículo sobre el uso de Volatileconmultithreading programación.

Aquí está el artículo del enlace anterior: http://www.drdobbs.com/cpp/volatile-the-multithreaded-programmers-b/184403766

volátil: el mejor amigo del programador multiproceso

Por Andrei Alexandrescu, 01 de febrero de 2001

La palabra clave volátil se diseñó para evitar las optimizaciones del compilador que podrían hacer que el código sea incorrecto en presencia de ciertos eventos asincrónicos.

No quiero estropear su estado de ánimo, pero esta columna aborda el temido tema de la programación multiproceso. Si, como dice la entrega anterior de Generic, la programación segura para excepciones es difícil, es un juego de niños en comparación con la programación multiproceso.

Los programas que utilizan varios subprocesos son notoriamente difíciles de escribir, demostrar que son correctos, depurar, mantener y domesticar en general. Los programas multiproceso incorrectos pueden ejecutarse durante años sin un problema técnico, solo para volverse inesperadamente fuera de control porque se ha cumplido alguna condición de tiempo crítica.

No hace falta decir que un programador que escribe código multiproceso necesita toda la ayuda que pueda obtener. Esta columna se centra en las condiciones de carrera, una fuente común de problemas en los programas multiproceso, y le brinda información y herramientas sobre cómo evitarlas y, sorprendentemente, hacer que el compilador trabaje duro para ayudarlo con eso.

Solo una pequeña palabra clave

Aunque tanto los estándares C como C ++ son notoriamente silenciosos cuando se trata de subprocesos, hacen una pequeña concesión al subproceso múltiple, en la forma de la palabra clave volátil.

Al igual que su contraparte más conocida const, volatile es un modificador de tipo. Está diseñado para usarse junto con variables a las que se accede y se modifican en diferentes subprocesos. Básicamente, sin volátiles, la escritura de programas multiproceso se vuelve imposible o el compilador desperdicia grandes oportunidades de optimización. Se necesita una explicación.

Considere el siguiente código:

class Gadget {
public:
    void Wait() {
        while (!flag_) {
            Sleep(1000); // sleeps for 1000 milliseconds
        }
    }
    void Wakeup() {
        flag_ = true;
    }
    ...
private:
    bool flag_;
};

El propósito de Gadget :: Wait anterior es verificar la variable de miembro flag_ cada segundo y regresar cuando esa variable haya sido establecida en verdadera por otro hilo. Al menos eso es lo que pretendía su programador, pero, lamentablemente, Wait es incorrecto.

Suponga que el compilador descubre que Sleep (1000) es una llamada a una biblioteca externa que no puede modificar la variable miembro flag_. Luego, el compilador concluye que puede almacenar en caché flag_ en un registro y usar ese registro en lugar de acceder a la memoria interna más lenta. Esta es una excelente optimización para código de un solo subproceso, pero en este caso, perjudica la corrección: después de llamar a Wait para algún objeto Gadget, aunque otro subproceso llame a Wakeup, Wait se repetirá para siempre. Esto se debe a que el cambio de flag_ no se reflejará en el registro que almacena en caché flag_. La optimización es demasiado ... optimista.

El almacenamiento en caché de variables en registros es una optimización muy valiosa que se aplica la mayor parte del tiempo, por lo que sería una pena desperdiciarla. C y C ++ le dan la oportunidad de deshabilitar explícitamente dicho almacenamiento en caché. Si usa el modificador volátil en una variable, el compilador no almacenará en caché esa variable en los registros; cada acceso alcanzará la ubicación de memoria real de esa variable. Entonces, todo lo que tienes que hacer para que el combo Wait / Wakeup de Gadget funcione es calificar flag_ apropiadamente:

class Gadget {
public:
    ... as above ...
private:
    volatile bool flag_;
};

La mayoría de las explicaciones de la justificación y el uso de volatile se detienen aquí y le aconsejan que califique los tipos primitivos que usa en múltiples subprocesos. Sin embargo, hay mucho más que puede hacer con volatile, porque es parte del maravilloso sistema de tipos de C ++.

Uso de volátiles con tipos definidos por el usuario

Puede calificar de forma volátil no solo los tipos primitivos, sino también los tipos definidos por el usuario. En ese caso, volatile modifica el tipo de forma similar a const. (También puede aplicar const y volatile al mismo tipo simultáneamente).

A diferencia de const, volatile discrimina entre tipos primitivos y tipos definidos por el usuario. Es decir, a diferencia de las clases, los tipos primitivos aún admiten todas sus operaciones (suma, multiplicación, asignación, etc.) cuando están calificados como volátiles. Por ejemplo, puede asignar un int no volátil a un int volátil, pero no puede asignar un objeto no volátil a un objeto volátil.

Ilustremos cómo funciona volatile en tipos definidos por el usuario en un ejemplo.

class Gadget {
public:
    void Foo() volatile;
    void Bar();
    ...
private:
    String name_;
    int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;

Si cree que lo volátil no es tan útil con los objetos, prepárese para una sorpresa.

volatileGadget.Foo(); // ok, volatile fun called for
                  // volatile object
regularGadget.Foo();  // ok, volatile fun called for
                  // non-volatile object
volatileGadget.Bar(); // error! Non-volatile function called for
                  // volatile object!

La conversión de un tipo no calificado a su contraparte volátil es trivial. Sin embargo, al igual que con const, no puede regresar de volátil a no calificado. Debes usar un yeso:

Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar(); // ok

Una clase calificada como volátil da acceso solo a un subconjunto de su interfaz, un subconjunto que está bajo el control del implementador de la clase. Los usuarios pueden obtener acceso completo a la interfaz de ese tipo solo mediante el uso de const_cast. Además, al igual que la constness, la volatilidad se propaga de la clase a sus miembros (por ejemplo, volatileGadget.name_ y volatileGadget.state_ son variables volátiles).

volátiles, secciones críticas y condiciones de carrera

El dispositivo de sincronización más simple y más utilizado en programas multiproceso es el mutex. Un mutex expone las primitivas Adquirir y Liberar. Una vez que llame a Acquire en algún hilo, cualquier otro hilo que llame a Acquire se bloqueará. Más tarde, cuando ese hilo llame a Release, se liberará precisamente un hilo bloqueado en una llamada Acquire. En otras palabras, para un mutex dado, solo un subproceso puede obtener tiempo de procesador entre una llamada a Adquirir y una llamada a Liberar. El código de ejecución entre una llamada a Adquirir y una llamada a Liberar se denomina sección crítica. (La terminología de Windows es un poco confusa porque llama al mutex en sí mismo una sección crítica, mientras que "mutex" es en realidad un mutex entre procesos. Hubiera sido bueno si se llamaran subproceso mutex y proceso mutex).

Los mutex se utilizan para proteger los datos contra las condiciones de carrera. Por definición, una condición de carrera ocurre cuando el efecto de más subprocesos en los datos depende de cómo se programan los subprocesos. Las condiciones de carrera aparecen cuando dos o más subprocesos compiten por usar los mismos datos. Debido a que los subprocesos pueden interrumpirse entre sí en momentos arbitrarios, los datos pueden corromperse o malinterpretarse. En consecuencia, los cambios y, a veces, los accesos a los datos deben protegerse cuidadosamente con secciones críticas. En la programación orientada a objetos, esto generalmente significa que almacena un mutex en una clase como una variable miembro y lo usa cada vez que accede al estado de esa clase.

Los programadores de multiproceso experimentados pueden haber bostezado leyendo los dos párrafos anteriores, pero su propósito es proporcionar un entrenamiento intelectual, porque ahora lo vincularemos con la conexión volátil. Hacemos esto trazando un paralelo entre el mundo de los tipos de C ++ y el mundo de la semántica de subprocesos.

  • Fuera de una sección crítica, cualquier hilo puede interrumpir cualquier otro en cualquier momento; no hay control, por lo que las variables accesibles desde múltiples hilos son volátiles. Esto está en consonancia con la intención original de volatile: la de evitar que el compilador almacene en caché sin saberlo los valores utilizados por varios subprocesos a la vez.
  • Dentro de una sección crítica definida por un mutex, solo un hilo tiene acceso. En consecuencia, dentro de una sección crítica, el código de ejecución tiene semántica de un solo subproceso. La variable controlada ya no es volátil; puede eliminar el calificador volátil.

En resumen, los datos compartidos entre subprocesos son conceptualmente volátiles fuera de una sección crítica y no volátiles dentro de una sección crítica.

Entras en una sección crítica bloqueando un mutex. Elimina el calificador volátil de un tipo aplicando un const_cast. Si logramos unir estas dos operaciones, creamos una conexión entre el sistema de tipos de C ++ y la semántica de subprocesos de una aplicación. Podemos hacer que el compilador compruebe las condiciones de carrera por nosotros.

LockingPtr

Necesitamos una herramienta que recopile una adquisición de mutex y un const_cast. Desarrollemos una plantilla de clase LockingPtr que inicializa con un objeto volátil obj y un mutex mtx. Durante su vida útil, LockingPtr mantiene mtx adquirido. Además, LockingPtr ofrece acceso al obj despojado de volátiles. El acceso se ofrece en forma de puntero inteligente, a través de operador-> y operador *. El const_cast se realiza dentro de LockingPtr. La conversión es semánticamente válida porque LockingPtr mantiene el mutex adquirido durante toda su vida.

Primero, definamos el esqueleto de una clase Mutex con la que LockingPtr funcionará:

class Mutex {
public:
    void Acquire();
    void Release();
    ...    
};

Para usar LockingPtr, implemente Mutex usando las estructuras de datos nativas y las funciones primitivas de su sistema operativo.

LockingPtr tiene una plantilla con el tipo de variable controlada. Por ejemplo, si quieres controlar un widget, utilizas un LockingPtr que inicializas con una variable de tipo Widget volátil.

La definición de LockingPtr es muy simple. LockingPtr implementa un puntero inteligente poco sofisticado. Se centra únicamente en recopilar un const_cast y una sección crítica.

template <typename T>
class LockingPtr {
public:
    // Constructors/destructors
    LockingPtr(volatile T& obj, Mutex& mtx)
      : pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {    
        mtx.Lock();    
    }
    ~LockingPtr() {    
        pMtx_->Unlock();    
    }
    // Pointer behavior
    T& operator*() {    
        return *pObj_;    
    }
    T* operator->() {   
        return pObj_;   
    }
private:
    T* pObj_;
    Mutex* pMtx_;
    LockingPtr(const LockingPtr&);
    LockingPtr& operator=(const LockingPtr&);
};

A pesar de su simplicidad, LockingPtr es una ayuda muy útil para escribir código multiproceso correcto. Debe definir los objetos que se comparten entre subprocesos como volátiles y nunca usar const_cast con ellos; siempre use objetos automáticos LockingPtr. Ilustremos esto con un ejemplo.

Supongamos que tiene dos subprocesos que comparten un objeto vectorial:

class SyncBuf {
public:
    void Thread1();
    void Thread2();
private:
    typedef vector<char> BufT;
    volatile BufT buffer_;
    Mutex mtx_; // controls access to buffer_
};

Dentro de una función de hilo, simplemente usa un LockingPtr para obtener acceso controlado a la variable de miembro buffer_:

void SyncBuf::Thread1() {
    LockingPtr<BufT> lpBuf(buffer_, mtx_);
    BufT::iterator i = lpBuf->begin();
    for (; i != lpBuf->end(); ++i) {
        ... use *i ...
    }
}

El código es muy fácil de escribir y comprender; siempre que necesite usar buffer_, debe crear un LockingPtr que apunte a él. Una vez que hagas eso, tendrás acceso a toda la interfaz de vector.

Lo bueno es que si comete un error, el compilador lo señalará:

void SyncBuf::Thread2() {
    // Error! Cannot access 'begin' for a volatile object
    BufT::iterator i = buffer_.begin();
    // Error! Cannot access 'end' for a volatile object
    for ( ; i != lpBuf->end(); ++i ) {
        ... use *i ...
    }
}

No puede acceder a ninguna función de buffer_ hasta que aplique un const_cast o use LockingPtr. La diferencia es que LockingPtr ofrece una forma ordenada de aplicar const_cast a variables volátiles.

LockingPtr es muy expresivo. Si solo necesita llamar a una función, puede crear un objeto LockingPtr temporal sin nombre y usarlo directamente:

unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}

Volver a Tipos primitivos

Vimos lo bien que protege los objetos volátiles contra el acceso incontrolado y cómo LockingPtr proporciona una forma simple y efectiva de escribir código seguro para subprocesos. Volvamos ahora a los tipos primitivos, que son tratados de manera diferente por volátiles.

Consideremos un ejemplo en el que varios subprocesos comparten una variable de tipo int.

class Counter {
public:
    ...
    void Increment() { ++ctr_; }
    void Decrement() { —ctr_; }
private:
    int ctr_;
};

Si se van a llamar Incremento y Decremento desde diferentes hilos, el fragmento de arriba tiene errores. Primero, ctr_ debe ser volátil. En segundo lugar, incluso una operación aparentemente atómica como ++ ctr_ es en realidad una operación de tres etapas. La memoria en sí misma no tiene capacidades aritméticas. Al incrementar una variable, el procesador:

  • Lee esa variable en un registro
  • Incrementa el valor en el registro
  • Escribe el resultado en la memoria.

Esta operación de tres pasos se denomina RMW (lectura-modificación-escritura). Durante la parte de modificación de una operación RMW, la mayoría de los procesadores liberan el bus de memoria para dar acceso a la memoria a otros procesadores.

Si en ese momento otro procesador realiza una operación RMW sobre la misma variable, tenemos una condición de carrera: la segunda escritura sobrescribe el efecto de la primera.

Para evitar eso, puede confiar, nuevamente, en LockingPtr:

class Counter {
public:
    ...
    void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
    void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
    volatile int ctr_;
    Mutex mtx_;
};

Ahora el código es correcto, pero su calidad es inferior en comparación con el código de SyncBuf. ¿Por qué? Porque con Counter, el compilador no le advertirá si accede por error a ctr_ directamente (sin bloquearlo). El compilador compila ++ ctr_ si ctr_ es volátil, aunque el código generado es simplemente incorrecto. El compilador ya no es tu aliado y solo tu atención puede ayudarte a evitar las condiciones de carrera.

¿Qué deberías hacer entonces? Simplemente encapsule los datos primitivos que usa en estructuras de nivel superior y use volátiles con esas estructuras. Paradójicamente, es peor usar volatile directamente con incorporados, a pesar del hecho de que inicialmente esta era la intención de uso de volatile.

Funciones de miembro volátiles

Hasta ahora, hemos tenido clases que agregan miembros de datos volátiles; ahora pensemos en diseñar clases que a su vez serán parte de objetos más grandes y compartidas entre subprocesos. Aquí es donde las funciones de miembro volátiles pueden ser de gran ayuda.

Al diseñar su clase, califica de forma volátil solo aquellas funciones miembro que son seguras para subprocesos. Debe asumir que el código externo llamará a las funciones volátiles desde cualquier código en cualquier momento. No olvide: volátil equivale a código multiproceso gratuito y sin sección crítica; no volátil equivale al escenario de un solo subproceso o dentro de una sección crítica.

Por ejemplo, usted define un widget de clase que implementa una operación en dos variantes: una segura para subprocesos y otra rápida y desprotegida.

class Widget {
public:
    void Operation() volatile;
    void Operation();
    ...
private:
    Mutex mtx_;
};

Observe el uso de la sobrecarga. Ahora, el usuario de Widget puede invocar Operation usando una sintaxis uniforme, ya sea para objetos volátiles y obtener seguridad de subprocesos, o para objetos regulares y obtener velocidad. El usuario debe tener cuidado al definir los objetos Widget compartidos como volátiles.

Cuando se implementa una función miembro volátil, la primera operación suele ser bloquearla con LockingPtr. Luego, el trabajo se realiza utilizando el hermano no volátil:

void Widget::Operation() volatile {
    LockingPtr<Widget> lpThis(*this, mtx_);
    lpThis->Operation(); // invokes the non-volatile function
}

Resumen

Al escribir programas multiproceso, puede utilizar volatile a su favor. Debes ceñirte a las siguientes reglas:

  • Defina todos los objetos compartidos como volátiles.
  • No use volatile directamente con tipos primitivos.
  • Al definir clases compartidas, utilice funciones miembro volátiles para expresar la seguridad de los subprocesos.

Si hace esto, y si usa el componente genérico simple LockingPtr, puede escribir código seguro para subprocesos y preocuparse mucho menos por las condiciones de carrera, porque el compilador se preocupará por usted y señalará diligentemente los puntos en los que está equivocado.

Un par de proyectos en los que he estado involucrado usan volatile y LockingPtr con gran efecto. El código es limpio y comprensible. Recuerdo un par de puntos muertos, pero prefiero los puntos muertos a las condiciones de carrera porque son mucho más fáciles de depurar. Prácticamente no hubo problemas relacionados con las condiciones de carrera. Pero entonces nunca se sabe.

Agradecimientos

Muchas gracias a James Kanze y Sorin Jianu que ayudaron con ideas perspicaces.


Andrei Alexandrescu es Gerente de Desarrollo en RealNetworks Inc. (www.realnetworks.com), con sede en Seattle, WA, y autor del aclamado libro Modern C ++ Design. Puede ser contactado en www.moderncppdesign.com. Andrei también es uno de los instructores destacados del Seminario C ++ (www.gotw.ca/cpp_seminar).

Este artículo puede estar un poco anticuado, pero da una buena idea de un uso excelente del uso del modificador volátil en el uso de la programación multiproceso para ayudar a mantener los eventos asíncronos mientras el compilador verifica las condiciones de carrera por nosotros. Es posible que esto no responda directamente a la pregunta original de los OP sobre la creación de una barrera de memoria, pero elijo publicar esto como una respuesta para otros como una excelente referencia para un buen uso de volátiles cuando se trabaja con aplicaciones multiproceso.

Francis Cugler
fuente
0

volatileBásicamente, la palabra clave significa que la lectura y escritura de un objeto debe realizarse exactamente como lo escribe el programa y no debe optimizarse de ninguna manera . El código binario debe seguir el código C o C ++: una carga donde se lee, una tienda donde hay una escritura.

También significa que no se debe esperar que ninguna lectura dé como resultado un valor predecible: el compilador no debe asumir nada sobre una lectura, incluso inmediatamente después de una escritura en el mismo objeto volátil:

volatile int i;
i = 1;
int j = i; 
if (j == 1) // not assumed to be true

volatilepuede ser la herramienta más importante en la caja de herramientas "C es un lenguaje ensamblador de alto nivel" .

Si declarar un objeto volátil es suficiente para garantizar el comportamiento del código que se ocupa de los cambios asincrónicos depende de la plataforma: diferentes CPU dan diferentes niveles de sincronización garantizada para lecturas y escrituras de memoria normales. Probablemente no debería intentar escribir código de subprocesos múltiples de tan bajo nivel a menos que sea un experto en el área.

Las primitivas atómicas proporcionan una buena vista de los objetos de alto nivel para el subproceso múltiple que facilita el razonamiento sobre el código. Casi todos los programadores deberían usar primitivas atómicas o primitivas que proporcionen exclusiones mutuas como mutex, bloqueos de lectura-escritura, semáforos u otras primitivas de bloqueo.

curioso
fuente