La necesidad de un modificador volátil en el locking controlado doble en .NET

Múltiples textos dicen que al implementar el locking con doble verificación 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 statement de “locking” tanto la lectura como la escritura serían volátiles y, por lo tanto, se lograría la consistencia necesaria?

Volátil es innecesario. Especie de**

volatile se usa para crear una barrera de memoria * entre lecturas y escrituras en la variable.
lock , cuando se usa, crea barreras de memoria que se crean 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 en caché en algún registro) y que el comstackdor no reordena las declaraciones. Usar volatile es necesario ** porque ya tienes un locking.

Joseph Albahari explica esto mucho mejor que yo.

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

actualización :
* Las lecturas de causas volatile de la variable son VolatileRead y las escrituras son VolatileWrite s, que en x86 y x64 en CLR, se implementan con un MemoryBarrier . Pueden ser más finos en otros sistemas.

** mi respuesta solo es correcta si está utilizando CLR en procesadores x86 y x64. Puede ser cierto en otros modelos de memoria, como en Mono (y otras implementaciones), Itanium64 y hardware futuro. Esto es a lo que se está refiriendo Jon en su artículo en los “errores” para el doble locking comprobado.

Hacer uno de {marcar la variable como volatile , leerlo con Thread.VolatileRead o insertar una llamada a Thread.MemoryBarrier } puede ser necesario para que el código funcione correctamente en una situación de modelo de memoria débil.

Por lo que entiendo, en el CLR (incluso en IA64), las escrituras nunca se reordenan (las escrituras siempre tienen semántica de publicación). Sin embargo, en IA64, las lecturas pueden reordenarse antes de las escrituras, a menos que estén marcadas como volátiles. Desgraciadamente, no tengo acceso al hardware IA64 para jugar, así que todo lo que diga al respecto sería especulación.

También encontré útiles estos artículos:
http://www.codeproject.com/KB/tips/MemoryBarrier.aspx
el artículo de vance morrison (todo lo vincula a esto, se refiere al locking doblemente verificado)
el artículo de Chris Brumme (todo lo vincula a esto)
Joe Duffy: variantes rotas de locking controlado doble

La serie de Luis abreu sobre multihebra ofrece también una buena visió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

Hay una manera de implementarlo sin campo volatile . Lo explicaré …

Creo que es el reordenamiento de acceso a la memoria dentro de la cerradura lo que es peligroso, de modo que puede obtener una instancia no inicializada completamente 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; } } } 

Comprender el código

Imagine 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 tiene una instancia incompleta … imagine que la clase tiene este código:

 private int _value; public int Value { get { return this._value; } } private Singleton() { this._value = 1; } 

Ahora imagine una llamada al constructor utilizando el nuevo operador:

 instance = new Singleton(); 

Esto se puede expandir a estas operaciones:

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

¿Qué sucede si reordena estas instrucciones de esta manera?

 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 piensas 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 de 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 encoding!

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

El volátil y el primero if (instance == null) no son “necesarios”. El locking hará que este código sea seguro para subprocesos.

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

Se presume que la razón es para evitar ejecutar innecesariamente la sección de código bloqueada. Mientras está ejecutando el código dentro del locking, cualquier otro hilo que intente también ejecutar ese código está bloqueado, lo que ralentizará su progtwig si intenta acceder al singleton con frecuencia desde muchos subprocesos. Dependiendo del idioma / plataforma, también podría haber gastos indirectos del propio candado que desea evitar.

Por lo tanto, se agrega la primera verificación nula como una manera muy rápida de ver si necesita el locking. Si no necesita crear el singleton, puede evitar el locking 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 cambiarlo y leería un valor “obsoleto” que lo llevaría a ingresar el locking innecesariamente. ¡Pero estás tratando de evitar un locking!

De modo que convierte el singleton en volátil para asegurarse de leer el valor más reciente, sin necesidad de usar un locking.

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

Ahora, ¿esto es realmente útil?

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

Si Singleton.Instance puede causar ineficiencia debido a las cerraduras, ¿por qué lo llamas con tanta frecuencia que esto sería un problema importante ? El objective de un singleton es que solo haya 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 que este almacenamiento en caché no sería posible sería cuando tienes una gran cantidad de subprocesos (por ejemplo, un servidor que utiliza un nuevo hilo para procesar cada solicitud podría crear millones de subprocesos de ejecución muy corta, cada uno que debería llamar a Singleton.Instance una vez).

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

AFAIK (y – toma esto con precaución, no estoy haciendo muchas cosas al mismo tiempo) no. El locking solo te da sincronización entre múltiples contendientes (hilos).

Por otro lado, volátil le dice a su máquina que reevalúe el valor cada vez, para que no tropiece con un valor en caché (y erróneo).

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

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

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

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

La mayoría de las personas apuntan a 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 palabra de advertencia: solo estoy adivinando el modelo de memoria x86 del comportamiento observado en procesadores existentes. Por lo tanto, las técnicas de locking bajo también son frágiles porque el hardware y los comstackdores 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 bajo locking. (…) Finalmente, asum el modelo de memoria más débil posible, usando declaraciones volátiles en lugar de confiar en garantías implícitas “

Si necesita algo más convincente, lea este artículo sobre las especificaciones de ECMA que se usarán para otras plataformas: msdn.microsoft.com/en-us/magazine/jj863136.aspx

Si necesita una explicación más convincente, 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 ser volátil por el momento, pero no se arriesgue a que escriba el código correcto y use los métodos de lectura / escritura volátiles o volátiles. Los artículos que sugieren hacer lo contrario a veces dejan de lado algunos de los posibles riesgos de optimizaciones de JIT / comstackdor 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 menciona supuestos en el último artículo, las suposiciones previas de trabajar sin volátil ya no pueden sostenerse en ARM.

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

Un mejor enfoque es sincronizar el acceso a datos estáticos al bloquear 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) { ... } } } 

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

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

Esta es una muy buena publicación sobre el uso de volátiles con doble locking comprobado:

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

En Java, si el objective es proteger una variable, no es necesario bloquearla si está marcada como volátil.