¿Cuándo debe usarse la palabra clave volátil en C #?

299

¿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?

Doron Yaacoby
fuente
66
¿Por qué quieres ahorrar en el uso del bloqueo? Los bloqueos no contenidos agregan unos pocos nanosegundos a su programa. ¿Realmente no puede permitirse unos pocos nanosegundos?
Eric Lippert

Respuestas:

273

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

En C #, "volátil" significa no solo "asegurarse de que el compilador y la fluctuación de fase no realicen ningún reordenamiento de código ni registran optimizaciones de almacenamiento en caché en esta variable". También significa "decirle a los procesadores que hagan lo que sea necesario 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 y escrituras volátiles es considerablemente más compleja de lo que he esbozado aquí; de hecho , no garantizan que cada procesador detenga lo que está haciendo y actualice las memorias caché a / desde la memoria principal. Por el contrario, ofrecen garantías más débiles sobre cómo se puede observar que los accesos a la memoria antes y después de las lecturas y escrituras se ordenan entre sí . Ciertas operaciones, como crear un nuevo subproceso, ingresar un bloqueo o utilizar uno de los métodos de la familia Interlocked, ofrecen garantías más sólidas sobre la observación de los pedidos. Si desea más detalles, lea las secciones 3.10 y 10.5.3 de la especificación C # 4.0.

Francamente, te desaliento de que nunca hagas 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 memoria leída o modificada dentro del bloqueo se observa como consistente, los bloqueos garantizan que solo un hilo acceda a un trozo de memoria dado a la vez, y así sucesivamente. 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 exacto de memoria es muy grande. No intento escribir ningún código de bloqueo bajo, excepto los usos más triviales de las operaciones entrelazadas. Dejo el uso de "volátil" a verdaderos expertos.

Para más información ver:

Ohad Schneider
fuente
29
Votaría en contra de esto si pudiera. Hay mucha información interesante allí, pero en realidad no responde a su pregunta. Él está preguntando sobre el uso de la palabra clave volátil en relación con el bloqueo. Durante bastante tiempo (antes de 2.0 RT), la palabra clave volátil era necesaria para usar para que un subproceso de campo estático fuera seguro si la instancia de campo tuviera algún código de inicialización en el constructor (consulte la respuesta de AndrewTek). Todavía hay mucho código 1.1 RT en entornos de producción y los desarrolladores que lo mantienen deben saber por qué esa palabra clave está allí y si es segura eliminarla.
Paul Easter
3
@PaulEaster el hecho de que se pueda usar para el bloqueo controlado por doulbe (generalmente en el patrón singleton) no significa que deba hacerlo . 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 portar a mono un día, que puede tener un modelo diferente. También debo entender que diferentes arquitecturas de hardware podrían cambiar las cosas. Para obtener más información, consulte: stackoverflow.com/a/7230679/67824 . Para obtener mejores alternativas de singleton (para todas las versiones .NET) consulte: csharpindepth.com/articles/general/singleton.aspx
Ohad Schneider
66
En otras palabras, la respuesta correcta a la pregunta es: si su código se está ejecutando en el tiempo de ejecución 2.0 o posterior, la palabra clave volátil casi nunca es necesaria y hace más daño que bien si se usa innecesariamente. Pero en versiones anteriores del tiempo de ejecución, ES necesario para un correcto bloqueo de doble verificación en los campos estáticos.
Paul Easter
3
¿Significa esto que los bloqueos y las variables volátiles son mutuamente excluyentes en el siguiente sentido: si he usado bloqueos alrededor de alguna variable, ya no hay necesidad de declarar esa variable como volátil?
giorgim
44
@Giorgi sí - las barreras de memoria garantizadas volatileestarán allí en virtud del bloqueo
Ohad Schneider
54

Si desea obtener un poco más técnico sobre lo que hace la palabra clave volátil, 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;
}

Usando la configuración estándar optimizada (versión) del compilador, el compilador crea el 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              

Mirando la salida, el compilador ha decidido usar 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 de 16 bytes. El otro es el uso de edx para incrementar el contador de bucle 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 bucle 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 asignarse a un registro. El compilador incluso llega a no hacer una carga / incremento / almacenamiento como tres pasos distintos (cargar eax, inc eax, guardar eax) al actualizar el valor del contador, en cambio, la memoria se modifica directamente en una sola instrucción (una memoria adicional , reg). La forma en que se creó el código garantiza que el valor del contador de bucle esté siempre actualizado dentro del contexto de un solo núcleo de CPU. Ninguna operación en los datos puede resultar en corrupción o pérdida de datos (por lo tanto, no usar la carga / inc / store ya que el valor puede cambiar durante el inc, perdiéndose así en la tienda). Dado que las interrupciones solo pueden ser atendidas una vez que la instrucción actual se ha completado,

Una vez que introduce una segunda CPU en el sistema, la palabra clave volátil no protegerá contra la actualización de datos 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á la corrupción potencial si los datos no pueden manejarse atómicamente, por ejemplo, si el contador de bucle era de tipo largo largo (64 bits), requeriría dos operaciones de 32 bits para actualizar el valor, en el medio de que puede ocurrir una interrupción y cambiar los datos.

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

La palabra clave volátil se concibió para usarse con operaciones de E / S en las que la E / S 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 de la dirección.

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

Skizz
fuente
Esto es C ++ pero el principio se aplica a C #.
Skizz
66
Eric Lippert escribe que volátil en C ++ solo evita que el compilador realice algunas optimizaciones, mientras que en C # volátil adicionalmente hace alguna comunicación entre los otros núcleos / procesadores para garantizar que se lea el último valor.
Peter Huber
44

Si está utilizando .NET 1.1, la palabra clave volátil es necesaria cuando se realiza un bloqueo de doble verificación. ¿Por qué? Debido a que antes de .NET 2.0, el siguiente escenario podría causar que un segundo subproceso acceda a un objeto no nulo, pero no completamente construido:

  1. El hilo 1 pregunta si una variable es nula. //if(this.foo == nulo)
  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 == nulo)
  4. El subproceso 1 aún 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, this.foo podía tener asignada 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 == nulo)
  2. El subproceso 2 determina que la variable NO es nula, por lo que intenta usarla. //this.foo.MakeFoo ()

Antes de .NET 2.0, podría declarar this.foo como volátil para solucionar este problema. Desde .NET 2.0, ya no necesita usar la palabra clave volátil para lograr un bloqueo doblemente verificado.

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

AndrewTek
fuente
2
Esto es exactamente lo que veo en un código heredado y me preguntaba al respecto. Por eso comencé una investigación más profunda. ¡Gracias!
Peter Porfy
1
No entiendo cómo asignaría valor el hilo 2 foo? ¿No se bloquea el subproceso 1 this.bary, por lo tanto, solo el subproceso 1 podrá inicializar foo en un momento dado? Quiero decir, usted verifica el valor después de que se libera el bloqueo nuevamente, cuando de todos modos debería tener el nuevo valor del hilo 1.
Gilmishal
24

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

Puede pensar en la palabra clave volátil como decirle al compilador "Quiero que almacene este valor en la memoria". Esto garantiza que el segundo hilo recupere el último valor.

Benoit
fuente
21

Desde MSDN : el modificador volátil se usa generalmente para un campo al que acceden varios subprocesos sin usar 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.

Dr. Bob
fuente
13

Al CLR le gusta optimizar las instrucciones, por lo que cuando accede a un campo en el código, es posible que no siempre acceda al valor actual del campo (puede ser de la pila, etc.). Marcar un campo como volatileasegura 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 algo de optimización, pero mantiene el código más simple.

Joseph Daigle
fuente
3

¡Este artículo de Joydip Kanjilal me pareció muy útil!

When you mark an object or a variable as volatile, it becomes a candidate for volatile reads and writes. It should be noted that in C# all memory writes are volatile irrespective of whether you are writing data to a volatile or a non-volatile object. However, the ambiguity happens when you are reading data. When you are reading data that is non-volatile, the executing thread may or may not always get the latest value. If the object is volatile, the thread always gets the most up-to-date value

Solo lo dejaré aquí como referencia

Christina Katsakoula
fuente
0

El compilador a veces cambia el orden de las declaraciones en el 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 subproceso múltiple. Ver el 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, no esperaría ningún resultado o "Valor: 10" como resultado. Puede ser que el compilador cambie de línea dentro de la función t1. Si t2 se ejecuta, podría ser que _flag tiene un valor de 5, pero _value tiene 0. Por lo tanto, la lógica esperada podría romperse.

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

private static volatile int _flag = 0;

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

Aliaksei Maniuk
fuente
2
Tu ejemplo es realmente malo. El programador nunca debe tener ninguna expectativa sobre el valor de _flag en la tarea t2 debido al hecho de que el código de t1 se escribe primero. Escrito primero! = Ejecutado primero. No importa si el compilador 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
@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
Aliaksei Maniuk
0

Para resumir todo esto, la respuesta correcta a la pregunta es: si su código se ejecuta en el tiempo de ejecución 2.0 o posterior, la palabra clave volátil casi nunca es necesaria y hace más daño que bien si se usa innecesariamente. IE Nunca lo uses. PERO en versiones anteriores del tiempo de ejecución, se necesita para el correcto bloqueo de doble verificación en campos estáticos. Específicamente campos estáticos cuya clase tiene código de inicialización de clase estática.

Paul Easter
fuente
-4

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

usuario2943601
fuente