C#

O modelo de memória C# na teoria e na prática, Parte 2

Igor Ostrovsky

 

Este é o segundo artigo de uma série de duas partes que aborda o modelo de memória C#. Conforme explicado na primeira parte da edição de dezembro da MSDN Magazine (msdn.microsoft.com/magazine/jj863136), o compilador e o hardware podem transformar sutilmente as operações de memória de um programa de modo que não afetem o comportamento single-threaded, mas que podem afetar o comportamento multithreaded. Veja este exemplo de método:

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

Se _data e _initialized forem campos comuns (isto é, não voláteis), o compilador e o processador poderão reordenar as operações, de modo que Init será executado como se tivesse sido escrito desta forma:

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

No artigo anterior, descrevi o modelo de memória C# abstrato. Neste artigo, explicarei como o modelo de memória C# é realmente implementado em diferentes arquiteturas que contam com suporte do Microsoft .NET Framework 4.5.

Otimizações do compilador

Conforme mencionado no primeiro artigo, o compilador pode otimizar o código de maneira que reordene as operações de memória. No .NET Framework 4.5, o compilador csc.exe que compila C# para IL não faz muitas otimizações, de modo que ele não reordenará as operações de memória. No entanto, o compilador JIT (just-in-time) que converte IL para código de máquina executará, de fato, algumas otimizações que reordenam as operações de memória, conforme discutiremos.

Elevação da leitura do loop Considere o padrão de loop de sondagem:

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!   } }

Nesse caso, o compilador JIT do .NET 4.5 pode reescrever o loop desta forma:

if (_flag) { while (true); }

No caso do single-threaded, essa transformação é inteiramente legal e, no geral, elevar uma leitura de um loop é uma excelente otimização. No entanto, se _flag for definido como false em outro thread, a otimização poderá causar uma parada.

Observe que se o campo _flag fosse volátil, o compilador JIT não elevaria a leitura do loop. (Consulte a seção "Loop de sondagem", no artigo de dezembro, para obter uma explicação mais detalhada desse padrão.)

Eliminação da leitura Outra otimização do compilador que pode causar erros no código multithreaded é ilustrada neste exemplo:

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

A classe contém dois campos não voláteis, _A e _B. O método Foo lê primeiro o campo _A e depois o campo _B. No entanto, como os campos não são voláteis, o compilador é livre para reordenar as duas leituras. Desse modo, se a correção do algoritmo depender da ordem das leituras, o programa conterá um bug.

É difícil imaginar o que o compilador ganharia trocando a ordem das leituras. Devido ao modo como o Foo é escrito, provavelmente, o compilador não trocará a ordem das leituras.

No entanto, a reordenação acontecerá se eu adicionar outra instrução inócua no início do método Foo:

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

Na primeira linha do método Foo, o compilador carrega o valor de _B em um registro. Assim, a segunda carga de _B simplesmente usa o valor que já está no registro, em vez de emitir uma instrução de carga real.

O compilador reescreve eficazmente o método Foo como se segue:

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

Embora esse exemplo de código forneça uma vaga aproximação de como o compilador otimiza o código, também é interessante observar a desmontagem do 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 ...

Mesmo que você não conheça o procedimento de montagem, o que está acontecendo aqui é bastante fácil de entender. Como parte da avaliação da condição _B == -1, o compilador carrega o campo _B no registro EDX. Posteriormente, quando o campo _B é lido novamente, o compilador simplesmente reutiliza o valor que ele já tem no EDX, em vez de emitir uma leitura de memória real. Consequentemente, as leituras de _A e _B são reordenadas.

Nesse caso, a solução correta é marcar o campo _A como volátil. Se isso for feito, o compilador não deverá reordenar as leituras de _A e _B, pois a carga de _A possui semânticas de aquisição por carga. No entanto, devo apontar que o .NET Framework, até a versão 4, não trata esse caso corretamente e, na verdade, marcar o campo _A como volátil não evita a reordenação da leitura. Esse problema foi corrigido no .NET Framework versão 4.5.

Introdução da leitura Como já expliquei, às vezes, o compilador unifica várias leituras em uma só. O compilador também pode dividir uma única leitura em várias leituras. No .NET Framework 4.5, a introdução da leitura é muito menos comum que a eliminação da leitura e ocorre somente em circunstâncias bastante raras e específicas. No entanto, às vezes, ela realmente acontece.

Para entender a introdução da leitura, veja o exemplo a seguir:

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;   } }

Ao examinar o método PrintObj, o que parece é que o valor de obj nunca será nulo na expressão obj.ToString. Entretanto, a linha de código poderia, de fato, gerar um NullReferenceException. O JIT do CLR pode compilar o método PrintObj como se tivesse sido escrito desta forma:

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

Como a leitura do campo _obj foi dividido em duas leituras do campo, o método ToString agora pode ser chamado em um destino null.

Observe que você não poderá reproduzir NullReferenceException usando esse exemplo de código no .NET Framework 4.5 em x86-x64. A introdução da leitura é bastante difícil de ser reproduzida no .NET Framework 4.5, mas, apesar disso, ela nunca ocorrerá em determinadas circunstâncias especiais.

Implementação do modelo de memória C# no x86-x64

Como o x86 e x64 têm o mesmo comportamento em relação ao modelo de memória, considerarei ambas as variantes de processador juntas.

Diferentemente de algumas arquiteturas, o processador x86-x64 fornece rígidas garantias de ordenação nas operações de memória. Na verdade, o compilador JIT não precisa usar nenhuma instrução especial no x86-x64 para atingir semânticas voláteis; operações comuns de memória já fornecem essas semânticas. Mesmo assim, ainda há casos específicos em que o processador x86-x64 não reordena as operações de memória.

Reordenação de memória do x86-x64 Embora o processador x86-x64 forneça rígidas garantias de ordenação corretamente, um tipo específico de reordenação de hardware ainda ocorre.

O processador x86-x64 não reordenará duas gravações, nem duas leituras. No entanto, um possível efeito (e único) de reordenação é que quando um processador grava um valor, este não é disponibilizado imediatamente para outros processadores. A Figura 1 mostra um exemplo que demonstra esse comportamento.

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 o caso em que os métodos ThreadA e ThreadB são chamados de diferentes threads em uma nova instância de StoreBufferExample, conforme mostrado na Figura 2. Se você pensar sobre os possíveis resultados do programa na Figura 2, três casos parecem ser possíveis:

  1. O Thread 1 é concluído antes do início do Thread 2. O resultado é A_Won=true, B_Won=false.
  2. O Thread 2 é concluído antes do início do Thread 1. O resultado é A_Won=false, B_Won=true.
  3. Os threads se intercalam. O resultado é A_Won=false, B_Won=false.


Figura 2 Chamando os métodos ThreadA e ThreadB de diferentes threads

Mas, surpreendentemente, há um quarto caso: é possível que os campos A_Won e B_Won sejam verdadeiros depois que esse código for finalizado! Por causa do buffer do repositório, os repositórios podem ficar "atrasados" e, dessa forma, acabam reordenados com uma carga subsequente. Embora esse resultado não seja consistente com nenhuma intercalação das execuções de Thread 1 e Thread 2, ele ainda é possível.

Esse exemplo é interessante porque temos um processador (o x86-x64) com ordenação relativamente rígida e todos os campos são voláteis - e ainda observamos uma reordenação das operações de memória. Ainda que a gravação em A seja volátil e a leitura de A_Won também seja volátil, os limites são unidirecionais e, na realidade, permitem essa reordenação. Assim, o método ThreadA pode ser executado efetivamente como se tivesse sido escrito desta forma:

 

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

Uma possível correção é inserir uma barreira de memória em ThreadA e ThreadB. O método ThreadA atualizado se pareceria com este:

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

O JIT do CLR insere uma instrução "lock or" no lugar da barreira de memória. Uma instrução do x86 bloqueada tem o feito colateral de liberar o buffer do repositório.

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

Uma observação interessante a ser feita é que a linguagem de programação Java usa uma abordagem diferente. O modelo de memória Java tem uma definição de "volátil " um pouco mais rígida, que não permite a reordenação de carga no repositório, de modo que um compilador Java no x86 caracteristicamente emitirá uma instrução bloqueada após uma gravação volátil.

Remarcações do x86-x64 O processador x86 tem um modelo de memória razoavelmente rígido, e a única fonte de reordenação no nível de hardware é o buffer do repositório. O buffer do repositório pode fazer com que uma gravação seja reordenada com uma leitura subsequente (reordenação na carga do repositório).

Além disso, determinadas otimizações do compilador podem resultar na reordenação das operações de memória. Notavelmente, se várias leituras acessarem o mesmo local da memória, o compilador poderá optar por executar a leitura somente uma vez e manter o valor em um registro para leitura subsequentes.

É interessante observar que as semânticas voláteis de C# correspondem rigorosamente às garantias de reordenação de hardware feitas pelo hardware x86-x64. Consequentemente, as leituras e as gravações de campos voláteis não exigem instruções especiais no x86: as leituras e gravações comuns (por exemplo, usando a instrução MOV) são suficientes. Obviamente, seu código não deve depender desses detalhes de implementação, pois eles variam entre arquiteturas de hardware e, possivelmente, versões do .NET.

Implementação do modelo de memória C# na arquitetura Itanium

A arquitetura de hardware Itanium tem um modelo de memória menos rígido do que o do x86-x64. O Itanium contava com o suporte do .NET Framework até a versão 4.

Embora o Itanium não tenha mais suporte no .NET Framework 4.5, entender o modelo de memória Itanium é útil quando você lê artigos mais antigos no modelo de memória .NET e precisa manter o código que incorporava as recomendações desses artigos.

Reordenação do Itanium O Itanium tem um conjunto de instruções diferente do x86-x64, e os conceitos de modelo de memória são mostrados no conjunto de instruções. O Itanium faz a diferenciação entre uma LD (carga comum) e uma LD.ACQ (aquisição por carga), bem como entre um ST (repositório comum) e uma ST.REL (liberação por repositório).

As cargas e os repositórios comuns podem ser reordenados livremente pelo hardware, desde que o comportamento single-threaded não mude. Por exemplo, 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 duas leituras de _a e _b no método PrintAB. Como as leituras acessam um campo comum e não volátil, o compilador usará uma LD comum (e não uma LD.ACQ) para implementar as leituras. Consequentemente, as duas leituras podem ser reordenadas eficazmente no hardware, de modo que o método PrintAB se comportará como se tivesse sido escrito desta forma:

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

Na prática, se a reordenação acontece ou não depende de uma variedade de fatores imprevisíveis - o que há no cache do processador, quão ocupado está o pipeline do processador, etc. Entretanto, o processador não reordenará duas leituras se elas tiverem sido relacionadas pela dependência de dados. A dependência de dados entre duas leituras ocorre quando o valor retornado por uma leitura de memória determina o local da leitura por uma leitura subsequente.

Este exemplo ilustra a dependência de dados:

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   } }

No método Do, o Itanium nunca reordenará Read 1 e Read 2, mesmo que Read 1 seja uma carga comum, e não uma aquisição por carga. Parece óbvio que essas duas leituras não possam ser reordenadas: A primeira leitura determina qual local da memória a segunda leitura deve acessar! No entanto, alguns processadores, diferentes do Itanium, podem, de fato, reordenar as leituras. O processador pode adivinhar o valor que Read 1 retornará e executar Read 2 especulativamente, mesmo antes de Read 1 ser concluída. Porém, novamente, o Itanium não fará isso.

Voltarei à dependência de dados na discussão sobre o Itanium logo mais e sua relevância para o modelo de memória C# se tornará mais clara.

Além disso, o Itanium não reordenará duas leituras comuns se elas tiverem sido relacionadas pela dependência de controle. A dependência de controle ocorre quando o valor retornado por uma leitura determina se uma instrução subsequente será executada.

Assim, neste exemplo, as leituras de _initialized e _data are são relacionadas pela dependência de controle:

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

Mesmo que _initialized e _data sejam leituras comuns (não voláteis), o processador Itanium não as reordenará. Observe que o compilador JIT ainda está livre para reordenar as duas leituras e, em alguns, é isso que ele fará.

Além disso, vale apontar que, assim como o processador x86-x64, o Itanium também usa um buffer de repositório, de modo que o StoreBufferExample mostrado na Figura 1 exibirá o mesmo tipo de reordenações no Itanium, como fez no x86-x64. O interessante é que se você usar LD.ACQ para todas as leituras e ST.REL para todas as gravações no Itanium, você obterá basicamente o modelo de memória x86-x64, onde o buffer do repositório é a única fonte de reordenação.

Comportamento do compilador no Itanium O compilador JIT do CLR tem um comportamento surpreendente no Itanium: todas as gravações são emitidas como ST.REL, e não como ST. Consequentemente, uma gravação volátil e uma não volátil caracteristicamente emitirão a mesma instrução no Itanium. No entanto, uma leitura comum será emitida como LD; somente leituras de campos voláteis são emitidas como LD.ACQ.

Esse comportamento poderá ser uma surpresa, pois o compilador, certamente, não é exigido para emitir ST.REL para gravações não voláteis. No que se refere à especificação da ECMA (Associação Europeia dos Fabricantes de Computadores) para C#, o compilador pode emitir instruções ST comuns. Emitir ST.REL é apenas algo extra que o compilador opta por fazer, a fim de garantir que um padrão comum específico (mas, na teoria, incorreto) funcionará conforme esperado.

Pode ser difícil imaginar quão importante esse padrão pode ser onde o ST.REL deve ser usado para gravações, mas a LD é suficiente para leituras. No exemplo de PrintAB apresentado anteriormente nesta seção, restringir apenas as gravações não ajudaria, pois as leituras ainda poderiam ser reordenadas.

Há um cenário muito importante no qual usar ST.REL com a LD comum é suficiente: quando as cargas em si são ordenadas usando a dependência de dados. Esse padrão surge na inicialização lenta, que é um padrão extremamente importante. A Figura 3 mostra um exemplo de inicialização lenta.

Figura 3 Inicialização lenta

// 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 essa parte do código sempre retorne 42 (mesmo que GetInt seja chamado de vários threads simultaneamente), Read 1 não deve ser reordenada com Read 2, e Write 1 não deve ser reordenada com Write 2. As leituras não serão reordenadas pelo processador Itanium, pois elas são relacionadas pela dependência de dados. E as gravações não serão reordenadas porque o JIT do CLR as emite como ST.REL.

Observe que se o campo _boxedInt fosse volátil, o código estaria correto de acordo com a especificação ECMA para C#. Esse é o melhor tipo de correto e, comprovadamente, o único tipo real de correto. No entanto, mesmo que _boxed não seja volátil, a versão atual do compilador garantirá que o código ainda funcione no Itanium, na prática.

Obviamente, a elevação da leitura do loop, a eliminação da leitura e a introdução da leitura podem ser executadas pelo JIT do CLR no Itanium, assim como são no x86-x64.

Remarcações do Itanium O Itanium é uma parte interessante da história porque ele foi a primeira arquitetura com um modelo de memória frágil que executou o .NET Framework.

Consequentemente, em vários artigos sobre o modelo de memória C# e sobre a palavra-chave volátil e C#, os autores geralmente têm o Itanium em mente. Afinal de contas, até o .NET Framework 4.5, o Itanium foi a única arquitetura além do x86-x64 que executou o .NET Framework.

Consequentemente, o autor pode dizer algo como, "No modelo de memória .NET 2.0, todas as gravações são voláteis, mesmo aquelas em campos não voláteis". O que o autor quer dizer é que no Itanium, o CLR emitirá todas as gravações como ST.REL. Esse comportamento não é garantido pela especificação da ECMA para C# e, por consequência, pode não ser mantido em versões futuras do .NET Framework e em arquiteturas futuras (e, na verdade, não foi mantida no .NET Framework 4.5 no ARM).

De forma semelhante, algumas pessoas argumentariam que a inicialização lenta é correta no .NET Framework mesmo se o campo holding não fosse volátil, enquanto outros diriam que o campo deve ser volátil.

E, é claro, os desenvolvedores escreveriam o código em relação a essas suposições (às vezes, contraditórias). Assim, entender parte da história do Itanium pode ser útil quando se tenta ver sentido no código simultâneo escrito por outra pessoa, ao ler outros artigos ou mesmo apenas ao conversar com outros desenvolvedores.

Implementação do modelo de memória C# no ARM

A arquitetura ARM é a mais recente adição à lista de arquiteturas que têm suporte do .NET Framework. Assim como o Itanium, o ARM tem um modelo de memória mais frágil que o x86-x64.

Reordenação do ARM Assim como o Itanium, o ARM podia reordenar livremente as leituras e gravações comuns. No entanto, a solução que o ARM fornece para dominar a movimentação das leituras e gravações é um pouco diferente da fornecida pelo Itanium. O ARM expõe uma única instrução - DMB - que atua como uma barreira de memória completa. Nenhuma operação de memória pode ignorar a DMB em qualquer direção.

Além das restrições impostas pela instrução DMB, o ARM também respeita a dependência de dados, mas não respeita a dependência de controle. Consulte a seção "Reordenação do Itanium", mais acima neste artigo, para ver explicações sobre dependências de dados e de controle.

Comportamento do compilador no ARM A instrução DMB é usada para implementar as semânticas voláteis no C#. No ARM, o JIT do CLR implementa uma leitura de um campo volátil usando uma leitura comum (como LDR) seguida pela instrução DMB. Como a instrução DMB impedirá a leitura volátil de ser reordenada com as operações subsequentes, essa solução implementa corretamente as semânticas de aquisição.

Uma gravação em um campo volátil é implementada usando a instrução DMB seguida por uma gravação comum (como STR). Como a instrução DMB impede a gravação volátil de ser reordenada com as operações anteriores, essa solução implementa corretamente as semânticas de liberação.

Assim como no processador Itanium, seria interessante ir além da especificação da ECMA para C# e manter o padrão de inicialização lenta em funcionamento, pois muito do código existente dependerá dela. No entanto, tornar efetivamente voláteis todas as gravações não é uma boa solução no ARM, pois a instrução DBM é razoavelmente onerosa.

No .NET Framework 4.5, o JIT do CLR usa um truque ligeiramente diferente para que a inicialização lenta funcione. Veja a seguir o que são consideradas barreiras de "liberação":

  1. Gravações nos campos de tipo de referência no heap do GC (coletor de lixo)
  2. Gravações nos campos estáticos de tipo de referência

Consequentemente, qualquer gravação que possa publicar um objeto é tratada como uma barreira de liberação.

Essa é a parte relevante do LazyExample (lembre-se de que nenhum dos campos é volátil):

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

Como o JIT do CLR emite as instruções DMB antes da publicação do objeto no campo _boxedInt, Write 1 e Write 2 não serão reordenadas. E como o ARM respeita a dependência de dados, as leituras no padrão de inicialização lenta tampouco serão reordenadas e o código funcionará corretamente no ARM.

Desse modo, o JIT do CLR faz um esforço extra (além do que é obrigatório na especificação da ECMA para C#) para manter a variante mais comum da inicialização lenta incorreta ativa no ARM.

Apenas como um último comentário sobre o ARM, observe que, assim como no x86-x64 e no Itanium, a elevação da leitura do loop, a eliminação da leitura e a introdução da leitura são todas otimizações legítimas no que tange ao JIT do CLR.

Exemplo: inicialização lenta

Pode ser instrutivo observar algumas variantes diferentes do padrão de inicialização lenta e pensar sobre como elas se comportarão em diferentes arquiteturas.

Implementação correta A implementação da inicialização lenta na Figura 4 é correta de acordo com o modelo de memória C#, conforme definido pela especificação da ECMA para C#, de modo que é garantido que ela funcione em todas as arquiteturas com suporte das versões atuais e futuras do .NET Framework.

Figura 4 Uma implementação correta da inicialização lenta

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 mesmo que o exemplo de código esteja correto, na prática, ainda é preferível usar o tipo Lazy<T> ou LazyInitializer.

Implementação incorreta nº 1 A Figura 5 mostra uma implementação que não está correta, de acordo com o modelo de memória C#. Apesar disso, a implementação provavelmente funcionará nas arquiteturas x86-x64, Itanium e ARM no .NET Framework. Essa versão do código não está correta. Como _boxedInt não é volátil, um compilador do C# tem permissão para reordenar Read 1 com Read 2 ou Write 1 com Write 2. Possivelmente, a reordenação também resultaria no retorno de 0 em GetInt.

Figura 5 Uma implementação incorreta da inicialização lenta

// 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   } }

No entanto, esse código se comportará corretamente (isto é, sempre retornará 42) em todas as arquiteturas nas versões 4 e 4.5 do .NET Framework:

  • x86-x64:
    • As gravações e leituras não serão reordenadas. Não há padrão de carga por repositório no código, assim como não há motivo para o compilador armazenar em cache os valores em registros.
  • Itanium:
    • As gravações não serão reordenadas porque são ST.REL.
    • As leituras não serão reordenadas devido à dependência de dados.
  • ARM:
    • As gravações não serão reordenadas porque DMB é emitida antes de "_boxedInt = b".
    • As leituras não serão reordenadas devido à dependência de dados.

Obviamente, você deve usar essas informações apenas para tentar entender o comportamento do código existente. Não use esse padrão ao gravar um novo código.

Implementação incorreta nº 2 A implementação incorreta na Figura 6 pode falhar no ARM e no Itanium.

Figura 6 Uma segunda implementação incorreta da inicialização lenta

// 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   } }

Essa versão da inicialização lenta usa dois campos separados para rastrear os dados (_value) e se o campo é inicializado (_initialized). Consequentemente, as duas leituras - Read 1 e Read 2 - não são mais relacionadas por dependência de dados. Além disso, no ARM, as gravações também pode ser reordenadas, pelos mesmos motivos, na próxima implementação incorreta (nº 3).

Como resultado, na prática, essa versão pode falhar e retornar 0 no ARM e no Itanium. Obviamente, GetInt pode retornar 0 no x86-x64 (e também como consequência das otimizações de JIT), mas esse comportamento não parece acontecer no .NET Framework 4.5.

Implementação incorreta nº 3 Por fim, é possível obter o exemplo de falha mesmo no x86-x64. Preciso apenas adicionar uma leitura de aparência inócua, conforme mostrado na Figura 7.

Figura 7 Uma terceira implementação incorreta da inicialização lenta

// 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   } }

A leitura extra que verifica se _value < 0 agora pode fazer com que o compilador armazene em cache o valor no registro. Consequentemente, Read 2 será servida de um registro e, assim, é reordenada efetivamente com Read 1. Desse modo, essa versão de GetInt pode, na prática, retornar 0 mesmo no x86-x64.

Conclusão

Ao escrever novo código multithreaded, normalmente é uma boa prática evitar a complexidade do modelo de memória C#, de modo geral usando primitivos de simultaneidade de alto nível, como bloqueios, coletas simultâneas, tarefas e loops paralelos. Ao escrever código que consome muita CPU, às vezes, faz sentido usar campos voláteis, desde que você confie apenas nas garantias da especificação da ECMA para C#, e não nos detalhes da implementação específica de arquitetura.

Igor Ostrovsky é engenheiro sênior de desenvolvimento de software da Microsoft. Ele trabalhou no Parallel LINQ, na Task Parallel Library e em outras bibliotecas paralelas e primitivos no .NET Framework. Ostrovsky bloga tópicos de programação em igoro.com.

Agradecemos aos seguintes especialistas técnicos pela revisão deste artigo: Joe Duffy, Eric Eilebrecht, Joe Hoag, Emad Omara, Grant Richins, Jaroslav Sevcik e Stephen Toub