Este código se bloquea en el modo de lanzamiento pero funciona bien en el modo de depuración


110

Me encontré con esto y quiero saber el motivo de este comportamiento en el modo de depuración y liberación.

public static void Main(string[] args)
{            
   bool isComplete = false;

   var t = new Thread(() =>
   {
       int i = 0;

        while (!isComplete) i += 0;
   });

   t.Start();

   Thread.Sleep(500);
   isComplete = true;
   t.Join();
   Console.WriteLine("complete!");
}

25
¿Cuál es exactamente la diferencia en el comportamiento?
Mong Zhu

4
Si fuera java, asumiría que el compilador no ve actualizaciones en la variable 'compilar'. Agregar 'volátil' a la declaración de variable solucionaría eso (y convertirlo en un campo estático).
Sebastian

23

4
Nota: esta es la razón por la que el desarrollo multiproceso tiene cosas como mutex y operaciones atómicas. Cuando comience a utilizar multiproceso, debe considerar un conjunto notable de problemas de memoria adicionales que antes no eran obvios. Las herramientas de sincronización de subprocesos como los mutexs habrían resuelto esto.
Cort Ammon

5
@DavidSchwartz: Ciertamente está permitido . Y está permitido que el compilador, el tiempo de ejecución y la CPU hagan que el resultado de ese código sea diferente al esperado. En particular, C # no puede hacer que el acceso bool no sea atómico , pero está permitido mover las lecturas no volátiles hacia atrás en el tiempo. Los dobles, por el contrario, no tienen tal restricción de atomicidad; se permite la rotura de una lectura doble y escrita en dos hilos diferentes sin sincronización.
Eric Lippert

Respuestas:


149

Supongo que el optimizador es engañado por la falta de palabra clave 'volátil' en la isCompletevariable.

Por supuesto, no puede agregarlo, porque es una variable local. Y, por supuesto, dado que es una variable local, no debería ser necesaria en absoluto, porque los locales se mantienen en la pila y, naturalmente, siempre están "frescos".

Sin embargo , después de compilar, ya no es una variable local . Dado que se accede a él en un delegado anónimo, el código se divide y se traduce en una clase auxiliar y un campo de miembro, algo como:

public static void Main(string[] args)
{
    TheHelper hlp = new TheHelper();

    var t = new Thread(hlp.Body);

    t.Start();

    Thread.Sleep(500);
    hlp.isComplete = true;
    t.Join();
    Console.WriteLine("complete!");
}

private class TheHelper
{
    public bool isComplete = false;

    public void Body()
    {
        int i = 0;

        while (!isComplete) i += 0;
    }
}

Puedo imaginar ahora que el compilador / optimizador JIT en un entorno multiproceso, al procesar la TheHelperclase, puede almacenar en caché el valor falseen algún registro o marco de pila al comienzo del Body()método, y nunca actualizarlo hasta que finalice el método. Esto se debe a que NO HAY GARANTÍA de que el hilo y el método NO terminen antes de que se ejecute "= true", así que si no hay garantía, ¿por qué no almacenarlo en caché y obtener el aumento de rendimiento de leer el objeto del montón una vez en lugar de leerlo cada vez? iteración.

Esta es exactamente la razón por la que volatileexiste la palabra clave .

Para que esta clase auxiliar sea correcta un poquito mejor 1) en entornos de subprocesos múltiples, debería tener:

    public volatile bool isComplete = false;

pero, por supuesto, dado que es un código generado automáticamente, no puede agregarlo. Un mejor enfoque sería agregar algunos lock()mensajes de correo electrónico alrededor de lecturas y escrituras isCompleted, o usar alguna otra sincronización lista para usar o utilidades de subprocesamiento / tareas en lugar de intentar hacerlo de forma completa (que no será completa , ya que es C # en CLR con GC, JIT y (..)).

La diferencia en el modo de depuración se produce probablemente porque en el modo de depuración se excluyen muchas optimizaciones, por lo que puede, bueno, depurar el código que ve en la pantalla. Por while (!isComplete)lo tanto, no está optimizado para que pueda establecer un punto de interrupción allí y, por isCompletelo tanto, no se almacena en caché de manera agresiva en un registro o pila al inicio del método y se lee del objeto en el montón en cada iteración del ciclo.

Por cierto. Eso es solo mis conjeturas sobre eso. Ni siquiera intenté compilarlo.

Por cierto. No parece ser un error; es más como un efecto secundario muy oscuro. Además, si estoy en lo cierto, entonces puede ser una deficiencia de lenguaje: C # debería permitir colocar una palabra clave 'volátil' en las variables locales que se capturan y promueven a los campos de miembros en los cierres.

1) vea a continuación los comentarios de Eric Lippert sobre volatiley / o este artículo muy interesante que muestra los niveles de complejidad involucrados en garantizar que el código en el que se basa volatilesea seguro ... uh, bien ... uh, digamos que está bien.


2
@EricLippert: whoa, ¡muchas gracias por confirmar eso tan rápido! ¿Cómo crees que existe alguna posibilidad de que en alguna versión futura podamos obtener una volatileopción sobre variables locales de captura a cierre? Me imagino que puede ser un poco difícil de procesar por el compilador ..
quetzalcoatl

7
@quetzalcoatl: No contaría con que esa función se agregue pronto. Este es el tipo de codificación que desearía desalentar y no facilitar . Y además, hacer que las cosas sean volátiles no necesariamente resuelve todos los problemas. A continuación se muestra un ejemplo en el que todo es volátil y el programa sigue siendo incorrecto; ¿puedes encontrar el error? blog.coverity.com/2014/03/26/reordering-optimizations
Eric Lippert

3
Entendido. Dejo de intentar comprender las optimizaciones de subprocesos múltiples ... es una locura lo complicado que es.
Entre el

10
@Pikoh: Nuevamente, piense como un optimizador. Tiene una variable que se incrementa pero nunca se lee. Una variable que nunca se lee se puede eliminar por completo.
Eric Lippert

4
@EricLippert ahora mi mente hizo un clic. Este hilo ha sido muy informativo, muchas gracias, de verdad.
Pikoh

82

La respuesta de quetzalcoatl es correcta. Para arrojar más luz sobre esto:

El compilador de C # y la fluctuación de CLR pueden realizar muchas optimizaciones que asumen que el hilo actual es el único hilo en ejecución. Si esas optimizaciones hacen que el programa sea incorrecto en un mundo donde el hilo actual no es el único hilo en ejecución, ese es su problema . Es necesario que escriba programas multiproceso que le digan al compilador y alteren qué cosas locas de multiproceso estás haciendo.

En este caso particular, se permite el jitter, pero no se requiere, para observar que la variable no ha cambiado por el cuerpo del bucle y, por lo tanto, concluir que, dado que por supuesto este es el único hilo en ejecución, la variable nunca cambiará. Si nunca cambia, entonces la variable debe ser verificada una vez , no cada vez a través del ciclo. Y esto es de hecho lo que está sucediendo.

¿Cómo solucionar esto? No escriba programas multiproceso . El multiproceso es increíblemente difícil de hacer bien, incluso para los expertos. Si es necesario, utilice los mecanismos de más alto nivel para lograr su objetivo . La solución aquí es no convertir la variable en volátil. La solución aquí es escribir una tarea cancelable y usar el mecanismo de cancelación de Task Parallel Library . Deje que el TPL se preocupe por hacer que la lógica de subprocesamiento sea correcta y la cancelación se envíe correctamente a través de subprocesos.


1
Los comentarios no son para una discusión extensa; esta conversación se ha movido al chat .
Madara's Ghost

14

Me adjunté al proceso en ejecución y descubrí (si no cometí errores, no tengo mucha práctica con esto) que el Threadmétodo se traduce a esto:

debug051:02DE04EB loc_2DE04EB:                            
debug051:02DE04EB test    eax, eax
debug051:02DE04ED jz      short loc_2DE04EB
debug051:02DE04EF pop     ebp
debug051:02DE04F0 retn

eax(que contiene el valor de isComplete) se carga por primera vez y nunca se actualiza.


8

No es realmente una respuesta, pero para arrojar algo más de luz sobre el tema:

El problema parece ser cuando ise declara el interior del cuerpo lambda y está solamente lee en la expresión de asignación. De lo contrario, el código funciona bien en modo de lanzamiento:

  1. i declarado fuera del cuerpo lambda:

    int i = 0; // Declared outside the lambda body
    
    var t = new Thread(() =>
    {
        while (!isComplete) { i += 0; }
    }); // Completes in release mode
  2. i no se lee en la expresión de asignación:

    var t = new Thread(() =>
    {
        int i = 0;
        while (!isComplete) { i = 0; }
    }); // Completes in release mode
  3. i también se lee en otro lugar:

    var t = new Thread(() =>
    {
        int i = 0;
        while (!isComplete) { Console.WriteLine(i); i += 0; }
    }); // Completes in release mode

Mi apuesta es que algún compilador u optimización JIT iesté arruinando las cosas. Alguien más inteligente que yo probablemente podrá arrojar más luz sobre el tema.

No obstante, no me preocuparía demasiado por eso, porque no veo dónde un código similar realmente serviría para algún propósito.


1
vea mi respuesta, estoy bastante seguro de que se trata de una palabra clave 'volátil' que no se puede agregar a una variable local (que en realidad se promueve más tarde a un campo miembro en el cierre) ..
quetzalcoatl
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.