Observação
O acesso a essa página exige autorização. Você pode tentar entrar ou alterar diretórios.
O acesso a essa página exige autorização. Você pode tentar alterar os diretórios.
O modelo de memória C# na teoria e na prática
Esta é a primeira de uma série de duas partes que contará a longa história do modelo de memória C#. A primeira parte explica as garantias feitas pelo modelo de memória C# e mostra os padrões de código que motivam as garantias; a segunda parte detalhará como as garantias são atingidas em diferentes arquiteturas de hardware no Microsoft .NET Framework 4.5.
Uma fonte de complexidade na programação multi-threaded é que o compilador e o hardware podem sutilmente se transformar em operações de memória de um programa de formas que não afetam o comportamento single-threaded, mas podem afetar o comportamento multi-threaded. Considere o seguinte 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 se tiver sido escrito desta forma:
void Init() { _initialized = true; _data = 42; }
Há várias otimizações nos compiladores e nos processadores que podem resultar nesse tipo de reordenação, conforme abordarei na Parte 2.
Em um programa single-threaded, a reordenação das instruções em Init não faz diferença no significado do programa. Desde que _initialized e _data sejam atualizados antes do retorno do método, a ordem das atribuições não importa. Em um programa single-threaded, não há segundo thread que possa observar o estado entre as atualizações.
No entanto, em um programa multi-threaded, a ordem das atribuições pode ser importante porque outro thread poderia ler os campos, enquanto Init estivesse no meio da execução. Consequentemente, na versão reordenada de Init, outro thread pode observar _initialized=true e _data=0.
O modelo de memória C# é um conjunto de regras que descreve quais tipos de reordenação de operação de memória são e não são permitidos. Todos os programas devem ser escritos em relação às garantias definidas na especificação.
No entanto, mesmo que o compilador e o processador possam reordenar as operações de memória, isso não significa que eles sempre façam isso na prática. Muitos programas que contêm um "bug" de acordo com o modelo de memória C# abstrato continuarão sendo executados corretamente em determinados hardwares que estiverem executando uma versão específica do .NET Framework. Notadamente, os processadores x86 e x64 reordenam operações somente em determinados cenários restritos e, da mesma forma, o compilador JIT (just-in-time) do CLR não executa muitas das transformações para as quais tem permissão.
Embora o modelo de memória C# abstrato seja o que você deve ter em mente ao escrever novo código, ele pode ser útil para entender a implantação real do modelo de memória em diferentes arquiteturas, especialmente ao tentar entender o comportamento do código existente.
Modelo de memória C# de acordo com a ECMA-334
A definição autorizada do modelo de memória C# está na Especificação de Linguagem C# Padrão da ECMA-334 (bit.ly/MXMCrN). Vamos falar sobre o modelo de memória C#, conforme definido na especificação.
Reordenação de operação de memória De acordo com a ECMA-334, quando um thread lê um local de memória no C# para o qual foi escrito por um thread diferente, o leitor pode ver um valor obsoleto. Esse problema é ilustrado na Figura 1.
Figura 1 Código em risco de reordenação de operação de memória
public class DataInit { private int _data = 0; private bool _initialized = false; void Init() { _data = 42; // Write 1 _initialized = true; // Write 2 } void Print() { if (_initialized) // Read 1 Console.WriteLine(_data); // Read 2 else Console.WriteLine("Not initialized"); } }
Suponha que Init e Print sejam chamados paralelamente (isto é, em threads diferentes) em uma nova instância de DataInit. Se você examinar o código de Init e Print, pode parecer que Print pode emitir apenas "42" ou "Não inicializado". No entanto, Print também pode emitir "0".
O modelo de memória C# permite a reordenação de operações de memória em um método, desde que o comportamento da execução single-threaded não mude. Por exemplo, o compilador e o processador são livres para reordenar as operações do método Init, como se segue:
void Init() { _initialized = true; // Write 2 _data = 42; // Write 1 }
Essa reordenação não mudaria o comportamento do método Init em um programa single-threaded. Em um programa multi-threaded, entretanto, outro thread poderia ler os campos _initialized e _data depois que Init tivesse modificado um campo, mas não o outro e, assim, a reordenação poderia mudar o comportamento do programa. Consequentemente, o método Print poderia acabar emitindo um "0".
A reordenação de Init não é a única fonte possível de problemas nesse código de exemplo. Mesmo que a escrita de Init não acabe reordenada, as leituras no método Print podem ser transformadas:
void Print() { int d = _data; // Read 2 if (_initialized) // Read 1 Console.WriteLine(d); else Console.WriteLine("Not initialized"); }
Assim como a reordenação de escritas, essa transformação não tem efeito em um programa single-threaded, mas pode mudar o comportamento de um programa multi-threaded. E, assim como a reordenação de escritas, a reordenação de leituras também pode resultar em um 0 impresso na saída.
Na Parte 2 deste artigo, você verá como e por que essas transformações ocorrem na prática quando observo diferentes arquiteturas de hardware em detalhes.
Campos voláteis A linguagem de programação C# fornece campos voláteis que restringem como as operações de memória podem ser reordenadas. A especificação ECMA declara que os campos voláteis fornecem semântica de aquisição-liberação (bit.ly/NArSlt).
Uma leitura de um campo volátil tem semântica de aquisição, o que significa que ela não pode ser reordenada com operações subsequentes. A leitura volátil forma um limite unilateral: operações precedentes podem passá-lo, mas as operações subsequentes não. Considere este exemplo:
class AcquireSemanticsExample { int _a; volatile int _b; int _c; void Foo() { int a = _a; // Read 1 int b = _b; // Read 2 (volatile) int c = _c; // Read 3 ... } }
Read 1 e Read 3 não são voláteis, enquanto Read 2 é volátil. Read 2 não pode ser reordenado com Read 3, mas pode ser reordenado com Read 1. A Figura 2 mostra as reordenações válidas do corpo Foo.
Figura 2 Reordenação válida de leituras em AcquireSemanticsExample
int a = _a; // Read 1 int b = _b; // Read 2 (volatile) int c = _c; // Read 3 |
int b = _b; // Read 2 (volatile) int a = _a; // Read 1 int c = _c; // Read 3 |
int b = _b; // Read 2 (volatile) int c = _c; // Read 3 int a = _a; // Read 1 |
Um escrita de um campo volátil, por outro lado, tem semântica de liberação, de modo que ela por ser reordenada com operações anteriores. Uma escrita volátil forma um limite unilateral, como demonstra este exemplo:
class ReleaseSemanticsExample { int _a; volatile int _b; int _c; void Foo() { _a = 1; // Write 1 _b = 1; // Write 2 (volatile) _c = 1; // Write 3 ... } }
Write 1 e Write 3 não são voláteis, enquanto Write 2 é volátil. Write 2 não pode ser reordenado com Write 1, mas pode ser reordenado com Write 3. A Figura 3 mostra as reordenações válidas do corpo Foo.
Figura 3 Reordenação válida de escritas em ReleaseSemanticsExample
_a = 1; // Write 1 _b = 1; // Write 2 (volatile) _c = 1; // Write 3 |
_a = 1; // Write 1 _c = 1; // Write 3 _b = 1; // Write 2 (volatile) |
_c = 1; // Write 3 _a = 1; // Write 1 _b = 1; // Write 2 (volatile) |
Voltarei à semântica de aquisição-liberação na seção "Publicação pelo campo volátil", mais adiante neste artigo.
Atomicidade Outro problema que deve ser considerado é que em C#, os valores não são necessariamente escritos atomicamente na memória. Considere este exemplo:
class AtomicityExample { Guid _value; void SetValue(Guid value) { _value = value; } Guid GetValue() { return _value; } }
Se um thread chamasse repetidamente SetValue e outro thread chamasse GetValue, o thread getter poderia observar um valor que nunca foi escrito pelo thread setter. Por exemplo, se o thread setter alternativamente chamasse SetValue com valores Guid (0,0,0,0) e (5,5,5,5), GetValue poderia observar (0,0,0,5) ou (0,0,5,5) ou (5,5,0,0), mesmo que nenhum desses valores jamais fosse atribuído usando SetValue.
O motivo por trás da "laceração" é que a atribuição "_value = value" não é executada atomicamente no nível de hardware. Da mesma forma, a leitura de _value também não é executada atomicamente.
A especificação ECMA para C# garante que os tipos a seguir serão escritos atomicamente: tipos de referência, bool, char, byte, sbyte, short, ushort, uint, int e float. Valores de outros tipos, incluindo tipos de valor definidos pelo usuário, podem ser escritos na memória em várias escritas atômicas. Como resultado, um thread de leitura poderia observar um valor quebrado consistindo em partes de valores diferentes.
Vale a pena observar que mesmo que os tipos que normalmente são lidos e escritos atomicamente (como int) poderão ser lidos ou escritos de forma não atômica se o valor não for corretamente alinhado na memória. Normalmente, C# garantirá que os valores sejam corretamente alinhados, mas o usuário poderá substituir o alinhamento usando a classe StructLayoutAttribute (bit.ly/Tqa0MZ).
Otimizações de não reordenação Algumas otimizações do compilador podem introduzir ou eliminar determinadas operações de memória. Por exemplo, o compilador pode substituir leituras repetidas de um campo por uma única leitura. Da mesma forma, se o código ler um campo e armazenar o valor em uma variável local e, repetidamente, ler a variável, o compilador poderá optar por ler repetidamente o campo.
Como a especificação ECMA para C# não rejeita as otimizações de não reordenação, supostamente, elas são permitidas. Na verdade, como abordarei na Parte 2, o compilador JIT não executa esses tipos de otimizações.
Padrões de comunicação do thread
A finalidade de um modelo de memória é permitir a comunicação do thread. Quando um thread escreve valores na memória e outro thread os lê na memória, o modelo de memória impõe quais valores o thread de leitura poderá ver.
Bloqueio Geralmente, o bloqueio é a maneira mais fácil de compartilhar dados entre threads. Ao usar os bloqueios corretamente, você basicamente não precisará se preocupar com nenhuma confusão do modelo de memória.
Sempre que um thread adquire um bloqueio, o CLR garante que o thread verá todas as atualizações feitas pelo thread que mantém o bloqueio anterior. Vamos adicionar bloqueio ao exemplo do início deste artigo, conforme mostrado na Figura 4.
Figura 4 Comunicação de thread com bloqueio
public class Test { private int _a = 0; private int _b = 0; private object _lock = new object(); void Set() { lock (_lock) { _a = 1; _b = 1; } } void Print() { lock (_lock) { int b = _b; int a = _a; Console.WriteLine("{0} {1}", a, b); } } }
A adição de um bloqueio que Print e Set adquirem fornece uma solução simples. Agora, Set e Print são executados atomicamente um em relação ao outro. A instrução lock garante que os corpos de Print e Set pareçam ser executados em alguma ordem sequencial, mesmo que eles sejam chamados de vários threads.
O diagrama na Figura 5 mostra uma possível ordem sequencial que poderia acontecer se Thread 1 chamasse Print três vezes, Thread 2 chamasse Set uma vez e Thread 3 chamasse Print uma vez.
Figura 5 Execução sequencial com bloqueio
Quando um bloco de códigos bloqueado é executado, há garantia de que todas as escritas dos blocos que precedem o bloco na ordem sequencial do bloqueio sejam vistas. Além disso, também há garantia que não sejam vistas nenhuma das escritas dos blocos que o seguem na ordem sequencial do bloqueio.
Resumindo, os bloqueios ocultam toda a estranheza da complexidade e a imprevisibilidade do modelo de memória: Você não precisará se preocupar com a reordenação das operações de memória se usar os bloqueios corretamente. No entanto, observe que o uso do bloqueio tem que estar correto. Se apenas Print ou Set usar o bloqueio (ou Print e Set adquirirem dois bloqueios diferentes), as operações de memória podem se tornar reordenadas e a complexidade do modelo de memória retorna.
Publicação pela API de threading O bloqueio é um mecanismo bastante comum e potente para compartilhamento de estado entre threads. A publicação pela API do threading é outro padrão da programação concomitante usado frequentemente.
A maneira mais fácil de ilustrar a publicação pela API do threading é por meio de um exemplo:
class Test2 { static int s_value; static void Run() { s_value = 42; Task t = Task.Factory.StartNew(() => { Console.WriteLine(s_value); }); t.Wait(); } }
Quando você examina o código de exemplo anterior, provavelmente espera que "42" seja impresso na tela. E, na verdade, sua intuição estaria correta. Esse exemplo de código garante a impressão de "42".
Pode ser surpreendente que esse caso ainda precise ser mencionado, mas, na realidade, há possíveis implementações de StartNew que permitiriam que "0" fosse impresso, em vez de "42", pelo menos na teoria. Apesar de tudo, há dois threads que se comunicam por um campo não volátil, de modo que as operações de memória podem ser reordenadas. O padrão é exibido no diagrama na Figura 6.
Figura 6 Dois threads se comunicando por um campo não volátil
A implementação StartNew deverá garantir que a escrita em s_value no Thread 1 não seja movida para depois de <start task t> e que a leitura de s_value no Thread 2 não seja movida para antes de <task t starting>. E, de fato, a API StartNew realmente garante isso.
Todas as outras APIs de threading no .NET Framework, como Thread.Start e ThreadPool.QueueUserWorkItem, também apresentam uma garantia semelhante. Na verdade, quase todas as APIs de threading devem ter semântica de barreira para que funcionem corretamente. Essas quase nunca são documentadas, mas geralmente podem ser deduzidas simplesmente pensando nas garantias que deveria haver para que a API fosse útil.
Publicação pela inicialização de tipo Outra maneira de publicar seguramente um valor em vários threads é escrever o valor em um campo estático em um inicializador estático ou em um construtor estático. Considere este exemplo:
class Test3 { static int s_value = 42; static object s_obj = new object(); static void PrintValue() { Console.WriteLine(s_value); Console.WriteLine(s_obj == null); } }
Se Test3.PrintValue fosse chamado de vários threads simultaneamente, haveria garantia de que cada chamada PrintValue imprimisse "42" e "false"? Ou poderia uma das chamadas também imprimir "0" ou "true"? Assim como no caso anterior, você obtém o comportamento esperado: Cada thread garante a impressão de "42" e "false".
Os padrões discutidos até aqui se comportam conforme o esperado. Agora, mostrarei os casos cujo comportamento pode ser surpreendente.
Publicação pelo campo volátil Muitos programas simultâneos podem ser criados usando os três padrões simples discutidos até agora, usados com primitivos de simultaneidade nos namespaces .NET System.Threading e System.Collections.Concurrent.
O padrão que estou prestes a discutir é tão importante que a semântica da palavra-chave volátil foi desenvolvida em torno dele. Na verdade, a melhor maneira de se lembrar da semântica da palavra-chave volátil é se lembrar desse padrão, em vez de tentar memorizar as regras abstratas explicadas anteriormente neste artigo.
Vamos começar com o código de exemplo na Figura 7. A classe DataInit na Figura 7 tem dois métodos, Init e Print; ambos podem ser chamados de vários threads. Se nenhuma operação de memória for reordenada, Print poderá imprimir somente "Não inicializado" ou "42", mas há dois casos possíveis em que Print poderia imprimir um "0".
- Write 1 e Write 2 foram reordenados.
- Read 1 e Read 2 foram reordenados.
Figura 7 Usando a palavra-chave volátil
public class DataInit { private int _data = 0; private volatile bool _initialized = false; void Init() { _data = 42; // Write 1 _initialized = true; // Write 2 } void Print() { if (_initialized) { // Read 1 Console.WriteLine(_data); // Read 2 } else { Console.WriteLine("Not initialized"); } } }
Se _initialized não fosse marcado como volátil, ambas as reordenações seriam permitidas. No entanto, quando _initialized é marcado como volátil, nenhuma reordenação é permitida! No caso de escritas, você tem uma escrita comum seguida por uma escrita volátil, e uma escrita volátil não pode ser reordenada com uma operação de memória anterior. No caso das leituras, você tem uma leitura volátil seguida por uma leitura comum, e uma leitura volátil não pode ser reordenada com uma operação de memória subsequente.
Desse modo, Print nunca imprimirá "0", mesmo se chamado simultaneamente com Init em uma nova instância de DataInit.
Observe que se o campo _data fosse volátil, mas _initialized não fosse, ambas as reordenações seriam permitidas. Consequentemente, lembrar-se desse exemplo é uma excelente maneira de se lembrar da semântica volátil.
Inicialização lenta Uma variante comum de publicação pelo campo volátil é a inicialização lenta. O exemplo na Figura 8 ilustra a inicialização lenta.
Figura 8 Inicialização lenta
class BoxedInt { public int Value { get; set; } } class LazyInit { volatile BoxedInt _box; public int LazyGet() { var b = _box; // Read 1 if (b == null) { lock(this) { b = new BoxedInt(); b.Value = 42; // Write 1 _box = b; // Write 2 } } return b.Value; // Read 2 } }
Nesse exemplo, LazyGet sempre garante o retorno de "42". No entanto, se o campo _box não fosse volátil, LazyGet poderia retornar "0" por dois motivos: as leituras poderiam ser reordenadas ou as escritas poderiam ser reordenadas.
Para enfatizar ainda mais o ponto, considere esta classe:
class BoxedInt2 { public readonly int _value = 42; void PrintValue() { Console.WriteLine(_value); } }
Agora é possível, pelo menos na teoria, que PrintValue imprima "0" devido a um problema do modelo de memória. Veja um exemplo de uso de BoxeInt que o permite:
class Tester { BoxedInt2 _box = null; public void Set() { _box = new BoxedInt2(); } public void Print() { var b = _box; if (b != null) b.PrintValue(); } }
Como a instância BoxeInt foi publicada incorretamente (por meio de um campo não volátil, _box), o thread que chama Print pode observar um objeto parcialmente construído! Novamente, tornar o campo _box volátil corrigiria o problema.
Barreiras de memória e operações integradas As operações integradas são operações atômicas que podem ser usadas, às vezes, para reduzir o bloqueio em um programa multi-threaded. Considere essa classe simples de contador thread-safe:
class Counter { private int _value = 0; private object _lock = new object(); public int Increment() { lock (_lock) { _value++; return _value; } } }
Usando Interlocked.Increment, você pode reescrever o programa desta forma:
class Counter { private int _value = 0; public int Increment() { return Interlocked.Increment(ref _value); } }
Conforme reescrito com Interlocked.Increment, o método deverá ser executado de modo mais rápido, pelo menos, em algumas arquiteturas. Além das operações de incremento, a classe Interlocked (bit.ly/RksCMF) expõe métodos de várias operações atômicas: adição de um valor, substituição condicional de um valor, substituição de um valor e retorno do valor original, etc.
Todos os métodos Interlocked têm uma propriedade bastante interessante: Eles não podem ser reordenados com outras operações de memória. Assim, nenhuma operação de memória, seja antes ou depois de uma operação Interlocked, pode passar uma operação Interlocked.
Uma operação que está intimamente relacionada aos métodos Interlocked é Thread.MemoryBarrier, que pode ser pensada como uma operação Interlocked fictícia. Assim como um método Interlocked, Thread.MemoryBarrier não pode ser reordenada com nenhuma operação de memória anterior ou subsequente. No entanto, diferentemente de um método Interlocked, Thread.MemoryBarrier não tem efeito colateral, ela simplesmente restringe as reordenações de memória.
Loop de sondagem O loop de sondagem é um padrão que geralmente não é recomendado, mas que, de certa forma infelizmente, é usado com frequência na prática. A Figura 9 mostra um loop de sondagem quebrado.
Figura 9 Loop de sondagem quebrado
class PollingLoopExample { private bool _loop = true; public static void Main() { PollingLoopExample test1 = new PollingLoopExample(); // Set _loop to false on another thread new Thread(() => { test1._loop = false;}).Start(); // Poll the _loop field until it is set to false while (test1._loop) ; // The previous loop may never terminate } }
Nesse exemplo, os principais loops de thread sondam um campo não volátil específico. Um thread auxiliar define o campo nesse intervalo, mas o thread principal nunca pode ver o valor atualizado.
Agora, e se o campo _loop fosse marcado como volátil? Isso corrigiria o programa? O consenso geral dos especialistas parece ser de que o compilador não pode suspender um campo volátil lido de um loop, mas ele será contestável se a especificação ECMA para C# garantir isso.
Por um lado, a especificação determina que os campos voláteis apenas obedecem a semântica de aquisição-liberação, que não parece ser suficiente para impedir a suspensão de um campo volátil. Por outro lado, o código de exemplo na especificação não sonda, de fato, um campo volátil, indicando que o campo volátil lido não pode ser suspenso do loop.
Nas arquiteturas x86 e x64, PollingLoopExample.Main normalmente é interrompido. O compilador JIT lerá o campo test1._loop apenas uma vez, salva o valor em um registro e executa loop até que o valor do registro mude, o que, obviamente, nunca acontecerá.
No entanto, se o corpo do loop contiver algumas instruções, o compilador JIT provavelmente precisará do registro para alguma outra finalidade, de modo que cada iteração pode acabar relendo test1._loop. Consequentemente, você pode acabar vendo loops nos programas existentes que sondam um campo não volátil e, ainda assim, funcionam.
Primitivos de simultaneidade Muitos códigos simultâneos podem se beneficiar dos primitivos de simultaneidade de alto nível que foram disponibilizados no .NET Framework 4. A Figure 10 lista alguns dos primitivos de simultaneidade do .NET.
Figura 10 Primitivos de simultaneidade no .NET Framework 4
Tipo | Descrição |
Lazy<> | Valores inicializados lentamente |
LazyInitializer | |
BlockingCollection<> | Conjuntos de thread-safe |
ConcurrentBag<> | |
ConcurrentDictionary<,> | |
ConcurrentQueue<> | |
ConcurrentStack<> | |
AutoResetEvent | Primitivos para coordenar a execução de threads diferentes |
Barrier | |
CountdownEvent | |
ManualResetEventSlim | |
Monitor | |
SemaphoreSlim | |
ThreadLocal<> | Contêiner que mantém um valor separado para cada thread |
Ao usar esses primitivos, muitas vezes você pode evitar código de nível baixo que depende do modelo de memória de maneiras confusas (pelo campo volátil e similares).
Em breve
Até aqui, descrevi o modelo de memória C# conforme definido na especificação ECMA para C# e discuti os padrões mais importantes da comunicação de thread que definem o modelo de memória.
A segunda parte deste artigo explicará como o modelo de memória é realmente implementado em diferentes arquiteturas, o que é útil para entender o comportamento dos programas no mundo real.
Práticas recomendadas
- Todo código que você escreve deve depender somente das garantias feitas pela especificação ECMA para C#, e não de alguns detalhes da implementação explicados neste artigo.
- Evite o uso desnecessário de campos voláteis. Na maior parte dos casos, os bloqueios ou conjuntos simultâneos (System.Collections.Concurrent.*) são mais apropriados para a troca de dados entre os threads. Em alguns casos, os campos voláteis podem ser usados para otimizar o código simultâneo, mas você deve usar medidas de desempenho para validar se o benefício vale mais que a complexidade extra.
- Em vez de você mesmo implementar o padrão de inicialização lenta usando um campo volátil, use os tipos System.Lazy<T> e System.Threading.LazyInitializer.
- Evite loops de sondagem. Muitas vezes, você pode usar um BlockingCollection<T>, Monitor.Wait/Pulse, eventos ou programação assíncrona, em vez de um loop de sondagem.
- Sempre que possível, use os primitivos de simultaneidade padrão do .NET, em vez de implementar você mesmo a funcionalidade equivalente.
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 Microsoft .NET Framework. Ostrovsky bloga tópicos de programação em igoro.com.
Agradecemos ao seguinte especialista técnico pela revisão deste artigo: Joe Duffy, Eric Eilebrecht, Joe Hoag, Emad Omara, Grant Richins, Jaroslav Sevcik e Stephen Toub