2008-09-16 24 views
250

¿Alguien puede proporcionar una buena explicación de la palabra clave volátil en C#? ¿Qué problemas resuelve y cuáles no? ¿En qué casos me ahorrará el uso de bloqueo?¿Cuándo se debe usar la palabra clave volátil en C#?

+5

¿Por qué quiere ahorrar en el uso del bloqueo? Los bloqueos no controlados agregan unos nanosegundos a su programa. ¿Realmente no puedes permitirte unos nanosegundos? –

Respuesta

231

No creo que haya una mejor persona para responder a esto que Eric Lippert (énfasis en el original):

En C#, "volátil" significa no sólo "asegúrese de que el compilador y el fluctuación no realice ningún reordenamiento de código ni registre las optimizaciones de almacenamiento en memoria caché en esta variable ". También significa "indicar a los procesadores hacer lo que sea que necesiten para asegurarse de que estoy leyendo el último valor , incluso si eso significa detener otros procesadores y hacer que sincronicen la memoria principal con sus cachés".

En realidad, ese último bit es una mentira. La verdadera semántica de las lecturas volátiles y las escrituras son considerablemente más complejas de lo que he resumido aquí; en hecho en realidad no garantizan que cada procesador detenga lo que está haciendo y actualiza el caché a/desde la memoria principal. Por el contrario, proporcionan garantías más débiles sobre cómo se accede a los accesos a la memoria antes y después de las lecturas y las escrituras se ordenan entre sí. Algunas operaciones, como crear un nuevo hilo, ingresar un candado, o usando una de las familias de métodos enclavados, brindan garantías más sólidas de sobre la observación del pedido. Si desea más detalles, lea las secciones 3.10 y 10.5.3 de la especificación C# 4.0.

Francamente, Te desanimo de hacer siempre un campo volátil. Los campos volátiles son una señal de que estás haciendo algo francamente loco: estás intentando leer y escribir el mismo valor en dos hilos diferentes sin poner un candado en su lugar. Los bloqueos garantizan que la lectura de la memoria o modificada dentro de la cerradura sea consistente, las cerraduras garantizan que solo un hilo accede a un trozo dado de memoria a la vez, y así encendido. El número de situaciones en las que un bloqueo es demasiado lento es muy pequeño, y la probabilidad de que obtenga el código incorrecto porque no comprende el modelo de memoria exacto es muy grande. I no intente escribir ningún código de bloqueo bajo a excepción de los usos más triviales de de operaciones de enclavamiento. Dejo el uso de "volátil" a expertos reales.

Para mayor información ver:

+19

Yo votaría esto si pudiera. Hay mucha información interesante allí, pero realmente no responde su pregunta. Él está preguntando sobre el uso de la palabra clave volátil en lo que se refiere al bloqueo. Durante bastante tiempo (antes de 2.0 RT), era necesario usar la palabra clave volátil para asegurar adecuadamente la seguridad de un hilo de campo estático si la instancia de campo tenía un código de inicialización en el constructor (consulte la respuesta de AndrewTek). Todavía hay un gran código de RT 1.1 en entornos de producción y los desarrolladores que lo mantienen deberían saber por qué esa palabra clave está ahí y si es seguro eliminarla. –

+3

@PaulEaster el hecho de que * puede * usarse para el bloqueo controlado por doulbe (generalmente en el patrón singleton) no significa que * debería *. Confiar en el modelo de memoria .NET es probablemente una mala práctica; en su lugar, debe confiar en el modelo ECMA. Por ejemplo, es posible que desee exportar a mono un día, que puede tener un modelo diferente. También tengo que entender que las diferentes arquitecturas de hardware pueden cambiar las cosas. Para obtener más información, consulte: http://stackoverflow.com/a/7230679/67824. Para mejores alternativas de singleton (para todas las versiones de .NET), consulte: http://csharpindepth.com/articles/general/singleton.aspx –

+0

No dije de una forma u otra si debería usarse. Si parte de su trabajo es mantener una aplicación heredada 1.1, no siempre puede aplicar mejores prácticas/modelos del tiempo de ejecución actual. De hecho, puede ser necesario mantener la palabra clave dentro del código si no se puede transferir a un tiempo de ejecución más nuevo. Además, debe confiar en el modelo de memoria que ejecutará en producción. Si su aplicación tiene problemas de memoria, decirle a su cliente que está escrita de esa manera en caso de que alguna vez se transmita a mono será lo último que le diga. No importará si tu código es académicamente correcto. –

13

Al CLR le gusta optimizar las instrucciones, por lo que cuando se accede a un campo en el código, puede que no siempre acceda al valor actual del campo (puede ser de la pila, etc.). Al marcar un campo como volatile se garantiza que la instrucción acceda al valor actual del campo. Esto es útil cuando el valor puede ser modificado (en un escenario sin bloqueo) por un hilo concurrente en su programa o algún otro código que se ejecute en el sistema operativo.

Obviamente, pierde un poco de optimización, pero mantiene el código más simple.

22

De MSDN: El modificador de volátiles se utiliza generalmente para un campo al que se accede mediante varios subprocesos sin utilizar la instrucción de bloqueo para serializar el acceso. El uso del modificador volátil garantiza que un hilo recupere el valor más actualizado escrito por otro hilo.

20

A veces, el compilador optimizará un campo y usará un registro para almacenarlo. Si el hilo 1 hace una escritura en el campo y otro hilo accede a él, dado que la actualización se almacenó en un registro (y no en la memoria), el segundo hilo obtendría datos obsoletos.

Puede pensar en la palabra clave volátil diciendo al compilador "Quiero que guarde este valor en la memoria". Esto garantiza que el segundo hilo recupera el último valor.

54

Si desea obtener un poco más técnica acerca de lo que la palabra clave volátiles hacen, considere el siguiente programa (estoy usando DevStudio 2005):

#include <iostream> 
void main() 
{ 
    int j = 0; 
    for (int i = 0 ; i < 100 ; ++i) 
    { 
    j += i; 
    } 
    for (volatile int i = 0 ; i < 100 ; ++i) 
    { 
    j += i; 
    } 
    std::cout << j; 
} 

Utilizando el estándar optimizado (liberación) configuración del compilador, las compilador crea la siguiente ensamblador (IA32):

void main() 
{ 
00401000 push  ecx 
    int j = 0; 
00401001 xor   ecx,ecx 
    for (int i = 0 ; i < 100 ; ++i) 
00401003 xor   eax,eax 
00401005 mov   edx,1 
0040100A lea   ebx,[ebx] 
    { 
    j += i; 
00401010 add   ecx,eax 
00401012 add   eax,edx 
00401014 cmp   eax,64h 
00401017 jl   main+10h (401010h) 
    } 
    for (volatile int i = 0 ; i < 100 ; ++i) 
00401019 mov   dword ptr [esp],0 
00401020 mov   eax,dword ptr [esp] 
00401023 cmp   eax,64h 
00401026 jge   main+3Eh (40103Eh) 
00401028 jmp   main+30h (401030h) 
0040102A lea   ebx,[ebx] 
    { 
    j += i; 
00401030 add   ecx,dword ptr [esp] 
00401033 add   dword ptr [esp],edx 
00401036 mov   eax,dword ptr [esp] 
00401039 cmp   eax,64h 
0040103C jl   main+30h (401030h) 
    } 
    std::cout << j; 
0040103E push  ecx 
0040103F mov   ecx,dword ptr [__imp_std::cout (40203Ch)] 
00401045 call  dword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (402038h)] 
} 
0040104B xor   eax,eax 
0040104D pop   ecx 
0040104E ret    

en cuanto a la salida, el compilador ha decidido utilizar el registro ECX para almacenar el valor de la variable j. Para el bucle no volátil (el primero) el compilador ha asignado i al registro eax. Bastante sencillo. Sin embargo, hay un par de bits interesantes: la instrucción lea ebx, [ebx] es efectivamente una instrucción multibyte nop para que el bucle salte a una dirección de memoria alineada con 16 bytes. El otro es el uso de edx para incrementar el contador de bucles en lugar de usar una instrucción inc eax. La instrucción add reg, reg tiene una latencia más baja en algunos núcleos IA32 en comparación con la instrucción inc reg, pero nunca tiene una latencia más alta.

Ahora para el ciclo con el contador de bucle volátil. El contador se almacena en [esp] y la palabra clave volátil le dice al compilador que el valor siempre debe leerse/escribirse en la memoria y nunca debe asignarse a un registro. El compilador incluso va tan lejos como para no cargar/incrementar/almacenar como tres pasos distintos (cargar eax, inc eax, save eax) al actualizar el valor del contador, en cambio la memoria se modifica directamente en una sola instrucción (un add mem , reg). La forma en que se ha creado el código garantiza que el valor del contador de bucles esté siempre actualizado dentro del contexto de un único núcleo de CPU. Ninguna operación en los datos puede provocar corrupción o pérdida de datos (por lo tanto, no se utiliza la carga/inc/almacenar ya que el valor puede cambiar durante la inc pérdida de la tienda). Como las interrupciones solo se pueden atender una vez que se ha completado la instrucción actual, los datos nunca se pueden dañar, incluso con la memoria no alineada.

Una vez que introduce una segunda CPU en el sistema, la palabra clave volátil no protegerá contra los datos que está siendo actualizado por otra CPU al mismo tiempo.En el ejemplo anterior, necesitaría que los datos no estén alineados para obtener una posible corrupción. La palabra clave volátil no evitará daños potenciales si los datos no se pueden manejar atómicamente, por ejemplo, si el contador de bucle era de tipo largo (64 bits), entonces se requerirían dos operaciones de 32 bits para actualizar el valor, en el medio de qué interrupción puede ocurrir y cambiar los datos.

Por lo tanto, la palabra clave volátil solo sirve para datos alineados que son menores o iguales que el tamaño de los registros nativos, de modo que las operaciones son siempre atómicas.

La palabra clave volátil se concibió para ser utilizada con operaciones IO donde el IO cambiaría constantemente pero tenía una dirección constante, como un dispositivo UART mapeado en memoria, y el compilador no debería seguir reutilizando el primer valor leído del dirección.

Si maneja datos grandes o tiene varias CPU, necesitará un sistema de bloqueo de nivel superior (OS) para manejar el acceso a los datos correctamente.

+0

Esto es C++, pero el principio se aplica a C#. – Skizz

+8

No exactamente: http://www.ddj.com/hpc-high-performance-computing/212701484 –

+4

Eric Lippert escribe que volátil en C++ solo evita que el compilador realice algunas optimizaciones, mientras que en C# volátil también se produce una cierta comunicación entre otros núcleos/procesadores para garantizar que se lea el último valor. –

-2

múltiples hilos pueden tener acceso a una variable. La última actualización estará en la variable

33

Si está utilizando .NET 1.1, se necesita la palabra clave volátil al hacer doble bloqueo comprobado. ¿Por qué? Porque antes de .NET 2.0, el siguiente escenario podría causar un segundo subproceso para acceder a un objeto no nulo, pero no totalmente construido:

  1. El subproceso 1 pregunta si una variable es nula. //if(this.foo == null)
  2. El subproceso 1 determina que la variable es nula, por lo que ingresa un bloqueo. //lock(this.bar)
  3. El hilo 1 pregunta OTRA VEZ si la variable es nula. //if(this.foo == null)
  4. El subproceso 1 todavía determina que la variable es nula, por lo que llama a un constructor y asigna el valor a la variable. //this.foo = new Foo();

Antes de .NET 2.0, a este .foo se le podía asignar la nueva instancia de Foo, antes de que el constructor terminara de ejecutarse. En este caso, podría entrar un segundo hilo (durante la llamada del hilo 1 al constructor de Foo) y experimentar lo siguiente:

  1. El hilo 2 pregunta si la variable es nula. //if(this.foo == null)
  2. El subproceso 2 determina que la variable NO es nula, por lo que intenta usarla. //this.foo.MakeFoo()

Antes de .NET 2.0, se podría declarar this.foo por ser volátil para solucionar este problema. Desde .NET 2.0, ya no es necesario utilizar la palabra clave volátil para lograr el doble bloqueo verificado.

Wikipedia en realidad tiene un buen artículo sobre el bloqueo doble cuadros, y brevemente toca este tema: http://en.wikipedia.org/wiki/Double-checked_locking

+1

esto es exactamente lo que veo en un código heredado y me preguntaba al respecto. es por eso que comencé una investigación más profunda. ¡Gracias! –

1

El compilador veces cambia el orden de las sentencias de código para optimizarlo. Normalmente, esto no es un problema en un entorno de subproceso único, pero podría ser un problema en un entorno de subprocesos múltiples. Ver siguiente ejemplo:

private static int _flag = 0; 
private static int _value = 0; 

var t1 = Task.Run(() => 
{ 
    _value = 10; /* compiler could switch these lines */ 
    _flag = 5; 
}); 

var t2 = Task.Run(() => 
{ 
    if (_flag == 5) 
    { 
     Console.WriteLine("Value: {0}", _value); 
    } 
}); 

Si ejecuta t1 y t2, que se puede esperar ninguna salida o "Valor: 10" como el resultado. Podría ser que el compilador cambie de línea dentro de la función t1. Si t2 se ejecuta entonces, podría ser que _flag tenga el valor de 5, pero _value tiene 0. Así que la lógica esperada podría romperse.

Para solucionar este problema, puede usar palabra clave volátil que puede aplicar al campo. Esta instrucción deshabilita las optimizaciones del compilador para que pueda forzar el orden correcto en su código.

private static volatile int _flag = 0; 

Debe utilizar volátil sólo si realmente lo necesita, ya que deshabilita ciertas optimizaciones del compilador, que perjudicará el rendimiento. Tampoco es compatible con todos los lenguajes .NET (Visual Basic no lo admite), por lo que dificulta la interoperabilidad del lenguaje.

+1

Tu ejemplo es realmente malo. El programador nunca debe tener ninguna expectativa sobre el valor de _flag en la tarea t2 basándose en el hecho de que el código de t1 se escribe primero. ¡Primero escrito! = Ejecutado primero. No importa si el compilador SES cambia esas dos líneas en t1. Incluso si el compilador no cambió esas declaraciones, su Console.WriteLne en la rama else aún puede ejecutarse, incluso CON la palabra clave volátil en _flag. – Jakotheshadows

+0

@jakotheshadows, tienes razón, he editado mi respuesta. Mi idea principal era mostrar que la lógica esperada podría romperse cuando ejecutamos t1 y t2 simultáneamente –

Cuestiones relacionadas