Compartir a través de


C#

Teoría y práctica del modelo de memoria de C#, segunda parte

Igor Ostrovsky

 

Este es el segundo artículo de una serie de dos que analiza el modelo de memoria de C#. Tal y como se explica en la primera parte del número de diciembre de la revista MSDN Magazine (msdn.microsoft.com/magazine/jj863136), el compilador y el hardware pueden transformar sutilmente las operaciones de memoria de manera que no afecten el comportamiento uniproceso, pero que puedan afectar el comportamiento multiproceso. A modo de ejemplo, considere este método:

void Init() {
  _data = 42;
  _initialized = true;
}

Si _data e _initialized son campos corrientes (es decir, no son volátiles), entonces el compilador y el procesador tienen permiso para reordenar las operaciones de tal modo que Init se ejecute como si se hubiera escrito como:

void Init() {
  _initialized = true;
  _data = 42;
}

En el artículo anterior, describí el modelo de memoria de C# abstracto. En este artículo explicaré cómo se implementa realmente el modelo de memoria de C# en las diferentes arquitecturas que se admiten en Microsoft .NET Framework 4.5.

Optimizaciones del compilador

Como se menciona en el primer artículo, el compilador podría optimizar el código de una manera que reordene las operaciones de memoria. En .NET Framework 4.5, el compilador csc.exe que compila C# a IL no realiza muchas optimizaciones, por lo que no reordenará las operaciones de memoria. Sin embargo, el compilador just-in-time (JIT) que convierte IL a código máquina realizará de hecho algunos cambios que reordenan las operaciones de memoria, como veremos.

Elevación de lectura del bucle Considere el patrón de bucle de sondeo:

class Test
{
  private bool _flag = true;
  public void Run()
  {
    // Set _flag to false on another thread
    new Thread(() => { _flag = false; }).Start();
    // Poll the _flag field until it is set to false
    while (_flag) ;
    // The loop might never terminate!
  }
}

En este caso, el compilador JIT de .NET 4.5 podría reescribir el bucle de la siguiente manera:

if (_flag) { while (true); }

En el caso de un único subproceso, esta transformación es totalmente legal y en general la elevación de una lectura de bucle es una excelente optimización. Sin embargo, si _flag se establece como falso en otro subproceso, la optimización puede causar un bloqueo.

Tenga en cuenta que si el campo _flag fuese volátil, el compilador JIT no elevaría la lectura del bucle. (Consulte la sección "Bucle de sondeo" en el artículo de diciembre para obtener una explicación más detallada de este patrón).

Eliminación de lectura Otra optimización del compilador que puede causar errores en el código de multiproceso se ilustra en el siguiente ejemplo:

class Test
{
  private int _A, _B;
  public void Foo()
  {
    int a = _A;
    int b = _B;
    ...
  }
}

La clase contiene dos campos no volátiles, _A y _B. El método Foo lee primero el campo _A y luego el _B. Sin embargo, ya que los campos no son volátiles, el compilador es libre de reordenar ambas lecturas. Entonces, si la exactitud del algoritmo depende del orden de las lecturas, el programa contiene un error.

Es difícil imaginar lo que el compilador obtendría al cambiar el orden de las lecturas. Dada la forma en que Foo está escrito, el compilador probablemente no cambiaría el orden de las lecturas.

Sin embargo, la reordenación sí ocurre si agrego otra instrucción inocua en la parte superior del método Foo:

public bool Foo()
{
  if (_B == -1) throw new Exception(); // Extra read
  int a = _A;
  int b = _B;
  return a > b;
}

En la primera línea del método Foo, el compilador carga el valor de _B en un registro. Luego, la segunda carga de _B usa solo el valor que ya está en el registro en lugar de emitir una instrucción de carga real.

De forma eficaz, el compilador reescribe el método Foo de la siguiente manera:

public bool Foo()
{
  int b = _B;
  if (b == -1) throw new Exception(); // Extra read
  int a = _A;
  return a > b;
}

Aunque este ejemplo de código entrega una aproximación general de cómo el compilador optimiza el código, también es instructivo observar el desensamblado del código:

if (_B == -1) throw new Exception();
  push        eax
  mov         edx,dword ptr [ecx+8]
  // Load field _B into EDX register
  cmp         edx,0FFFFFFFFh
  je          00000016
int a = _A;
  mov         eax,dword ptr [ecx+4]
  // Load field _A into EAX register
return a > b;
  cmp         eax,edx
  // Compare registers EAX and EDX
...

Incluso si no se conoce el ensamblado, lo que aquí ocurre es muy fácil de entender. Como parte de la evaluación de la condición _B == -1, el compilador carga el campo _B en el registro EDX. Luego, cuando se vuelve a leer el campo _B, el compilador simplemente vuelve a usar el valor que ya tiene en EDX en vez de emitir una lectura de memoria real. Por lo tanto, se reordenan las lecturas de _A y _B.

En este caso, la solución correcta es marcar el campo _A como volátil. Si esto se hace, el compilador no debiese reordenar las lecturas de _A y _B, ya que la carga de _A tiene semántica de adquirir carga. Sin embargo, debo señalar que .NET Framework en la versión 4 no administra este caso de forma correcta y, de hecho, marcar el campo _A como volátil no evitará la reordenación de la lectura. Este problema se corrigió en .NET Framework versión 4.5.

Introducción de lectura Como acabo de explicar, el compilador algunas veces fusiona varias lecturas en una.sola. El compilador también puede separar una sola lectura en varias. En .NET Framework 4.5, la introducción de lectura es mucho menos común que la eliminación de lectura y ocurre solamente en circunstancias poco comunes y específicas. Sin embargo, ocurre algunas veces.

Para entender la introducción de lectura, considere el siguiente ejemplo:

public class ReadIntro {
  private Object _obj = new Object();
  void PrintObj() {
    Object obj = _obj;
    if (obj != null) {
      Console.WriteLine(obj.ToString());
    // May throw a NullReferenceException
    }
  }
  void Uninitialize() {
    _obj = null;
  }
}

Si examina el método PrintObj, pareciera que el valor obj nunca será nulo en la expresión obj.ToString. Sin embargo, esa línea de código de hecho podría arrojar una NullReferenceException. El CLR JIT podría compilar el método PrintObj como si se hubiese escrito de la siguiente forma:

void PrintObj() {
  if (_obj != null) {
    Console.WriteLine(_obj.ToString());
  }
}

Debido a que la lectura del campo _obj se ha separado en dos lecturas del campo, el método ToString ahora puede llamarse destino nulo.

Tenga en cuenta que no podrá reproducir la NullReferenceException usando este ejemplo de código en .NET Framework 4.5 en x86-x64. La introducción de lectura es muy difícil de reproducir en .NET Framework 4.5, pero a pesar de todo en circunstancias especiales ocurre.

La implementación del modelo de memoria de C# en x86-x64

Como x86 and x64 tienen el mismo comportamiento con respecto al modelo de memoria, consideraré ambas variantes del procesador.

A diferencia de algunas arquitecturas, el procesador x86-x64 ofrece garantías de orden bastante fuertes en las operaciones de memoria. De hecho, el compilador JIT no necesita usar ninguna instrucción especial en x86-x64 para lograr una semántica volátil, las operaciones comunes de memoria ya proporcionan esta semántica. Incluso, todavía hay casos específicos en que el procesador x86-x64 reordena las operaciones de memoria.

Reordenación de memoria x86-x64 Aunque el procesador x86-x64 ofrece garantías de orden bastante fuertes, un tipo especial de reordenación de hardware todavía ocurre.

El procesador x86-x64 no reordenará las dos escrituras, ni las dos lecturas. Sin embargo, el único (y solo este) posible efecto de reordenación es que cuando un procesador escribe un valor, ese valor no estará disponible inmediatamente en otros procesadores. En la Figura 1 se da un ejemplo que demuestra este comportamiento.

Figura 1 StoreBufferExample

class StoreBufferExample
{
  // On x86 .NET Framework 4.5, it makes no difference
  // whether these fields are volatile or not
  volatile int A = 0;
  volatile int B = 0;
  volatile bool A_Won = false;
  volatile bool B_Won = false;
  public void ThreadA()
  {
    A = true;
    if (!B) A_Won = true;
  }
  public void ThreadB()
  {
    B = true;
    if (!A) B_Won = true;
  }
}

Considere el caso cuando los métodos ThreadA y ThreadB se llaman desde diferentes subprocesos a una nueva instancia de StoreBufferExample, como se muestra en la Figura 2. Si piensa en los posibles resultados del programa en la Figura 2, tres casos parecieran ser posibles:

  1. El subproceso 1 se completa antes de que el subproceso 2 comience. El resultado es A_Won=true, B_Won=false.
  2. El subproceso 2 se completa antes de que el subproceso 1 comience. El resultado es A_Won=false, B_Won=true.
  3. Los subprocesos se intercalan. El resultado es A_Won=false, B_Won=false.

Calling ThreadA and ThreadB Methods from Different Threads
En la figura 2 los métodos ThreadA y ThreadB se llaman desde diferentes subprocesos

Pero, sorprendentemente, existe un cuarto caso: ¡Es posible que ambos campos A_Won y B_Won sean verdaderos (true) una vez finalizado dicho código! Debido al búfer de almacenamiento, los almacenamientos pueden quedar "retrasados" y por lo tanto terminar reordenados en una carga posterior. A pesar de que este resultado no es coherente con ninguna intercalación de las ejecuciones del subproceso 1 y del subproceso 2, todavía puede suceder.

Este ejemplo es interesante ya que tenemos un procesador (el x86-x64) con un orden relativamente fuerte y todos los campos son volátiles y aún observamos un reordenamiento de las operaciones de memoria. A pesar de que la escritura en A es volátil y la lectura de A_Won también lo es, ambas vallas son unidireccionales, y de hecho permiten este reordenamiento. Por lo tanto, el método ThreadA puede ejecutarse de manera eficaz como si se hubiese escrito de la siguiente forma:

public void ThreadA()
{
  bool tmp = B;
  A = true;
  if (!tmp) A_Won = 1;
}

Una posible solución es insertar una barrera de memoria en ThreadA y en ThreadB. El método actualizado ThreadA tendría el siguiente aspecto:

public void ThreadA()
{
  A = true;
  Thread.MemoryBarrier();
  if (!B) aWon = 1;
}

El JIT de CLR insertará una instrucción "lock or" en vez de una barrera de memoria. Una instrucción x86 bloqueada tiene el efecto secundario de vaciar el búfer de almacenamiento:

mov         byte ptr [ecx+4],1
lock or     dword ptr [esp],0
cmp         byte ptr [ecx+5],0
jne         00000013
mov         byte ptr [ecx+6],1
ret

Como nota interesante, el lenguaje de programación Java tiene un enfoque diferente. El modelo de memoria Java tiene una definición un poco más fuerte de "volátil" que no permite el reordenamiento de almacenamiento de carga, por lo que un compilador de Java en x86 emitirá normalmente una instrucción bloqueada después de una escritura volátil.

Observaciones x86-x64: el procesador x86 tiene un modelo de memoria bastante fuerte y el origen del reordenamiento a nivel de hardware es el búfer de almacenamiento. El búfer de almacenamiento puede provocar una escritura para reordenarse con una lectura posterior (reordenamiento de almacenamiento de carga).

Además, ciertas optimizaciones del compilador pueden arrojar como resultado un reordenamiento de las operaciones de memoria. Cabe destacar que, si varias lecturas obtienen acceso a la misma ubicación de memoria, el compilador puede optar por ejecutar la lectura solo una vez y mantener el valor en un registro para lecturas posteriores.

Una anécdota interesante es que la semántica volátil de C# coincide en buena medida con las garantías del reordenamiento de hardware hechas por el hardware x86-x64. Como resultado, las lecturas y las escrituras de campos volátiles no requieren instrucciones especiales en x86: Las lecturas y escrituras normales (por ejemplo, usar la instrucción MOV) son suficientes. Por supuesto, su código no debe depender de estos detalles de implementación ya que varían entre arquitecturas de hardware y posiblemente en versiones .NET.

Implementación del modelo de memoria de C# en Arquitectura Itanium

La arquitectura de hardware Itanium tiene un modelo de memoria más frágil que el de x86-x64. Itanium era compatible con .NET Framework hasta la versión 4.

A pesar de que Itanium ya no es compatible con .NET Framework 4.5, entender el modelo de memoria de Itanium es útil cuando lee artículos más antiguos del modelo de memoria .NET y debe mantener código que incorporó recomendaciones de esos artículos.

Reordenación de Itanium Itanium tiene un conjunto de instrucciones distinto de x86-x64 y los conceptos de modelo de memoria aparecen en el conjunto de instrucciones. Itanium distingue entre una carga normal (LD) y una de adquirir carga (LD.ACQ), un almacenamiento normal (ST) y un almacenamiento de liberación (ST.REL).

El hardware puede ordenar libremente las cargas normales y los almacenamientos, siempre y cuando el comportamiento uniproceso no cambie. Por ejemplo, observe este código:

class ReorderingExample
{
  int _a = 0, _b = 0;
  void PrintAB()
  {
    int a = _a;
    int b = _b;
    Console.WriteLine("A:{0} B:{1}", a, b);
  }
  ...
}

Considere dos lecturas de _a y _b en el método PrintAB. Debido a que las lecturas obtienen acceso a un campo normal y no volátil, el compilador usará LD normal (no LD.ACQ) para implementar las lecturas. Por consiguiente, las dos lecturas podrían reordenarse de forma eficiente en el hardware, de modo que PrintAB se comporta como si se hubiese escrito de la siguiente forma:

void PrintAB()
{
  int b = _b;
  int a = _a;
  Console.WriteLine("A:{0} B:{1}", a, b);
}

En la práctica, si la reordenación ocurre o no depende de una variedad de factores impredecibles: lo que hay en la memoria caché del procesador, lo ocupada que está la canalización del procesador, etc. Sin embargo, el procesador no reordenará las dos lecturas si están relacionadas a través de la dependencia de datos. La dependencia de datos entre dos lecturas ocurre cuando el valor que ha devuelto una lectura de memoria determina la ubicación de la lectura por una lectura posterior.

Este ejemplo ilustra la dependencia de datos:

class Counter { public int _value; }
class Test
{
  private Counter _counter = new Counter();
  void Do()
  {
    Counter c = _counter; // Read 1
    int value = c._value; // Read 2
  }
}

En el método Do, Itanium nunca reordenará Read 1 y Read 2 aunque Read 1 sea una carga normal y no una de adquirir carga. Puede parecer obvio que estas dos lecturas no puedan reordenarse: La primera lectura determina a qué ubicación de memoria debiera obtener acceso la segunda lectura. No obstante, algunos procesadores distintos a Itanium pueden de hecho reordenar las lecturas. El procesador puede suponer el valor que Read 1 devolverá y realizará Read 2 de forma especulativa, incluso antes de que Read 1 se complete. Pero, nuevamente, Itanium no lo hará.

Más adelante volveré al análisis de la dependencia de datos en Itanium y la pertinencia del modelo de memoria C# quedará más clara.

Además, itanium no reordenará las dos lecturas normales si se relacionan por medio de la dependencia de control. La dependencia de control ocurre cuando el valor devuelto por una lectura determina si se ejecutará una instrucción posterior.

Por lo tanto, en este ejemplo las lecturas de _initialized y _data se relacionan por medio de la dependencia de control:

void Print() {
  if (_initialized)            // Read 1
    Console.WriteLine(_data);  // Read 2
  else
    Console.WriteLine("Not initialized");
}

Incluso si _initialized y _data son lecturas normales (no volátiles), el procesador Itanium no los reordenará. Tenga en cuenta que el compilador JIT todavía es libre de reordenar las dos lecturas; y en algunos casos lo hará.

Además, es importante señalar que, al igual que el procesador x86-x64, Itanium también usa un búfer de almacenamiento, por lo que StoreBufferExample que aparece en la Figura 1 presentará el mismo tipo de reordenaciones en Itanium que en x86-x64. Una anécdota interesante es que si usa LD.ACQ para todas las lecturas y ST.REL para todas las escrituras en Itanium, básicamente obtiene un modelo de memoria x86-x64, donde el búfer de almacenamiento es el único origen de reordenación.

Comportamiento del compilador en Itanium El compilador JIT de CLR tiene un comportamiento fascinante en Itanium: todas las escrituras se emiten como ST.REL, y no como ST. Por consiguiente una escritura volátil y una no volátil generalmente emiten la misma instrucción en Itanium. Sin embargo, una lectura normal se emitirá como LD, solo las lecturas de campos volátiles se emiten como LD.ACQ.

Este comportamiento podría resultar sorprendente ya que ciertamente no se requiere el compilador para emitir ST.REL en escrituras no volátiles. En cuanto a lo que respecta a la especificación de C# de la European Computer Manufacturers Association (ECMA), el compilador puede emitir instrucciones ST normales. Emitir ST.REL es solo algo adicional que el compilador elige realizar para asegurar que un patrón común específico (pero en teoría incorrecto) funcionará tal y como se espera.

Puede ser difícil de imaginar lo que ese importante patrón podría ser donde ST.REL se debe usar para las escrituras, pero LD es suficiente para las lecturas. En el ejemplo PrintAB presentado anteriormente en esta sección, restringir solo las escrituras no ayudarían, ya que las lecturas aún pueden reordenarse.

Existe un escenario muy importante en el que el uso de ST.REL con LD normal es suficiente: cuando las cargas en sí se ordenan a través de la dependencia de datos. Este patrón aparece en la inicialización diferida, que es extremadamente importante. En la Figura 3 se muestra un ejemplo de inicialización diferida.

Figura 3 Inicialización diferida

// Warning: Might not work on future architectures and .NET versions;
// do not use
class LazyExample
{
  private BoxedInt _boxedInt;
  int GetInt()
  {
    BoxedInt b = _boxedInt; // Read 1
    if (b == null)
    {
      lock(this)
      {
        if (_boxedInt == null)
        {
          b = new BoxedInt();
          b._value = 42;  // Write 1
          _boxedInt = b; // Write 2
        }
      }
    }
    int value = b._value; // Read 2
    return value;
  }
}

Para que este fragmento de código siempre devuelva 42, aunque se llame a GetInt desde varios subprocesos simultáneamente, Read 1 no debe reordenarse con Read 2 y Write 1 no debe reordenarse con Write 2. El procesador Itanium no reordenará las lecturas, ya que están relacionadas a través de la dependencia de datos. Las escrituras no se pueden reordenar dado que el JIT de CLR las emite como ST.REL.

Tenga en cuenta que si el campo _boxedInt fuese volátil, el código sería correcto de acuerdo con la especificación ECMA de C#. Eso es lo correcto y podría decirse que es lo único correcto. Sin embargo, incluso si _boxed no es volátil, la versión actual del compilador asegurará que en la práctica el código aún funcione en Itanium.

Por supuesto, la elevación de lectura del bucle, la eliminación de lectura y la introducción de lectura debe hacerlas el JIT de CLR en Itanium, de la misma forma que se hacen en x86-x64.

Observaciones de Itanium La razón por la cual Itanium es una parte interesante de la historia es porque fue la primera arquitectura con un modelo de memoria que se ejecutó en .NET Framework.

Como resultado de ello, en una serie de artículos acerca del modelo de memoria de C# y la palabra clave volátil, los autores por lo general tenían Itanium en mente. Después de todo, hasta .NET Framework 4.5, Itanium fue la única arquitectura distinta a x86-x64 que ejecutó .NET Framework.

Por consiguiente, el autor podría decir algo como: "en el modelo de memoria .NET 2.0, todas las lecturas son volátiles, incluso aquellas de campos no volátiles". Lo que el autor quiere decir es que en Itanium todas las escrituras en CLR se emiten como ST.REL. Este comportamiento no está garantizado por las especificaciones de ECMA en C#, por lo tanto, no debiera mantenerse en futuras versiones de .NET Framework ni en futuras arquitecturas (y, de hecho, no se mantiene en .NET Framework 4.5 de ARM).

Del mismo modo, algunas personas podrían argumentar que la inicialización diferida es correcta en .NET Framework incluso si el campo de alojamiento no es volátil, mientras que otros dicen que el campo debe ser volátil.

Y por supuesto, los desarrolladores escribieron el código para estos (algunas veces contradictorios) supuestos. Entonces, entender la parte Itanium de la historia puede ser útil cuando se intenta dar sentido al código concurrente escrito por otra persona, leer artículos más antiguos o incluso solo hablar con otros desarrolladores.

La implementación del modelo de memoria de C# en ARM

La arquitectura ARM es la incorporación más reciente en la lista de las arquitecturas compatible con .NET Framework. De la misma forma que Itanium, ARM tiene un modelo de memoria más frágil que la x86-x64.

Reordenación de ARM Al igual que Itanium, a ARM se le permite libremente reordenar las lecturas y las escrituras normales. Sin embargo, la solución que ofrece ARM para dominar el movimiento de lecturas y escrituras es algo distinta al que ofrece Itanium. ARM expone una única instrucción, DMB, que actúa como una barrera de memoria completa. Ninguna operación de memoria puede pasar por alto la DMB en cualquier dirección.

Además de las limitaciones impuestas por la instrucción DMB, ARM también respeta la dependencia de datos, pero no respeta la dependencia de control. Consulte la sección "Reordenación de Itanium" al principio de este artículo para obtener más explicaciones de datos y dependencias de control.

Comportamiento del compilador en ARM La instrucción DMB se usa para implementar la semántica volátil en C#. En ARM, el JIT de CLR implementa una lectura desde un campo volátil usando una lectura normal (tal como LDR) seguida de la instrucción DMB. Ya que la instrucción de DMB evitará la lectura volátil por medio de la reordenación con cualquier operación subsiguiente, esta solución implementa la semántica de adquisición.

Una escritura en un campo volátil se implementa usando la instrucción DMB seguida de una escritura normal (tal como STR). Ya que la instrucción de DMB evita la escritura volátil por medio de la reordenación con cualquier operación previa, esta solución implementa de forma correcta la semántica de liberación.

Al igual que con el procesador Itanium, sería bueno ir más allá de la especificación ECMA de C# y mantener funcionando el patrón de inicialización diferida ya que muchos de los códigos existentes dependen de él. No obstante, dejar eficazmente volátiles todas las escrituras en ARM no es una buena solución, ya que la instrucción DBM es bastante costosa.

En .NET Framework 4.5, el JIT de CLR usa un truco ligeramente distinto para obtener trabajos de inicialización diferida. Las siguientes barreras se consideran de "liberación":

  1. Las escrituras en los campos de tipos de referencia en el recolector de elementos del montón (GC) no usados
  2. Las escrituras en los campos estáticos de tipos de referencia

Como resultado, cualquier escritura que pueda publicar un objeto se trata como una barrera de liberación.

Esta es la parte importante de LazyExample (recordar que ninguno de los campos es volátil):

b = new BoxedInt();
b._value = 42;  // Write 1
// DMB will be emitted here
_boxedInt = b; // Write 2

Dado que el JIT de CLR emite la instrucción DMB antes de la publicación del objeto en el campo _boxedInt, Write 1 y Write 2 no se reordenarán. Y debido a que ARM respeta la dependencia de datos, las lecturas en el patrón de inicialización diferida tampoco se reordenarán, y el código funcionará correctamente en ARM.

Por lo tanto, el JIT de CLR realiza un esfuerzo adicional (superior a lo que está estipulado en la especificación ECMA de C#) para mantener la variante más común de la inicialización diferida incorrecta funcionando en ARM.

Como comentario final sobre ARM, tenga en cuenta que, como en x86-x64 e Itanium, la elevación de lectura del bucle, la eliminación de lectura y la introducción de lectura son optimizaciones legítimas a lo que refiere el JIT de CLR.

Ejemplo: Inicialización diferida

Puede ser instructivo examinar algunas variantes distintas del patrón de inicialización diferida y pensar en cómo se comportarán en distintas arquitecturas.

Implementación correcta La implementación de la inicialización diferida en la Figura 4 es correcta de acuerdo con el modelo de memoria C# como se define en la especificación ECMA de C#, por lo que se garantiza trabajar en todas las arquitecturas compatibles con versiones actuales y futuras de .NET Framework.

Figure 4 Implementación correcta de inicialización diferida

class BoxedInt
{
  public int _value;
  public BoxedInt() { }
  public BoxedInt(int value) { _value = value; }
}
class LazyExample
{
  private volatile BoxedInt _boxedInt;
  int GetInt()
  {
    BoxedInt b = _boxedInt;
    if (b == null)
    {
      b = new BoxedInt(42);
      _boxedInt = b;
    }
    return b._value;
  }
}

Observe que, aunque el ejemplo de código es correcto, en la práctica es aún preferible usar Lazy<T> o el tipo LazyInitializer.

Implementación incorrecta Nº 1 En la Figura 5 se muestra una implementación que no es correcta de acuerdo con el modelo de memoria C#. A pesar de ello, la implementación probablemente funcionará en x86-x64, en Itanium y en ARM en .NET Framework. Esta versión del código no es correcta. Dado que _boxedInt no es volátil, se permite un compilador de C# para reordenar Read 1 y Read 2 o Write 1 y Write 2. Aunque su resultado potencialmente sería que devuelva 0 desde GetInt.

Figura 5 Implementación incorrecta de inicialización diferida

// Warning: Bad code
class LazyExample
{
  private BoxedInt _boxedInt; // Note: This field is not volatile
  int GetInt()
  {
    BoxedInt b = _boxedInt; // Read 1
    if (b == null)
    {
      b = new BoxedInt(42); // Write 1 (inside constructor)
      _boxedInt = b;        // Write 2
    }
    return b._value;        // Read 2
  }
}

Sin embargo, este código se comportará correctamente (es decir, siempre devuelve 42) en todas las arquitecturas de .NET Framework versiones 4 y 4.5:

  • x86-x64:
    • Las escrituras y las lecturas no se reordenarán. No existe un patrón de almacenamiento de carga en el código ni tampoco existe un motivo para que el compilador almacene en memoria caché los valores de los registros.
  • Itanium:
    • Las escrituras no se reordenarán ya que son ST.REL.
    • Las lecturas no se reordenarán debido a la dependencia de datos.
  • ARM:
    • Las escrituras no se reordenarán ya que DMB se emite antes de "_boxedInt = b".
    • Las lecturas no se reordenarán debido a la dependencia de datos.

Desde luego, se debe usar esta información solo para intentar entender el comportamiento de un código existente. No use este patrón cuando escriba un nuevo código.

Implementación incorrecta Nº 2 Es posible que la implementación incorrecta de la Figura 6 arroje errores en ARM y en Itanium.

Figura 6 Segunda implementación incorrecta de inicialización diferida

// Warning: Bad code
class LazyExample
{
  private int _value;
  private bool _initialized;
  int GetInt()
  {
    if (!_initialized) // Read 1
    {
      _value = 42;
      _initialized = true;
    }
    return _value;     // Read 2
  }
}

Esta versión de inicialización diferida usa dos campos separados para hacer seguimiento de los datos (_value) y si el campo se inicializa (_initialized). Como resultado, las dos lecturas, Read 1 y Read 2, ya no están relacionadas a través de la dependencia de datos. Asimismo, en ARM, las escrituras también deben reordenarse por los mismos motivos que en la siguiente implementación incorrecta (Nº 3).

Como resultado, esta versión podría en la práctica tener errores y devolver 0 en ARM e Itanium. Por supuesto, a GetInt se le permite devolver 0 en x86-x64 (y también como resultado de las optimizaciones de JIT), pero ese comportamiento parece no ocurrir en .NET Framework 4.5.

Implementación incorrecta Nº 3 Por último, es posible tener el ejemplo de cómo tener errores incluso en x86-x64. Solo tengo que añadir una lectura de aspecto inofensivo, como se muestra en la Figura 7.

Figura 7 Tercera incorrecta implementación de inicialización diferida

// WARNING: Bad code
class LazyExample
{
  private int _value;
  private bool _initialized;
  int GetInt()
  {
    if (_value < 0) throw new 
      Exception(); // Note: extra reads to get _value
                          // pre-loaded into a register
    if (!_initialized)      // Read 1
    {
      _value = 42;
      _initialized = true;
      return _value;
    }
    return _value;          // Read 2
  }
}

La lectura adicional que comprueba si _value < 0 puede causar ahora que el compilador almacene en la memoria caché el valor del registro. Como resultado, Read 2 se procesará desde el registro y se reordenará de forma eficaz con Read 1. Por lo tanto, esta versión de GetInt puede en la práctica devolver 0 incluso en x86-x64.

En resumen

Cuando se escribe un nuevo código de multiproceso, generalmente es buena idea evitar la complejidad del modelo de memoria de C# del todo mediante primitivas de simultaneidad de alto nivel como: bloqueadores, colecciones simultáneas, tareas y bucles paralelos. Cuando se usa un código de alto consumo de CPU, a veces tiene sentido usar campos volátiles, siempre y cuando se base en las garantías de especificación de ECMA de C# y no en los detalles de la implementación específica de arquitectura.

Igor Ostrovsky es ingeniero de desarrollo de software senior en Microsoft. Ha trabajado con Parallel LINQ, Task Parallel Library y otras bibliotecas y primitivas paralelas de .NET Framework. Ostrovsky tiene un blog sobre temas de programación en igoro.com.

Gracias a los siguientes expertos técnicos por su ayuda en la revisión de este artículo: Joe Duffy, Eric Eilebrecht, Joe Hoag, Emad Omara, Grant Richins, Jaroslav Sevcik y Stephen Toub