Un ejemplo reproducible de uso volátil

Estoy buscando un ejemplo reproducible que pueda demostrar cómo funciona la palabra clave volátil. Estoy buscando algo que funciona “mal” sin variables marcadas como volátiles y funciona “correctamente” con él.

Me refiero a algún ejemplo que demuestre que el orden de las operaciones de escritura / lectura durante la ejecución es diferente del esperado cuando la variable no está marcada como volátil y no es diferente cuando la variable no está marcada como volátil.

Pensé que tenía un ejemplo, pero luego, con la ayuda de otros, me di cuenta de que solo era un código incorrecto de subprocesos múltiples. ¿Por qué volátiles y MemoryBarrier no impiden el reordenamiento de operaciones?

También encontré un enlace que demuestra un efecto de volátil en el optimizador, pero es diferente de lo que estoy buscando. Demuestra que las solicitudes a la variable marcada como volátil no se optimizarán. Cómo ilustrar el uso de palabras clave volátiles en C #

Aquí es donde llegué tan lejos. Este código no muestra ningún signo de reordenamiento de operación de lectura / escritura. Estoy buscando uno que se muestre.

using System; using System.Threading; using System.Threading.Tasks; using System.Runtime.CompilerServices; namespace FlipFlop { class Program { //Declaring these variables static byte a; static byte b; //Track a number of iteration that it took to detect operation reordering. static long iterations = 0; static object locker = new object(); //Indicates that operation reordering is not found yet. static volatile bool continueTrying = true; //Indicates that Check method should continue. static volatile bool continueChecking = true; static void Main(string[] args) { //Restarting test until able to catch reordering. while (continueTrying) { iterations++; a = 0; b = 0; var checker = new Task(Check); var writter = new Task(Write); lock (locker) { continueChecking = true; checker.Start(); } writter.Start(); checker.Wait(); writter.Wait(); } Console.ReadKey(); } static void Write() { //Writing is locked until Main will start Check() method. lock (locker) { WriteInOneDirection(); WriteInOtherDirection(); //Stops spinning in the Check method. continueChecking = false; } } [MethodImpl(MethodImplOptions.NoInlining)] static void WriteInOneDirection(){ a = 1; b = 10; } [MethodImpl(MethodImplOptions.NoInlining)] static void WriteInOtherDirection() { b = 20; a = 2; } static void Check() { //Spins until finds operation reordering or stopped by Write method. while (continueChecking) { int tempA = a; int tempB = b; if (tempB == 10 && tempA == 2) { continueTrying = false; Console.WriteLine("Caught when a = {0} and b = {1}", tempA, tempB); Console.WriteLine("In " + iterations + " iterations."); break; } } } } } 

Editar:

Como entiendo, una optimización que causa el reordenamiento puede venir de JITer o del hardware mismo. Puedo reformular mi pregunta. ¿Las CPU JITer o x86 reordenan las operaciones de lectura / escritura Y existe una forma de demostrarlo en C # si lo hacen?

La semántica exacta de volátil es un detalle de implementación de jitter. El comstackdor emite la instrucción Opcodes.Volatile IL siempre que acceda a una variable declarada volátil. Realiza algunas comprobaciones para verificar que el tipo de variable sea legal, no puede declarar tipos de valor de más de 4 bytes volátiles, pero ahí es donde se detiene el dinero.

La especificación de lenguaje C # define el comportamiento de volátil , citado aquí por Eric Lippert. La semántica ‘release’ y ‘acquire’ es algo que solo tiene sentido en un núcleo de procesador con un modelo de memoria débil. Esos tipos de procesadores no lo han hecho bien en el mercado, probablemente porque son un dolor tan enorme de progtwigr. Las probabilidades de que su código se ejecute alguna vez en un Titanium son prácticamente nulas.

Lo que es especialmente malo acerca de la definición de la especificación del lenguaje C # es que no menciona en absoluto lo que realmente sucede. Declarar una variable volátil evita que el optimizador de fluctuación de fase optimice el código para almacenar la variable en un registro de CPU. Por eso el código que marcó Marc está colgando. Esto solo ocurrirá con el jitter x86 actual, otra pista fuerte de que el volátil es realmente un detalle de implementación de jitter.

La semántica pobre de volátil tiene una rica historia, proviene del lenguaje C. Cuyos generadores de código tienen muchos problemas para hacerlo bien también. Aquí hay un informe interesante sobre él (pdf) . Data de 2008, más de 30 años de oportunidades para hacerlo bien. O mal, esto se arruina cuando el optimizador de código se olvida de que una variable es volátil. El código no optimizado nunca tiene un problema con él. Notable es que el jitter en la versión ‘open source’ de .NET (SSLI20) ignora por completo la instrucción IL. También se puede argumentar que el comportamiento actual del jitter x86 es un error. Creo que es así, no es fácil meterlo en el modo de falla. Pero nadie puede argumentar que en realidad es un error.

La escritura está en la pared, solo declare una variable volátil si está almacenada en un registro mapeado en la memoria. La intención original de la palabra clave. Las probabilidades de que te encuentres con ese uso en el lenguaje C # deberían ser infinitamente pequeñas, un código como ese pertenece a un controlador de dispositivo. Y, sobre todo, nunca suponga que es útil en un escenario de subprocesos múltiples.

Puede usar este ejemplo para demostrar el comportamiento diferente con y sin volatile . Este ejemplo debe comstackrse utilizando una versión de lanzamiento y ejecutarse fuera del depurador 1 . Experimente agregando y eliminando la palabra clave volatile al indicador de stop .

Lo que ocurre aquí es que la lectura de stop en el ciclo while se reordena para que ocurra antes del ciclo si se omite volatile . Esto evita que el hilo termine incluso después de que el hilo principal establezca el indicador de stop en true .

 class Program { static bool stop = false; public static void Main(string[] args) { var t = new Thread(() => { Console.WriteLine("thread begin"); bool toggle = false; while (!stop) { toggle = !toggle; } Console.WriteLine("thread end"); }); t.Start(); Thread.Sleep(1000); stop = true; Console.WriteLine("stop = true"); Console.WriteLine("waiting..."); // The Join call should return almost immediately. // With volatile it DOES. // Without volatile it does NOT. t.Join(); } } 

También se debe tener en cuenta que los cambios sutiles en este ejemplo pueden reducir su probabilidad de reproducibilidad. Por ejemplo, agregar Thread.Sleep (quizás para simular el entrelazado de hilos) introducirá una barrera de memoria y por lo tanto la semántica similar de la palabra clave volatile . Sospecho que Console.WriteLine introduce barreras de memoria implícitas o evita que la inestabilidad use la operación de reordenamiento de instrucciones. Solo tenlo en cuenta si comienzas a jugar demasiado con el ejemplo.


1 Creo que la versión de framework anterior a 2.0 no incluye esta optimización de reordenamiento. Eso significa que debe poder reproducir este comportamiento con la versión 2.0 y superior, pero no con las versiones anteriores.