Nota
O acesso a esta página requer autorização. Pode tentar iniciar sessão ou alterar os diretórios.
O acesso a esta página requer autorização. Pode tentar alterar os diretórios.
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 utilizam uma técnica de cache complexa que pode causar vazamento de 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 na UI são quase todos devidos à recolha 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 um número suficiente de vezes, isso pode resultar em 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 encaixotamento de tipos de valor que normalmente residem na pilha ou em estruturas de dados ocorre quando são transformados em objetos. Ou seja, você aloca um objeto para armazenar os dados e, em seguida, retorna um ponteiro para o objeto. O .NET Framework às vezes encaixa valores devido à assinatura de um método ou ao tipo de uma localização 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 a sua aplicação, o que significa que a sua aplicação causará mais GCs. O .NET Framework e os compiladores de linguagem evitam o encapsulamento sempre que possível, mas por vezes ocorre quando menos se espera.
Para ver boxes no PerfView, abra um rastreamento de execução e examine GC Heap Alloc Stacks no contexto do nome do processo da sua aplicação (lembre-se, o PerfView relata todos os processos). Se vires tipos como System.Int32 e System.Char em alocações, estás a encaixotar tipos de valor. Escolher um desses tipos mostrará as pilhas e funções nas quais estão encapsulados.
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 resolve-se à sobrecarga Format(String, Object, Object).
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() e passar todas as cadeias de caracteres (que são objetos) para a chamada string.Format. A chamada a ToString() aloca uma string, mas essa alocação também acontecerá dentro de string.Format.
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
Correção para o 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 encaixa a representação subjacente do tipo de enumeração, por razões 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.
Correção para o exemplo 2
Você pode facilmente evitar essas alocações convertendo 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 a HasFlag(Enum) tem de ser encaixotado. Na maioria das vezes, substituir chamadas para Enum.HasFlag(Enum) por um teste bit a bit é mais simples e sem 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 strings estão entre os principais culpados pelas alocações de memória, e frequentemente aparecem no PerfView entre as 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 análise de perfil mostrar que as cadeias de caracteres estão afetando muito o desempenho, procure por chamadas de métodos como String, Format, Concat, Split, Join, Substring, e assim por diante. Usar StringBuilder para evitar o custo de criar uma string a partir de muitas partes ajuda, mas até mesmo alocar o objeto StringBuilder pode se tornar um gargalo 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 chamada text.Split aloca uma nova matriz de três elementos como argumento cada 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 string para cada linha em text e 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.
Correção para o 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 de WriteFormattedDocComment() alocou um array, várias substrings e uma substring ajustada junto com um array vazio params. Também efetuou verificação quanto a "///". 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 provoca uma alocação para sb.ToString() e alocações internas dentro da implementação de StringBuilder, mas não podes controlar essas alocações se desejas obter o resultado em string.
Correção para o 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 utilizam threading, estas implementações empregam um campo thread-estático (ThreadStaticAttribute atributo) para armazenar em cache o StringBuilder, e provavelmente poderás abdicar da declaração de ThreadStatic. 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 termina o StringBuilder, chama GetStringAndReleaseBuilder() para obter o resultado da string, guarda a instância StringBuilder no campo ou no cache e devolve 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, List<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 lambda captura a variável local name. 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 tipo System.Collections.Generic.IEnumerable<T> também incorre em 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 (interface de IEnumerator<T>) que List<T> implementa com um struct. 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 foreach da linguagem, que utiliza 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() em um IEnumerable<T>. A atribuição symbols para a enumerable variável do tipo IEnumerable<Symbol> perde a informação de o objeto real ser um List<T>. 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 o 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 o 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 de coalescência nula para retornar cachedResult se não for nulo. Se cachedResult for null, então GetSyntaxTreeAsync() chama GetSyntaxTreeUncachedAsync() e armazena em cache o resultado. Observe que GetSyntaxTreeAsync() não aguarda pela chamada para GetSyntaxTreeUncachedAsync() como o código normalmente faria. Não usar await significa que, quando GetSyntaxTreeUncachedAsync() devolve o objeto Task, GetSyntaxTreeAsync() retorna imediatamente o Task. 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 no heap numa 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 de memória numa máquina x86, mesmo que não tenham campos, mas são económicas de manipular 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.
Cache
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 encaixotamento, manipulações de strings, uso de LINQ e expressões lambda, armazenamento em cache em métodos assíncronos, armazenamento em cache sem limite de tamanho ou política de eliminação, uso inadequado de dicionários e passagem de estruturas de dados. 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.