Escrevendo aplicativos .NET Framework grandes e responsivos
Este artigo fornece dicas para melhorar o desempenho de grandes aplicativos .NET Framework ou aplicativos que processam uma grande quantidade de dados, como arquivos ou bancos de dados. Essas dicas vêm da reescrita dos compiladores C# e Visual Basic em código gerenciado, e este artigo inclui vários exemplos reais do compilador C#.
O .NET Framework é altamente produtivo para a criação de aplicativos. Linguagens poderosas e seguras e uma rica coleção de bibliotecas tornam a criação de aplicativos altamente frutífera. No entanto, com grande produtividade vem a responsabilidade. Você deve usar todo o poder do .NET Framework, mas esteja preparado para ajustar o desempenho do seu código quando necessário.
Por que o novo desempenho do compilador se aplica ao seu aplicativo
A equipe da .NET Compiler Platform ("Roslyn") reescreveu os compiladores C# e Visual Basic em código gerenciado para fornecer novas APIs para modelagem e análise de código, criação de ferramentas e habilitação de experiências muito mais ricas e sensíveis a código no Visual Studio. Reescrever os compiladores e criar experiências do Visual Studio nos novos compiladores revelou informações úteis de desempenho que são aplicáveis a qualquer aplicativo .NET Framework grande ou qualquer aplicativo que processa muitos dados. Você não precisa saber sobre compiladores para aproveitar os insights e exemplos do compilador C#.
O Visual Studio usa as APIs do compilador para criar todos os recursos do IntelliSense que os usuários adoram, como colorização de identificadores e palavras-chave, listas de conclusão de sintaxe, rabiscos para erros, dicas de parâmetros, problemas de código e ações de código. O Visual Studio fornece essa ajuda enquanto os desenvolvedores estão digitando e alterando seu código, e o Visual Studio deve permanecer responsivo enquanto o compilador modela continuamente o código que os desenvolvedores editam.
Quando os usuários finais interagem com seu aplicativo, eles esperam que ele seja responsivo. A digitação ou o tratamento de comandos nunca devem ser bloqueados. A ajuda deve aparecer rapidamente ou desistir se o usuário continuar digitando. Seu aplicativo deve evitar bloquear o thread da interface do usuário com cálculos longos que fazem com que o aplicativo pareça lento.
Para obter mais informações sobre compiladores Roslyn, consulte The .NET Compiler Platform SDK.
Apenas os factos
Considere esses fatos ao ajustar o desempenho e criar aplicativos .NET Framework responsivos.
Fato 1: Otimizações prematuras nem sempre valem a pena
Escrever código que é mais complexo do que precisa ser incorre em custos de manutenção, depuração e polimento. Programadores experientes têm uma compreensão intuitiva de como resolver problemas de codificação e escrever código mais eficiente. No entanto, às vezes otimizam prematuramente seu código. Por exemplo, eles usam uma tabela de hash quando uma matriz simples seria suficiente ou usam cache complicado que pode vazar memória em vez de simplesmente recalcular valores. Mesmo que você seja um programador experiente, você deve testar o desempenho e analisar seu código quando encontrar problemas.
Fato 2: Se você não está medindo, você está adivinhando
Perfis e medidas não mentem. Os perfis mostram se a CPU está totalmente carregada ou se você está bloqueado na E/S do disco. Os perfis informam que tipo e quanta memória você está alocando e se sua CPU está gastando muito tempo na coleta de lixo (GC).
Você deve definir metas de desempenho para as principais experiências ou cenários do cliente em seu aplicativo e escrever testes para medir o desempenho. Investigue testes com falha aplicando o método científico: use perfis para guiá-lo, hipotetize qual pode ser o problema e teste sua hipótese com um experimento ou alteração de código. Estabeleça medições de desempenho da linha de base ao longo do tempo com testes regulares, para que você possa isolar as alterações que causam regressões no desempenho. Ao abordar o trabalho de desempenho de forma rigorosa, você evitará perder tempo com atualizações de código de que não precisa.
Facto 3: Boas ferramentas fazem toda a diferença
Boas ferramentas permitem que você analise rapidamente os maiores problemas de desempenho (CPU, memória ou disco) e ajudam a localizar o código que causa esses gargalos. A Microsoft fornece uma variedade de ferramentas de desempenho, como Visual Studio Profiler e PerfView.
O PerfView é uma ferramenta poderosa que ajuda você a se concentrar em problemas profundos, como E/S de disco, eventos GC e memória. Você pode capturar eventos de Rastreamento de Eventos para Windows (ETW) relacionados ao desempenho e exibir facilmente por aplicativo, por processo, por pilha e por informações de thread. O PerfView mostra quanto e que tipo de memória seu aplicativo aloca, e quais funções ou pilhas de chamadas contribuem quanto para as alocações de memória. Para obter detalhes, consulte os tópicos de ajuda avançados, demonstrações e vídeos incluídos na ferramenta.
Facto 4: Tudo tem a ver com alocações
Você pode pensar que a criação de um aplicativo .NET Framework responsivo tem tudo a ver com algoritmos, como usar classificação rápida em vez de classificação por bolhas, mas esse não é o caso. O maior fator na criação de um aplicativo responsivo é a alocação de memória, especialmente quando seu aplicativo é muito grande ou processa grandes quantidades de dados.
Quase todo o trabalho para criar experiências IDE responsivas com as novas APIs do compilador envolveu evitar alocações e gerenciar estratégias de cache. Os rastreamentos PerfView mostram que o desempenho dos novos compiladores C# e Visual Basic raramente está vinculado à CPU. Os compiladores podem ser ligados a E/S ao ler centenas de milhares ou milhões de linhas de código, ler metadados ou emitir código gerado. Os atrasos no thread da interface do usuário são quase todos devido à coleta de lixo. O .NET Framework GC é altamente ajustado para desempenho e faz grande parte de seu trabalho simultaneamente enquanto o código do aplicativo é executado. No entanto, uma única alocação pode desencadear uma coleção gen2 cara, interrompendo todos os threads.
Dotações comuns e exemplos
As expressões de exemplo nesta seção têm alocações ocultas que parecem pequenas. No entanto, se um aplicativo grande executar as expressões vezes suficientes, elas podem causar centenas de megabytes, até gigabytes, de alocações. Por exemplo, testes de um minuto que simularam a digitação de um desenvolvedor no editor alocaram gigabytes de memória e levaram a equipe de desempenho a se concentrar em cenários de digitação.
Boxe
O boxe ocorre quando tipos de valor que normalmente vivem na pilha ou em estruturas de dados são encapsulados em um objeto. Ou seja, você aloca um objeto para armazenar os dados e, em seguida, retorna um ponteiro para o objeto. O .NET Framework às vezes caixas valores devido à assinatura de um método ou o tipo de um local de armazenamento. Encapsular um tipo de valor em um objeto causa alocação de memória. Muitas operações de boxe podem contribuir com megabytes ou gigabytes de alocações para seu aplicativo, o que significa que seu aplicativo causará mais GCs. O .NET Framework e os compiladores de linguagem evitam o boxe quando possível, mas às vezes isso acontece quando você menos espera.
Para ver o boxe no PerfView, abra um rastreamento e examine GC Heap Alloc Stacks sob o nome do processo do seu aplicativo (lembre-se, o PerfView relata todos os processos). Se você vir tipos como System.Int32 e System.Char em alocações, você está encaixotando tipos de valor. Escolher um desses tipos mostrará as pilhas e funções em que eles estão encaixotados.
Exemplo 1: métodos de cadeia de caracteres e argumentos de tipo de valor
Este código de exemplo ilustra o boxe potencialmente desnecessário e excessivo:
public class Logger
{
public static void WriteLine(string s) { /*...*/ }
}
public class BoxingExample
{
public void Log(int id, int size)
{
var s = string.Format("{0}:{1}", id, size);
Logger.WriteLine(s);
}
}
Esse código fornece a funcionalidade de registro, portanto, um aplicativo pode chamar a Log
função com frequência, talvez milhões de vezes. O problema é que a chamada para string.Format
resolver a Format(String, Object, Object) sobrecarga.
Essa sobrecarga requer que o .NET Framework encaixote os int
valores em objetos para passá-los para essa chamada de método. Uma correção parcial é chamar id.ToString()
e size.ToString()
passar todas as cadeias de caracteres (que são objetos) para a string.Format
chamada. A chamada ToString()
aloca uma cadeia de caracteres, mas essa alocação acontecerá de qualquer maneira dentro string.Format
do .
Você pode considerar que essa chamada básica para string.Format
é apenas concatenação de cadeia de caracteres, então você pode escrever este código em vez disso:
var s = id.ToString() + ':' + size.ToString();
No entanto, essa linha de código introduz uma alocação de boxe porque compila para Concat(Object, Object, Object). O .NET Framework deve encaixotar o literal de caractere para invocar Concat
Corrigir por exemplo 1
A correção completa é simples. Basta substituir o literal do caractere por um literal de cadeia de caracteres, que não incorre em boxe porque as cadeias de caracteres já são objetos:
var s = id.ToString() + ":" + size.ToString();
Exemplo 2: enum boxing
Este exemplo foi responsável por uma enorme quantidade de alocação nos novos compiladores C# e Visual Basic devido ao uso frequente de tipos de enumeração, especialmente em operações de pesquisa de dicionário.
public enum Color
{
Red, Green, Blue
}
public class BoxingExample
{
private string name;
private Color color;
public override int GetHashCode()
{
return name.GetHashCode() ^ color.GetHashCode();
}
}
Este problema é muito subtil. PerfView relataria isso como GetHashCode() boxing porque o método caixas a representação subjacente do tipo de enumeração, por motivos de implementação. Se você olhar atentamente no PerfView, poderá ver duas alocações de boxe para cada chamada para GetHashCode(). O compilador insere um, e o .NET Framework insere o outro.
Corrigir por exemplo 2
Você pode facilmente evitar ambas as alocações transmitindo para a representação subjacente antes de chamar GetHashCode():
((int)color).GetHashCode()
Outra fonte comum de boxe em tipos de enumeração é o Enum.HasFlag(Enum) método. O argumento passado tem HasFlag(Enum) de ser encaixotado. Na maioria dos casos, substituir chamadas para Enum.HasFlag(Enum) por um teste bit a bit é mais simples e livre de alocação.
Tenha em mente o primeiro fato de desempenho (ou seja, não otimize prematuramente) e não comece a reescrever todo o seu código dessa maneira. Esteja ciente desses custos de boxe, mas altere seu código somente depois de criar o perfil do seu aplicativo e encontrar os pontos de acesso.
Cadeias
As manipulações de cadeia de caracteres são alguns dos maiores culpados pelas alocações, e geralmente aparecem no PerfView nas cinco principais alocações. Os programas usam cadeias de caracteres para serialização, JSON e APIs REST. Você pode usar cadeias de caracteres como constantes programáticas para interoperar com sistemas quando não puder usar tipos de enumeração. Quando a criação de perfil mostrar que as cadeias de caracteres estão afetando muito o desempenho, procure chamadas para String métodos como Format, Concat, , SplitJoin, Substring, e assim por diante. Usar StringBuilder para evitar o custo de criar uma cadeia de caracteres a partir de muitas partes ajuda, mas até mesmo alocar o objeto pode se tornar um gargalo StringBuilder que você precisa gerenciar.
Exemplo 3: operações de cadeia de caracteres
O compilador C# tinha este código que escreve o texto de um comentário de documento XML formatado:
public void WriteFormattedDocComment(string text)
{
string[] lines = text.Split(new[] { "\r\n", "\r", "\n" },
StringSplitOptions.None);
int numLines = lines.Length;
bool skipSpace = true;
if (lines[0].TrimStart().StartsWith("///"))
{
for (int i = 0; i < numLines; i++)
{
string trimmed = lines[i].TrimStart();
if (trimmed.Length < 4 || !char.IsWhiteSpace(trimmed[3]))
{
skipSpace = false;
break;
}
}
int substringStart = skipSpace ? 4 : 3;
for (int i = 0; i < numLines; i++)
WriteLine(lines[i].TrimStart().Substring(substringStart));
}
else { /* ... */ }
Você pode ver que esse código faz muita manipulação de cadeia de caracteres. O código usa métodos de biblioteca para dividir linhas em cadeias de caracteres separadas, para cortar espaço em branco, para verificar se o argumento text
é um comentário de documentação XML e para extrair substrings de linhas.
Na primeira linha dentro do WriteFormattedDocComment
, a text.Split
chamada aloca uma nova matriz de três elementos como argumento toda vez que é chamada. O compilador tem que emitir código para alocar essa matriz cada vez. Isso ocorre porque o compilador não sabe se Split armazena a matriz em algum lugar onde a matriz possa ser modificada por outro código, o que afetaria chamadas posteriores para WriteFormattedDocComment
. A chamada para Split também aloca uma cadeia de caracteres para cada linha e text
aloca outra memória para executar a operação.
WriteFormattedDocComment
tem três chamadas para o TrimStart método. Dois estão em loops internos que duplicam o trabalho e as alocações. Para piorar a situação, chamar o TrimStart método sem argumentos aloca uma matriz vazia (para o params
parâmetro) além do resultado da cadeia de caracteres.
Por fim, há uma chamada para o Substring método, que geralmente aloca uma nova cadeia de caracteres.
Corrigir por exemplo 3
Ao contrário dos exemplos anteriores, pequenas edições não podem corrigir essas alocações. É preciso recuar, olhar para o problema e abordá-lo de forma diferente. Por exemplo, você notará que o argumento para WriteFormattedDocComment()
é uma cadeia de caracteres que tem todas as informações de que o método precisa, portanto, o código poderia fazer mais indexação em vez de alocar muitas cadeias de caracteres parciais.
A equipe de desempenho do compilador lidou com todas essas alocações com um código como este:
private int IndexOfFirstNonWhiteSpaceChar(string text, int start) {
while (start < text.Length && char.IsWhiteSpace(text[start])) start++;
return start;
}
private bool TrimmedStringStartsWith(string text, int start, string prefix) {
start = IndexOfFirstNonWhiteSpaceChar(text, start);
int len = text.Length - start;
if (len < prefix.Length) return false;
for (int i = 0; i < len; i++)
{
if (prefix[i] != text[start + i]) return false;
}
return true;
}
// etc...
A primeira versão do alocado uma matriz, várias substrings e uma substring cortada WriteFormattedDocComment()
junto com uma matriz vazia params
. Também verificou "///". O código revisado usa apenas indexação e não aloca nada. Ele localiza o primeiro caractere que não é espaço em branco e, em seguida, verifica caractere por caractere para ver se a cadeia de caracteres começa com "///". O novo código usa IndexOfFirstNonWhiteSpaceChar
em vez de TrimStart retornar o primeiro índice (após um índice inicial especificado) onde ocorre um caractere sem espaço em branco. A correção não está completa, mas você pode ver como aplicar correções semelhantes para uma solução completa. Aplicando essa abordagem em todo o código, você pode remover todas as alocações no WriteFormattedDocComment()
.
Exemplo 4: StringBuilder
Este exemplo usa um StringBuilder objeto. A função a seguir gera um nome de tipo completo para tipos genéricos:
public class Example
{
// Constructs a name like "SomeType<T1, T2, T3>"
public string GenerateFullTypeName(string name, int arity)
{
StringBuilder sb = new StringBuilder();
sb.Append(name);
if (arity != 0)
{
sb.Append("<");
for (int i = 1; i < arity; i++)
{
sb.Append("T"); sb.Append(i.ToString()); sb.Append(", ");
}
sb.Append("T"); sb.Append(i.ToString()); sb.Append(">");
}
return sb.ToString();
}
}
O foco está na linha que cria uma nova StringBuilder instância. O código causa uma alocação para sb.ToString()
e alocações internas dentro da StringBuilder implementação, mas você não pode controlar essas alocações se quiser o resultado da cadeia de caracteres.
Corrigir por exemplo 4
Para corrigir a alocação do objeto, armazene StringBuilder
o objeto em cache. Mesmo o armazenamento em cache de uma única instância que pode ser descartada pode melhorar significativamente o desempenho. Esta é a nova implementação da função, omitindo todo o código, exceto as novas primeiras e últimas linhas:
// Constructs a name like "MyType<T1, T2, T3>"
public string GenerateFullTypeName(string name, int arity)
{
StringBuilder sb = AcquireBuilder();
/* Use sb as before */
return GetStringAndReleaseBuilder(sb);
}
As peças-chave são as novas AcquireBuilder()
e GetStringAndReleaseBuilder()
funções:
[ThreadStatic]
private static StringBuilder cachedStringBuilder;
private static StringBuilder AcquireBuilder()
{
StringBuilder result = cachedStringBuilder;
if (result == null)
{
return new StringBuilder();
}
result.Clear();
cachedStringBuilder = null;
return result;
}
private static string GetStringAndReleaseBuilder(StringBuilder sb)
{
string result = sb.ToString();
cachedStringBuilder = sb;
return result;
}
Como os novos compiladores usam threading, essas implementações usam um campo estático de thread (ThreadStaticAttribute atributo) para armazenar em cache o StringBuilder, e você provavelmente pode renunciar à ThreadStatic
declaração. O campo thread-static contém um valor exclusivo para cada thread que executa esse código.
AcquireBuilder()
Retorna a instância armazenada em StringBuilder cache, se houver, depois de limpá-la e definir o campo ou cache como null. Caso contrário, AcquireBuilder()
cria uma nova instância e a retorna, deixando o campo ou cache definido como nulo.
Quando terminar o StringBuilder , você liga GetStringAndReleaseBuilder()
para obter o resultado da cadeia de caracteres, salva a StringBuilder instância no campo ou no cache e retorna o resultado. É possível que a execução insira novamente esse código e crie vários StringBuilder objetos (embora isso raramente aconteça). O código salva apenas a última instância liberada StringBuilder para uso posterior. Essa estratégia simples de cache reduziu significativamente as alocações nos novos compiladores. Partes do .NET Framework e MSBuild ("MSBuild") usam uma técnica semelhante para melhorar o desempenho.
Essa estratégia de cache simples adere a um bom design de cache porque tem um limite de tamanho. No entanto, há mais código agora do que no original, o que significa mais custos de manutenção. Você deve adotar a estratégia de cache somente se encontrar um problema de desempenho, e o PerfView mostrou que StringBuilder as alocações são um contribuidor significativo.
LINQ e lambdas
O LINQ (Language-Integrated Query), em conjunto com expressões lambda, é um exemplo de recurso de produtividade. No entanto, seu uso pode ter um impacto significativo no desempenho ao longo do tempo, e você pode achar que precisa reescrever seu código.
Exemplo 5: Lambdas, Lista<T> e IEnumerable<T>
Este exemplo usa LINQ e código de estilo funcional para localizar um símbolo no modelo do compilador, com uma cadeia de caracteres de nome:
class Symbol {
public string Name { get; private set; }
/*...*/
}
class Compiler {
private List<Symbol> symbols;
public Symbol FindMatchingSymbol(string name)
{
return symbols.FirstOrDefault(s => s.Name == name);
}
}
O novo compilador e as experiências IDE construídas nele chamam FindMatchingSymbol()
com muita frequência, e há várias alocações ocultas na única linha de código dessa função. Para examinar essas alocações, primeiro divida a única linha de código da função em duas linhas:
Func<Symbol, bool> predicate = s => s.Name == name;
return symbols.FirstOrDefault(predicate);
Na primeira linha, a expressão s => s.Name == name
lambda fecha sobre a variável name
local. Isso significa que, além de alocar um objeto para o delegado que predicate
retém, o código aloca uma classe estática para manter o ambiente que captura o valor de name
. O compilador gera código como o seguinte:
// Compiler-generated class to hold environment state for lambda
private class Lambda1Environment
{
public string capturedName;
public bool Evaluate(Symbol s)
{
return s.Name == this.capturedName;
}
}
// Expanded Func<Symbol, bool> predicate = s => s.Name == name;
Lambda1Environment l = new Lambda1Environment() { capturedName = name };
var predicate = new Func<Symbol, bool>(l.Evaluate);
As duas new
alocações (uma para a classe de ambiente e outra para o delegado) estão explícitas agora.
Agora olhe para a chamada para FirstOrDefault
. Este método de extensão no System.Collections.Generic.IEnumerable<T> tipo também incorre em uma alocação. Como FirstOrDefault
usa um IEnumerable<T> objeto como seu primeiro argumento, você pode expandir a chamada para o seguinte código (simplificado um pouco para discussão):
// Expanded return symbols.FirstOrDefault(predicate) ...
IEnumerable<Symbol> enumerable = symbols;
IEnumerator<Symbol> enumerator = enumerable.GetEnumerator();
while(enumerator.MoveNext())
{
if (predicate(enumerator.Current))
return enumerator.Current;
}
return default(Symbol);
A symbols
variável tem o tipo List<T>. O List<T> tipo de coleção implementa IEnumerable<T> e define inteligentemente um enumerador (IEnumerator<T> interface) que List<T> implementa com um struct
arquivo . Usar uma estrutura em vez de uma classe significa que você geralmente evita quaisquer alocações de heap, o que, por sua vez, pode afetar o desempenho da coleta de lixo. Os enumeradores são normalmente usados com o loop da foreach
linguagem, que usa a estrutura do enumerador à medida que é retornada na pilha de chamadas. Incrementar o ponteiro da pilha de chamadas para abrir espaço para um objeto não afeta o GC da mesma forma que uma alocação de heap.
No caso da chamada expandida FirstOrDefault
, o código precisa chamar GetEnumerator()
um IEnumerable<T>arquivo . A atribuição symbols
à enumerable
variável do tipo IEnumerable<Symbol>
perde a informação de que o objeto real é um List<T>arquivo . Isso significa que quando o código busca o enumerador com enumerable.GetEnumerator()
, o .NET Framework tem que encaixotar a estrutura retornada para atribuí-lo à enumerator
variável.
Corrigir por exemplo 5
A correção é reescrever FindMatchingSymbol
da seguinte forma, substituindo sua única linha de código por seis linhas de código que ainda são concisas, fáceis de ler e entender e fáceis de manter:
public Symbol FindMatchingSymbol(string name)
{
foreach (Symbol s in symbols)
{
if (s.Name == name)
return s;
}
return null;
}
Esse código não usa métodos de extensão LINQ, lambdas ou enumeradores e não incorre em alocações. Não há alocações porque o compilador pode ver que a symbols
coleção é um List<T> e pode vincular o enumerador resultante (uma estrutura) a uma variável local com o tipo certo para evitar boxing. A versão original dessa função era um ótimo exemplo do poder expressivo do C# e da produtividade do .NET Framework. Esta nova versão, mais eficiente, preserva essas qualidades sem adicionar nenhum código complexo para manter.
Cache do método assíncrono
O próximo exemplo mostra um problema comum quando você tenta usar resultados armazenados em cache em um método assíncrono .
Exemplo 6: armazenamento em cache em métodos assíncronos
Os recursos do IDE do Visual Studio criados nos novos compiladores C# e Visual Basic freqüentemente buscam árvores de sintaxe, e os compiladores usam assíncrono ao fazer isso para manter o Visual Studio responsivo. Aqui está a primeira versão do código que você pode escrever para obter uma árvore de sintaxe:
class SyntaxTree { /*...*/ }
class Parser { /*...*/
public SyntaxTree Syntax { get; }
public Task ParseSourceCode() { /*...*/ }
}
class Compilation { /*...*/
public async Task<SyntaxTree> GetSyntaxTreeAsync()
{
var parser = new Parser(); // allocation
await parser.ParseSourceCode(); // expensive
return parser.Syntax;
}
}
Você pode ver que chamar GetSyntaxTreeAsync()
instancia um Parser
, analisa o código e, em seguida, retorna um Task objeto, Task<SyntaxTree>
. A parte cara é alocar a Parser
instância e analisar o código. A função retorna um Task para que os chamadores possam aguardar o trabalho de análise e liberar o thread da interface do usuário para responder à entrada do usuário.
Vários recursos do Visual Studio podem tentar obter a mesma árvore de sintaxe, portanto, você pode escrever o código a seguir para armazenar em cache o resultado da análise para economizar tempo e alocações. No entanto, este código incorre numa atribuição:
class Compilation { /*...*/
private SyntaxTree cachedResult;
public async Task<SyntaxTree> GetSyntaxTreeAsync()
{
if (this.cachedResult == null)
{
var parser = new Parser(); // allocation
await parser.ParseSourceCode(); // expensive
this.cachedResult = parser.Syntax;
}
return this.cachedResult;
}
}
Você vê que o novo código com cache tem um SyntaxTree
campo chamado cachedResult
. Quando esse campo é nulo, GetSyntaxTreeAsync()
faz o trabalho e salva o resultado no cache. GetSyntaxTreeAsync()
retorna o SyntaxTree
objeto. O problema é que quando você tem uma async
função do tipo Task<SyntaxTree>
, e você retorna um valor do tipo SyntaxTree
, o compilador emite código para alocar uma tarefa para manter o resultado (usando Task<SyntaxTree>.FromResult()
). A Tarefa é marcada como concluída e o resultado fica imediatamente disponível. No código para os novos compiladores, Task os objetos que já estavam concluídos ocorriam com tanta frequência que a correção dessas alocações melhorava visivelmente a capacidade de resposta.
Corrigir por exemplo 6
Para remover a alocação concluída Task , você pode armazenar em cache o objeto Task com o resultado concluído:
class Compilation { /*...*/
private Task<SyntaxTree> cachedResult;
public Task<SyntaxTree> GetSyntaxTreeAsync()
{
return this.cachedResult ??
(this.cachedResult = GetSyntaxTreeUncachedAsync());
}
private async Task<SyntaxTree> GetSyntaxTreeUncachedAsync()
{
var parser = new Parser(); // allocation
await parser.ParseSourceCode(); // expensive
return parser.Syntax;
}
}
Esse código altera o tipo de cachedResult
para Task<SyntaxTree>
e emprega uma async
função auxiliar que contém o código original de GetSyntaxTreeAsync()
. GetSyntaxTreeAsync()
agora usa o operador coalescing nulo para retornar cachedResult
se não for null. Se cachedResult
for null, então GetSyntaxTreeAsync()
chama GetSyntaxTreeUncachedAsync()
e armazena em cache o resultado. Observe que GetSyntaxTreeAsync()
não aguarda a chamada como GetSyntaxTreeUncachedAsync()
o código faria normalmente. Não usar await significa que, quando GetSyntaxTreeUncachedAsync()
retorna seu Task objeto, GetSyntaxTreeAsync()
retorna imediatamente o Taskarquivo . Agora, o resultado armazenado em cache é um Task, portanto, não há alocações para retornar o resultado armazenado em cache.
Considerações adicionais
Aqui estão mais alguns pontos sobre possíveis problemas em aplicativos grandes ou aplicativos que processam muitos dados.
Dicionários
Os dicionários são usados em muitos programas, embora os dicionários sejam muito convenientes e inerentemente eficientes. No entanto, muitas vezes são usados de forma inadequada. No Visual Studio e nos novos compiladores, a análise mostra que muitos dos dicionários continham um único elemento ou estavam vazios. Um vazio Dictionary<TKey,TValue> tem dez campos e ocupa 48 bytes na pilha em uma máquina x86. Os dicionários são ótimos quando você precisa de um mapeamento ou estrutura de dados associativa com pesquisa em tempo constante. No entanto, quando você tem apenas alguns elementos, você desperdiça muito espaço usando um dicionário. Em vez disso, por exemplo, você pode olhar iterativamente através de um List<KeyValuePair\<K,V>>
, com a mesma rapidez. Se você usar um dicionário apenas para carregá-lo com dados e, em seguida, ler a partir dele (um padrão muito comum), usar uma matriz ordenada com uma pesquisa N(log(N)) pode ser quase tão rápido, dependendo do número de elementos que você está usando.
Classes vs. estruturas
De certa forma, as classes e estruturas fornecem uma troca clássica de espaço/tempo para ajustar seus aplicativos. As classes incorrem em 12 bytes de sobrecarga em uma máquina x86, mesmo que não tenham campos, mas são baratas de passar porque basta um ponteiro para se referir a uma instância de classe. As estruturas não incorrem em alocações de heap se não estiverem encaixotadas, mas quando você passa grandes estruturas como argumentos de função ou valores de retorno, leva tempo da CPU para copiar atomicamente todos os membros de dados das estruturas. Esteja atento a chamadas repetidas para propriedades que retornam estruturas e armazene em cache o valor da propriedade em uma variável local para evitar cópias excessivas de dados.
Caches
Um truque de desempenho comum é armazenar os resultados em cache. No entanto, um cache sem um limite de tamanho ou política de descarte pode ser um vazamento de memória. Ao processar grandes quantidades de dados, se você retiver muita memória em caches, poderá fazer com que a coleta de lixo substitua os benefícios de suas pesquisas em cache.
Neste artigo, discutimos como você deve estar ciente dos sintomas de gargalo de desempenho que podem afetar a capacidade de resposta do seu aplicativo, especialmente para sistemas grandes ou sistemas que processam uma grande quantidade de dados. Os culpados comuns incluem boxe, manipulações de string, LINQ e lambda, cache em métodos assíncronos, cache sem limite de tamanho ou política de eliminação, uso inadequado de dicionários e passagem de estruturas. Lembre-se dos quatro fatos para ajustar seus aplicativos:
Não otimize prematuramente – seja produtivo e ajuste seu aplicativo quando detetar problemas.
Os perfis não mentem – você está adivinhando se não está medindo.
Boas ferramentas fazem toda a diferença – baixe o PerfView e experimente.
É tudo sobre alocações – é onde a equipe da plataforma de compiladores passou a maior parte do tempo melhorando o desempenho dos novos compiladores.