La necesidad de un modificador volátil en el bloqueo doble verificado en .NET

85

Varios textos dicen que al implementar el bloqueo doble verificado en .NET, el campo que está bloqueando debe tener aplicado un modificador volátil. ¿Pero por qué exactamente? Considerando el siguiente ejemplo:

public sealed class Singleton
{
   private static volatile Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         if (instance == null) 
         {
            lock (syncRoot) 
            {
               if (instance == null) 
                  instance = new Singleton();
            }
         }

         return instance;
      }
   }
}

¿Por qué "lock (syncRoot)" no logra la consistencia de memoria necesaria? ¿No es cierto que después de la instrucción "lock" tanto la lectura como la escritura serían volátiles y, por lo tanto, se lograría la consistencia necesaria?

Konstantin
fuente
2
Esto ya ha sido masticado muchas veces. yoda.arachsys.com/csharp/singleton.html
Hans Passant
1
Desafortunadamente, en ese artículo, Jon hace referencia a 'volátil' dos veces, y ninguna referencia se refiere directamente a los ejemplos de código que dio.
Dan Esparza
Consulte este artículo para comprender la preocupación: igoro.com/archive/volatile-keyword-in-c-memory-model-explained Básicamente, es TEÓRICO posible que el JIT use un registro de CPU para la variable de instancia, especialmente si necesita una pequeño código extra allí. Por lo tanto, hacer una instrucción if dos veces podría devolver el mismo valor independientemente de que cambie en otro hilo. En realidad, la respuesta es un poco compleja, la declaración de bloqueo puede o no ser responsable de mejorar las cosas aquí (continuación)
user2685937
(ver la continuación del comentario anterior) - Esto es lo que creo que realmente está sucediendo - Básicamente, cualquier código que hace algo más complejo que leer o establecer una variable puede hacer que el JIT diga, olvídese de intentar optimizar esto, carguemos y guardemos en la memoria porque si se llama una función, el JIT potencialmente necesitaría guardar y volver a cargar el registro si lo almacena allí cada vez, en lugar de hacerlo, solo escribe y lee directamente desde la memoria cada vez. ¿Cómo sé que el bloqueo no es nada especial? Mire el enlace que publiqué en el comentario anterior de Igor (continúa el siguiente comentario)
user2685937
(ver los 2 comentarios anteriores) - Probé el código de Igor y cuando crea un nuevo hilo agregué un candado a su alrededor e incluso lo hice en bucle. Aún así, no haría que el código saliera porque la variable de instancia se sacó del bucle. Al agregar al ciclo while, un conjunto de variables locales simples aún sacaba la variable del ciclo: ahora, algo más complicado como declaraciones if o una llamada a un método o sí, incluso una llamada de bloqueo, evitaría la optimización y, por lo tanto, la haría funcionar. Por lo tanto, cualquier código complejo a menudo fuerza el acceso directo a las variables en lugar de permitir que el JIT se optimice. (continúa el siguiente comentario)
user2685937

Respuestas:

59

Volátil es innecesario. Especie de**

volatilese utiliza para crear una barrera de memoria * entre lecturas y escrituras en la variable.
lock, cuando se usa, hace que se creen barreras de memoria alrededor del bloque dentro del lock, además de limitar el acceso al bloque a un hilo.
Las barreras de memoria hacen que cada hilo lea el valor más actual de la variable (no un valor local almacenado en caché en algún registro) y que el compilador no reordene las declaraciones. Usar volatilees innecesario ** porque ya tienes un candado.

Joseph Albahari explica estas cosas mucho mejor que yo.

Y asegúrese de consultar la guía de Jon Skeet para implementar el singleton en C #


update :
* volatilehace que las lecturas de la variable sean VolatileReadsy las escrituras sean VolatileWrites, que en x86 y x64 en CLR, se implementan con un MemoryBarrier. Pueden tener un grano más fino en otros sistemas.

** mi respuesta solo es correcta si está utilizando CLR en procesadores x86 y x64. Se podría ser cierto en otros modelos de memoria, al igual que en Mono (y otras implementaciones), Itanium64 y hardware en el futuro. Esto es a lo que Jon se refiere en su artículo sobre las "trampas" para el bloqueo doble verificado.

Puede ser necesario {marcar la variable como volatile, leerla Thread.VolatileReado insertar una llamada a Thread.MemoryBarrier} para que el código funcione correctamente en una situación de modelo de memoria débil.

Por lo que entiendo, en CLR (incluso en IA64), las escrituras nunca se reordenan (las escrituras siempre tienen semántica de liberación). Sin embargo, en IA64, las lecturas se pueden reordenar para que sean anteriores a las escrituras, a menos que estén marcadas como volátiles. Desafortunadamente, no tengo acceso al hardware IA64 para jugar, así que cualquier cosa que diga al respecto sería una especulación.

También he encontrado útiles estos artículos:
http://www.codeproject.com/KB/tips/MemoryBarrier.aspx artículo de
vance morrison (todo enlaza con esto, habla de bloqueo doble verificado)
artículo de chris brumme (todo enlaza con esto )
Joe Duffy: Variantes rotas de bloqueo con doble verificación

La serie de luis abreu sobre subprocesos múltiples también ofrece una buena descripción general de los conceptos
http://msmvps.com/blogs/luisabreu/archive/2009/06/29/multithreading-load-and-store-reordering.aspx
http: // msmvps. com / blogs / luisabreu / archive / 2009/07/03 / multithreading-introducing-memory-fences.aspx

dan
fuente
Jon Skeet en realidad dice que se necesita un modificador volátil para crear una barrera de memoria adecuada, mientras que el primer autor del enlace dice que el bloqueo (Monitor.Enter) sería suficiente. ¿Quién tiene razón?
Konstantin
@Konstantin parece que Jon se estaba refiriendo al modelo de memoria en los procesadores Itanium 64, por lo que en ese caso, puede ser necesario usar volátiles. Sin embargo, los volátiles no son necesarios en los procesadores x86 y x64. Actualizaré más en un momento.
Dan
Si el bloqueo de hecho está creando una barrera de memoria y si la barrera de memoria se trata de tanto el orden de instrucciones como la invalidación de caché, entonces debería funcionar en todos los procesadores. De todos modos, es tan extraño que algo tan básico cause tanta confusión ...
Konstantin
2
Esta respuesta me parece incorrecta. Si volatileno era necesaria en cualquier plataforma entonces significaría el JIT no podía optimizar las cargas de memoria object s1 = syncRoot; object s2 = syncRoot;a object s1 = syncRoot; object s2 = s1;en esa plataforma. Eso me parece muy poco probable.
user541686
1
Incluso si el CLR no reordenaría las escrituras (dudo que eso sea cierto, se pueden hacer muchas optimizaciones muy buenas al hacerlo), aún tendría errores siempre que podamos en línea la llamada al constructor y crear el objeto en el lugar (pudimos ver un objeto medio inicializado). Independientemente de cualquier modelo de memoria que utilice la CPU subyacente. Según Eric Lippert, el CLR en Intel al menos introduce un membarrier después de los constructores que niega esa optimización, pero eso no es requerido por la especificación y no contaría con que suceda lo mismo en ARM, por ejemplo ..
Voo
34

Hay una forma de implementarlo sin volatilecampo. Te lo explicare ...

Creo que es el reordenamiento del acceso a la memoria dentro de la cerradura lo que es peligroso, de modo que puede obtener una instancia no completamente inicializada fuera de la cerradura. Para evitar esto, hago esto:

public sealed class Singleton
{
   private static Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         // very fast test, without implicit memory barriers or locks
         if (instance == null)
         {
            lock (syncRoot)
            {
               if (instance == null)
               {
                    var temp = new Singleton();

                    // ensures that the instance is well initialized,
                    // and only then, it assigns the static variable.
                    System.Threading.Thread.MemoryBarrier();
                    instance = temp;
               }
            }
         }

         return instance;
      }
   }
}

Entendiendo el código

Imagina que hay algún código de inicialización dentro del constructor de la clase Singleton. Si estas instrucciones se reordenan después de establecer el campo con la dirección del nuevo objeto, entonces tienes una instancia incompleta ... imagina que la clase tiene este código:

private int _value;
public int Value { get { return this._value; } }

private Singleton()
{
    this._value = 1;
}

Ahora imagina una llamada al constructor usando el nuevo operador:

instance = new Singleton();

Esto se puede ampliar a estas operaciones:

ptr = allocate memory for Singleton;
set ptr._value to 1;
set Singleton.instance to ptr;

¿Qué pasa si reordeno estas instrucciones como esta?

ptr = allocate memory for Singleton;
set Singleton.instance to ptr;
set ptr._value to 1;

¿Hace alguna diferencia? NO si piensas en un solo hilo. SÍ, si piensa en varios hilos ... ¿qué pasa si el hilo se interrumpe justo después de set instance to ptr:

ptr = allocate memory for Singleton;
set Singleton.instance to ptr;
-- thread interruped here, this can happen inside a lock --
set ptr._value to 1; -- Singleton.instance is not completelly initialized

Eso es lo que evita la barrera de la memoria, al no permitir el reordenamiento del acceso a la memoria:

ptr = allocate memory for Singleton;
set temp to ptr; // temp is a local variable (that is important)
set ptr._value to 1;
-- memory barrier... cannot reorder writes after this point, or reads before it --
-- Singleton.instance is still null --
set Singleton.instance to temp;

¡Feliz codificación!

Miguel Angelo
fuente
1
Si CLR permite el acceso a un objeto antes de que se inicialice, es un agujero de seguridad. Imagine una clase privilegiada cuyo único constructor público establece "SecureMode = 1" y luego cuyos métodos de instancia comprueban eso. Si puede llamar a esos métodos de instancia sin que se ejecute el constructor, puede salirse del modelo de seguridad y violar el sandboxing.
MichaelGG
1
@MichaelGG: en el caso que describiste, si esa clase admite varios subprocesos para acceder a ella, entonces es un problema. Si la llamada al constructor está alineada por el jitter, entonces es posible que la CPU reordene las instrucciones de manera que la referencia almacenada apunte a una instancia no completamente inicializada. Este no es un problema de seguridad de CLR porque es evitable, es responsabilidad del programador utilizar: enclavados, barreras de memoria, bloqueos y / o campos volátiles, dentro del constructor de dicha clase.
Miguel Angelo
2
Una barrera dentro del ctor no lo arregla. Si CLR asigna la referencia al objeto recién asignado antes de que el ctor se haya completado y no inserta un membarrier, entonces otro hilo podría ejecutar un método de instancia en un objeto semiinicializado.
MichaelGG
Este es el "patrón alternativo" que sugiere ReSharper 2016/2017 en caso de DCL en C #. Otoh, Java hace garantía de que el resultado de newestá totalmente inicializado ..
user2864740
Sé que la implementación de MS .net coloca una barrera de memoria al final del constructor ... pero es mejor prevenir que curar.
Miguel Angelo
7

No creo que nadie haya respondido realmente a la pregunta , así que lo intentaré.

Los volátiles y los primeros if (instance == null)no son "necesarios". El bloqueo hará que este código sea seguro para subprocesos.

Entonces la pregunta es: ¿por qué agregarías el primero if (instance == null)?

La razón es presumiblemente para evitar ejecutar la sección bloqueada de código innecesariamente. Mientras está ejecutando el código dentro del bloqueo, cualquier otro hilo que intente ejecutar también ese código se bloquea, lo que ralentizará su programa si intenta acceder al singleton con frecuencia desde muchos hilos. Dependiendo del idioma / plataforma, también puede haber gastos generales de la cerradura que desea evitar.

Entonces, la primera verificación nula se agrega como una forma realmente rápida de ver si necesita el bloqueo. Si no necesita crear el singleton, puede evitar el bloqueo por completo.

Pero no puede verificar si la referencia es nula sin bloquearla de alguna manera, porque debido al almacenamiento en caché del procesador, otro hilo podría cambiarla y leería un valor "obsoleto" que lo llevaría a ingresar el bloqueo innecesariamente. ¡Pero estás intentando evitar un candado!

Por lo tanto, hace que el singleton sea volátil para asegurarse de leer el valor más reciente, sin necesidad de usar un candado.

Aún necesita el candado interno porque volátil solo lo protege durante un acceso único a la variable; no puede probarlo y configurarlo de manera segura sin usar un candado.

Ahora bien, ¿es esto realmente útil?

Bueno, yo diría "en la mayoría de los casos, no".

Si Singleton.Instance podría causar ineficiencia debido a las cerraduras, entonces ¿por qué lo llama con tanta frecuencia que esto sería un problema importante ? El objetivo de un singleton es que solo hay uno, por lo que su código puede leer y almacenar en caché la referencia singleton una vez.

El único caso en el que puedo pensar en el que este almacenamiento en caché no sería posible sería cuando tiene una gran cantidad de subprocesos (por ejemplo, un servidor que usa un nuevo subproceso para procesar cada solicitud podría estar creando millones de subprocesos de ejecución muy corta, cada uno de los cuales que tendría que llamar a Singleton.Instance una vez).

Así que sospecho que el bloqueo con doble verificación es un mecanismo que tiene un lugar real en casos muy específicos de rendimiento crítico, y luego todos se han subido al tren de "esta es la forma correcta de hacerlo" sin pensar realmente qué hace y si será realmente necesario en el caso de que lo estén usando.

Jason Williams
fuente
6
Esto está en algún lugar entre equivocado y perder el punto. volatileno tiene nada que ver con la semántica de bloqueo en el bloqueo doblemente verificado, tiene que ver con el modelo de memoria y la coherencia de la caché. Su propósito es garantizar que un subproceso no reciba un valor que todavía esté siendo inicializado por otro subproceso, que el patrón de bloqueo de doble verificación no previene de forma inherente. En Java definitivamente necesitas la volatilepalabra clave; en .NET es turbio, porque está mal según ECMA pero correcto según el tiempo de ejecución. De cualquier manera, lockdefinitivamente no se ocupa de eso.
Aaronaught
¿Eh? No veo dónde su declaración está en desacuerdo con lo que he dicho, ni he dicho que lo volátil esté relacionado de alguna manera con la semántica de bloqueo.
Jason Williams
6
Su respuesta, como varias otras declaraciones en este hilo, afirma que lockhace que el código sea seguro para subprocesos. Esa parte es cierta, pero el patrón de bloqueo de doble verificación puede hacerlo inseguro . Eso es lo que parece que te estás perdiendo. Esta respuesta parece divagar sobre el significado y el propósito de un bloqueo de doble verificación sin abordar los problemas de seguridad de los subprocesos que son la razón volatile.
Aaronaught
1
¿Cómo puede hacer inseguro si instanceestá marcado con volatile?
UserControl
5

Debe usar volátil con el patrón de bloqueo de doble verificación.

La mayoría de las personas señalan este artículo como prueba de que no necesita volátiles: https://msdn.microsoft.com/en-us/magazine/cc163715.aspx#S10

Pero no logran leer hasta el final: " Una última advertencia: solo estoy adivinando el modelo de memoria x86 a partir del comportamiento observado en los procesadores existentes. Por lo tanto, las técnicas de bloqueo bajo también son frágiles porque el hardware y los compiladores pueden volverse más agresivos con el tiempo . Aquí hay algunas estrategias para minimizar el impacto de esta fragilidad en su código. Primero, siempre que sea posible, evite las técnicas de bloqueo bajo. (...) Finalmente, asuma el modelo de memoria más débil posible, utilizando declaraciones volátiles en lugar de confiar en garantías implícitas . "

Si necesita más convencimiento, lea este artículo sobre la especificación ECMA que se utilizará para otras plataformas: msdn.microsoft.com/en-us/magazine/jj863136.aspx

Si necesita más convencimiento, lea este artículo más reciente de que se pueden implementar optimizaciones que eviten que funcione sin volátiles: msdn.microsoft.com/en-us/magazine/jj883956.aspx

En resumen, "podría" funcionar para usted sin volatile por el momento, pero no se arriesgue a que escriba el código adecuado y utilice los métodos volatile o volatileread / write. Los artículos que sugieren hacer lo contrario a veces omiten algunos de los posibles riesgos de las optimizaciones del compilador / JIT que podrían afectar su código, así como las optimizaciones futuras que pueden ocurrir y que podrían romper su código. Además, como se mencionó en las suposiciones del artículo anterior, las suposiciones anteriores de trabajar sin volátiles ya pueden no ser válidas para ARM.

usuario2685937
fuente
1
Buena respuesta. La única respuesta correcta a esta pregunta es un simple "No". Según esto, la respuesta aceptada es incorrecta.
Dennis Kassel
3

AFAIK (y - tómese esto con precaución, no estoy haciendo muchas cosas concurrentes) no. El bloqueo solo le brinda sincronización entre múltiples contendientes (hilos).

volatile por otro lado le dice a su máquina que reevalúe el valor cada vez, para que no tropiece con un valor en caché (y equivocado).

Consulte http://msdn.microsoft.com/en-us/library/ms998558.aspx y observe la siguiente cita:

Además, la variable se declara volátil para garantizar que la asignación a la variable de instancia se complete antes de que se pueda acceder a la variable de instancia.

Una descripción de volatile: http://msdn.microsoft.com/en-us/library/x13ttww7%28VS.71%29.aspx

Benjamin Podszun
fuente
2
Un 'candado' también proporciona una barrera de memoria, igual (o mejor que) volátil.
Henk Holterman
2

Creo que encontré lo que buscaba. Los detalles se encuentran en este artículo: http://msdn.microsoft.com/en-us/magazine/cc163715.aspx#S10 .

En resumen, el modificador volátil .NET no es necesario en esta situación. Sin embargo, en modelos de memoria más débiles, las escrituras realizadas en el constructor de un objeto iniciado de forma diferida pueden retrasarse después de escribir en el campo, por lo que otros subprocesos pueden leer una instancia corrupta no nula en la primera instrucción if.

Konstantin
fuente
1
Al final de ese artículo, lea atentamente, especialmente la última oración que dice el autor: "Una última advertencia: solo estoy adivinando el modelo de memoria x86 a partir del comportamiento observado en los procesadores existentes. Por lo tanto, las técnicas de bloqueo bajo también son frágiles porque El hardware y los compiladores pueden volverse más agresivos con el tiempo. Aquí hay algunas estrategias para minimizar el impacto de esta fragilidad en su código. Primero, siempre que sea posible, evite las técnicas de bloqueo bajo. (...) Finalmente, asuma el modelo de memoria más débil posible, utilizando declaraciones volátiles en lugar de depender de garantías implícitas ".
user2685937
1
Si necesita más convencimiento, lea este artículo sobre la especificación de ECMA que se utilizará para otras plataformas: msdn.microsoft.com/en-us/magazine/jj863136.aspx Si necesita más convencimiento, lea este artículo más reciente que puede incluir optimizaciones que evitan que funcione sin volátiles: msdn.microsoft.com/en-us/magazine/jj883956.aspx En resumen, "podría" funcionar para usted sin volátiles por el momento, pero no se arriesgue a que escriba el código adecuado y utilice volatile o los métodos volatileread / write.
user2685937
1

El lockes suficiente. La especificación de lenguaje de MS (3.0) en sí misma menciona este escenario exacto en §8.12, sin ninguna mención de volatile:

Un mejor enfoque es sincronizar el acceso a datos estáticos bloqueando un objeto estático privado. Por ejemplo:

class Cache
{
    private static object synchronizationObject = new object();
    public static void Add(object x) {
        lock (Cache.synchronizationObject) {
          ...
        }
    }
    public static void Remove(object x) {
        lock (Cache.synchronizationObject) {
          ...
        }
    }
}
Marc Gravell
fuente
Jon Skeet en su artículo ( yoda.arachsys.com/csharp/singleton.html ) dice que se necesita volátil para una barrera de memoria adecuada en este caso. Marc, ¿puedes comentar sobre esto?
Konstantin
Ah, no había notado la cerradura revisada dos veces; simplemente: no hagas eso ;-p
Marc Gravell
De hecho, creo que el bloqueo comprobado es algo bueno en cuanto al rendimiento. Además, si es necesario hacer que un campo sea volátil, mientras se accede a él dentro del bloqueo, el bloqueo de doble verificación no es mucho peor que cualquier otro bloqueo ...
Konstantin
Pero, ¿es tan bueno como el enfoque de clases separadas que menciona Jon?
Marc Gravell
-3

Esta es una publicación bastante buena sobre el uso de volátiles con bloqueo de doble verificación:

http://tech.puredanger.com/2007/06/15/double-checked-locking/

En Java, si el objetivo es proteger una variable, no es necesario bloquear si está marcada como volátil

Mark Pope
fuente
3
Interesante, pero no necesariamente muy útil. El modelo de memoria de la JVM y el modelo de memoria de CLR no son lo mismo.
bcat