Compartilhar via


Escrevendo aplicativos .NET Framework grandes e dinâmicos

Este artigo apresenta dicas para melhorar o desempenho de grandes aplicativos do .NET Framework ou aplicativos que processam um grande volume de dados, como arquivos ou bancos de dados. Essas dicas vêm da reescrita de 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 compilar aplicativos. Linguagens eficientes e seguras e uma coleção sofisticada de bibliotecas tornam a compilação de aplicativos altamente produtiva. Porém, grande produtividade traz muita responsabilidade. Você deve usar toda a potência do .NET Framework, mas esteja preparado para ajustar o desempenho do código quando necessário.

Por que o desempenho do novo compilador se aplica ao seu aplicativo

A equipe do .NET Compiler Platform ("Roslyn") reescreveu os compiladores do C# e do Visual Basic em código gerenciado para fornecer novas APIs para modelar e analisar códigos, compilar ferramentas e possibilitar experiências de código mais sofisticadas no Visual Studio. Reescrever os compiladores e desenvolver experiências no Visual Studio com os novos compiladores revelaram informações úteis sobre o desempenho, aplicáveis a qualquer aplicativo grande do .NET Framework ou qualquer aplicativo que processe muitos dados. Não é preciso ter conhecimento de compiladores para aproveitar as informações e os exemplos do compilador do C#.

O Visual Studio usa as APIs do compilador para compilar todos os recursos do IntelliSense adorados pelos usuários, como colorização de identificadores e palavras-chave, listas de conclusão de sintaxe, soluções para erros, dicas de parâmetro, problemas de código e ações de código. O Visual Studio oferece essa ajuda enquanto os desenvolvedores estão digitando e alterando seus códigos e ele deve continuar respondendo enquanto o compilador modela continuamente a edição do desenvolvedor do código.

Ao interagir com o aplicativo, os usuários finais esperam que ele seja ágil na resposta. A digitação ou a manipulação de comandos jamais deve ser bloqueada. A ajuda deveria aparecer rapidamente ou parar se o usuário continuar digitando. O aplicativo deve evitar bloquear o thread da interface do usuário com computações longas, que tornam o aplicativo lento.

Para obter mais informações sobre compiladores Roslyn, consulte O SDK do .NET Compiler Platform.

Apenas os Fatos

Considere estes fatos ao ajustar o desempenho e criar aplicativos do .NET Framework ágeis na resposta.

Fato 1: otimizações prematuras nem sempre valem o incômodo

Escrever um código mais complexo do que o necessário traz custos de manutenção, depuração e refinamento. Os programadores experientes têm uma compreensão intuitiva de como resolver problemas de codificação e gravar um código mais eficiente. Porém, às vezes, eles otimizam o código antes. Por exemplo, eles usam uma tabela hash quando uma simples matriz bastaria ou usam um cache complicado que pode causar perda de memória, em vez de simplesmente recalcular os valores. Mesmo que não seja um programador experiente, você deve testar o desempenho e analisar o código quando encontrar problemas.

Fato 2: se você não está medindo, está adivinhando

Os perfis e as medidas não mentem. Os perfis mostram se a CPU está totalmente carregada ou se há um bloqueio na E/S do disco. Os perfis informam o tipo e a quantidade de memória que você está alocando e se a CPU está gastando muito tempo na GC (coleta de lixo).

Estabeleça metas de desempenho para experiências ou cenários importantes do cliente no aplicativo e gravar testes para avaliar o desempenho. Investigue testes com falha aplicando o método científico: use perfis para orientá-lo, crie hipóteses sobre qual seria o problema e teste as hipóteses com um experimento ou uma alteração feita no código. Estabeleça medidas de desempenho de linha de base com o passar do tempo, usando testes regulares para que seja possível isolar as alterações que causam regressões no desempenho. Abordando o trabalho de desempenho de maneira rigorosa, você evitará a perda de tempo com atualizações desnecessárias de código.

Fato 3: boas ferramentas fazem toda a diferença

As boas ferramentas permitem chegar rapidamente aos maiores problemas de desempenho (CPU, memória ou disco) e ajudam a alocar o código que causa esses gargalos. A Microsoft fornece várias ferramentas de desempenho, como o Visual Studio Profiler e o PerfView.

O PerfView é uma ferramenta poderosa que ajuda você a se concentrar em problemas profundos, como E/S de disco, eventos de GC e memória. Você pode capturar eventos relacionados ao desempenho por meio do Rastreamento de Eventos para Windows (ETW) e exibir informações por aplicativo, processo, pilha e thread com facilidade. O PerfView mostra quanto e que tipo de memória o aplicativo aloca, além de quais funções ou pilhas de chamadas contribuem para a quantidade de alocações da memória. Para obter detalhes, consulte os tópicos avançados da ajuda, demonstrações e vídeos incluídos na ferramenta.

Fato 4: é tudo uma questão de alocação

Você poderia pensar que construir um aplicativo responsivo do .NET Framework depende de algoritmos, como usar quick sort em vez de bubble sort, mas não é esse o caso. O maior fator no desenvolvimento de um aplicativo responsivo é a alocação de memória, especialmente quando seu aplicativo tem grande extensão ou manipula grandes volumes de dados.

Praticamente todo o trabalho para construir experiências IDE responsivas com as novas APIs do compilador envolveu evitar alocações e gerenciar estratégias de cache. Os rastreamentos do PerfView mostram que o desempenho dos novos compiladores do C# e do Visual Basic raramente está associado à CPU. Os compiladores podem ser limitados pela E/S ao ler centenas de milhares ou milhões de linhas de código, ao ler metadados ou ao emitir código gerado. Os atrasos do thread da interface do usuário são praticamente todos por conta da coleta de lixo. A GC do .NET Framework está totalmente ajustada para o desempenho e faz boa parte de seu trabalho junto com a execução do código do aplicativo. Porém, uma única alocação pode disparar uma coleta gen2 cara, interrompendo todos os threads.

Alocações e exemplos comuns

As expressões de exemplo nesta seção têm alocações ocultas aparentemente pequenas. Porém, se um aplicativo grande executar as expressões o número de vezes suficiente, elas poderão causar centenas de megabytes, até mesmo gigabytes, de alocações. Por exemplo, testes de um minuto que simulavam a digitação de um desenvolvedor no editor alocaram gigabytes de memória e permitiram que a equipe de desenvolvimento se concentrasse nos cenários de digitação.

Boxing

O Boxing 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 esse objeto. Às vezes, o .NET Framework realiza boxing de valores devido à assinatura de um método ou ao tipo de local de armazenamento. Encapsular um tipo de valor em um objeto causa alocação de memória. Muitas operações de boxing podem proporcionar megabytes ou gigabytes de alocações no aplicativo, o que significa que isso aumentará a frequência de GCs. O .NET Framework e os compiladores de linguagem evitam o "boxing" (encapsulamento de valor) sempre que possível, mas às vezes ele acontece quando você menos espera.

Para ver o boxing no PerfView, abra um rastreamento e observe GC Heap Alloc Stacks sob o nome do processo do seu aplicativo (lembre-se de que o PerfView informa sobre todos os processos). Caso veja tipos como System.Int32 e System.Char nas alocações, você está fazendo boxe dos tipos de valor. Ao escolher um desses tipos, serão mostradas as pilhas e as funções nas quais estão encapsuladas.

Exemplo 1: métodos de cadeia de caracteres e argumentos de tipo de valor

Este código de exemplo ilustra um uso possivelmente desnecessário e excessivo de boxing.

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

Como esse código oferece funcionalidade de registro em log, um aplicativo pode chamar a função Log com frequência, talvez milhões de vezes. O problema é que a chamada para o string.Format resolve-se para a sobrecarga Format(String, Object, Object).

Essa sobrecarga exige que o .NET Framework realize boxing dos valores int em objetos para passá-los a 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 de ToString() aloca uma cadeia de caracteres, mas essa alocação ainda acontecerá em string.Format.

Talvez você ache que essa chamada básica para string.Format seja apenas uma concatenação da cadeia de caracteres, dessa forma, será possível gravar esse código em seu lugar:

var s = id.ToString() + ':' + size.ToString();

Porém, essa linha de código introduz uma alocação de boxing porque é compilada para Concat(Object, Object, Object). O .NET Framework deve realizar a conversão boxing do literal de caractere para invocar Concat

Correção para o exemplo 1

A correção completa é simples. Basta substituir o literal de caractere por um literal de string, sem incorrer em boxing porque strings já são objetos.

var s = id.ToString() + ":" + size.ToString();

Exemplo 2: boxe de enum

Esse exemplo foi responsável por um grande volume de alocação nos novos compiladores do C# e do Visual Basic por conta do 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();
    }
}

Esse problema é muito sutil. O PerfView relataria isso como GetHashCode() boxing porque o método realiza boxing da representação subjacente do tipo de enumeração, por razões de implementação. Se observar atentamente no PerfView, você pode ver duas alocações de boxing para cada chamada para GetHashCode(). O compilador insere uma e o .NET Framework insere a outra.

Correção para o exemplo 2

É possível evitar facilmente as duas alocações realizando o casting para a representação subjacente antes de chamar GetHashCode():

((int)color).GetHashCode()

Outra fonte comum de conversão para 'boxing' em tipos enumeradores é o método Enum.HasFlag(Enum). O argumento passado para HasFlag(Enum) precisa passar pela conversão boxing. Na maioria das vezes, a substituição das chamadas Enum.HasFlag(Enum) por um teste bit a bit é mais simples e sem necessidade de alocação.

Mantenha o fator do primeiro desempenho em mente (ou seja, não otimize antes) e não comece a gravar novamente todo o código dessa forma. Esteja atento aos custos de boxing, mas só altere seu código após fazer o perfilamento do seu aplicativo e encontrar os pontos quentes.

Cadeias de caracteres

As manipulações da cadeia de caracteres são as maiores responsáveis pelas alocações e costumam aparecer no PerfView entre as cinco primeiras alocações. Os programas usam cadeias de caracteres na serialização, em JSON e nas APIs REST. É possível usar cadeias de caracteres como constantes programáticas na interoperação com sistemas quando não é possível usar tipos de enumeração. Quando a análise de perfis mostrar que strings estão afetando muito o desempenho, procure por chamadas aos métodos String como Format, Concat, Split, Join, Substring e assim por diante. O uso de StringBuilder para evitar o custo da criação de uma string a partir de várias partes ajuda, mas mesmo a alocação do objeto StringBuilder pode se tornar um gargalo que precisa ser gerenciado.

Exemplo 3: operações da cadeia de caracteres

O compilador do C# tinha este código que grava o texto de um comentário em documento formatado em XML:

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 { /* ... */ }

É possível 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, cortar espaços em branco, verificar se o argumento text é um comentário de documentação em XML e extrair subcadeias de caracteres das linhas.

Na primeira linha do WriteFormattedDocComment, sempre que ocorre a chamada text.Split, ela aloca uma nova matriz de três elementos como o argumento. O compilador sempre precisa emitir o código para alocar essa matriz. Isso porque o compilador não sabe se Split armazena a matriz em um lugar onde a matriz pode 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 em text e aloca outra memória para realizar a operação.

WriteFormattedDocComment tem três chamadas ao método TrimStart. Duas estão em loops internos que duplicam trabalhos e alocações. Para piorar as coisas, a chamada do método TrimStart sem argumentos aloca uma matriz vazia (para o parâmetro params) além do resultado da cadeia de caracteres.

Por fim, existe uma chamada para o método Substring, que normalmente aloca uma nova cadeia de caracteres.

Correção para o exemplo 3

Diferentemente dos exemplos anteriores, pequenas edições não podem corrigir essas alocações. É preciso voltar, observar o problema e abordá-lo de maneira diferente. Por exemplo, você verá que o argumento para WriteFormattedDocComment() é uma cadeia de caracteres com todas as informações de que o método precisa, para que o código pudesse fazer mais indexação em vez de alocar muitas cadeias de caracteres parciais.

A equipe de desempenho do compilador resolveu todas essas alocações com códigos 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() alocava um array, várias subcadeias de caracteres e uma subcadeia de caracteres cortada, juntamente com um array params vazio. Também verificou "///". O código revisado usa apenas a indexação e não aloca nada. Ele localiza o primeiro caractere diferente de espaço em branco e, a partir daí, verifica um a um para identificar se a cadeia de caracteres começa com "///". O novo código usa IndexOfFirstNonWhiteSpaceChar em vez de TrimStart para retornar o primeiro índice (após um índice inicial especificado) em que ocorre um caractere diferente de espaço em branco. A correção não está completa, mas é possível ver como aplicar correções semelhantes para uma solução completa. Aplicando essa abordagem em todo o código, é possível remover todas as alocações em WriteFormattedDocComment().

Exemplo 4: StringBuilder

Este exemplo usa um objeto StringBuilder. 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 instância StringBuilder. O código causa uma alocação para sb.ToString() e alocações internas dentro da implementação StringBuilder, mas não será possível controlar essas alocações se você quiser o resultado da cadeia de caracteres.

Correção para o exemplo 4

Para corrigir a alocação do objeto StringBuilder, armazene o objeto em cache. Mesmo o armazenamento em cache de uma única instância que pode ser descartada pode melhorar significativamente o desempenho. Essa é a nova implementação da função, omitindo todo o código, exceto as primeiras e as últimas linhas novas:

// 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 partes-chave são as novas funções AcquireBuilder() e GetStringAndReleaseBuilder():

[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 o threading, essas implementações usam um campo com thread estático (atributo ThreadStaticAttribute) para armazenar em cache o StringBuilder e você certamente pode esquecer a declaração ThreadStatic. O campo com thread estático mantém um valor exclusivo para cada thread que executa esse código.

AcquireBuilder() retornará a instância de StringBuilder armazenada em cache se houver uma, depois de limpá-la e definir o campo ou o cache como nulo. Do contrário, AcquireBuilder() cria uma nova instância e a retorna, deixando o campo ou o cache definido com nulo.

Quando concluir o StringBuilder, você poderá chamar GetStringAndReleaseBuilder() para obter o resultado da cadeia de caracteres, salvar a instância de StringBuilder no campo ou no cache e retornar o resultado. Para a execução, é possível reinserir esse código e criar vários objetos StringBuilder (embora isso raramente aconteça). O código salva apenas a última instância de StringBuilder lançada para uso posterior. Essa estratégia de cache simples reduziu significativamente as alocações nos novos compiladores. Partes do .NET Framework e do MSBuild (“MSBuild”) usam uma técnica semelhante para melhorar o desempenho.

Essa estratégia de cache simples respeita o bom design de cache porque tem um limite de tamanho. Porém, há mais código agora do que havia originalmente, o que significa mais custos com manutenção. Você só deverá adotar a estratégia de cache se tiver encontrado um problema de desempenho e o PerfView tiver mostrado que as alocações de StringBuilder são um fator significativo.

LINQ e lambdas

As expressões LINQ (Language-Integrated Query), em conjunto com as do tipo lambda, são um exemplo de recurso de produtividade. No entanto, com o tempo, seu uso pode afetar significativamente o desempenho e talvez você descubra que precisa reescrever seu código.

Exemplo 5: lambdas, List<T> e IEnumerable<T>

Esse exemplo usa o LINQ e um código de estilo funcional para localizar um símbolo no modelo do compilador, considerando 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 de IDE com base nele chamam FindMatchingSymbol() com muita frequência, além de haver diversas alocações ocultas nessa única linha de código da 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 lambdas => s.Name == namefecha a variável local name. Isso significa que, além de alocar um objeto para o representante que predicate mantém, o código aloca uma classe estática para manter o ambiente que captura o valor name. O compilador gera um código semelhante ao 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 alocações new (uma para a classe de ambiente e uma para o delegado) agora estão explícitas.

Agora observe a chamada para FirstOrDefault. Esse método de extensão do tipo System.Collections.Generic.IEnumerable<T> também acarreta uma alocação. Como FirstOrDefault utiliza um objeto IEnumerable<T> como seu primeiro argumento, é possível expandir a chamada para o seguinte código (um pouco simplificado 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 variável symbols tem o tipo List<T>. O tipo de coleção List<T> implementa IEnumerable<T> e define de maneira inteligente um enumerador (interface IEnumerator<T>) que List<T> implementa com um struct. O uso de uma estrutura de dados em vez de uma classe significa que você normalmente evita alocações no heap, o que, por sua vez, pode afetar o desempenho da coleta de lixo. Os enumeradores costumam ser usados com o loop foreach da linguagem, que utiliza a estrutura do enumerador como ela é retornada na pilha de chamadas. O incremento do ponteiro do heap de chamadas para abrir espaço a um objeto não afeta a GC, como faz uma alocação de heap.

No caso da chamada FirstOrDefault expandida, o código precisa chamar GetEnumerator() em um IEnumerable<T>. A atribuição de symbols à variável enumerable do tipo IEnumerable<Symbol> perde as informações de que o objeto real é um List<T>. Isso significa que, quando o código busca o enumerador com enumerable.GetEnumerator(), o .NET Framework precisa realizar a conversão boxing da estrutura retornada para atribuí-la à variável enumerator.

Correção para o exemplo 5

A correção é para gravar novamente FindMatchingSymbol da seguinte forma, substituindo sua linha de código única por seis linhas de código que continuam concisas, fáceis de ler, compreender e 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 acarreta alocações. Não há alocações porque o compilador pode identificar que a coleção symbols é um List<T> e consegue atrelá-la ao enumerador resultante (como uma estrutura) em uma variável local do tipo correto, evitando o boxing. A versão original dessa função era um ótimo exemplo da potência expressiva do C# e da produtividade do .NET Framework. Essa versão nova e mais eficiente preserva essas qualidades sem adicionar nenhum código complexo de manutenção.

Cache para método assíncrono

O próximo exemplo mostra um problema comum quando você tenta usar os resultados armazenados em cache em um método async.

Exemplo 6: cache em métodos assíncronos

Os recursos de IDE do Visual Studio construídos sobre os novos compiladores do C# e do Visual Basic normalmente buscam árvores de sintaxe, e os compiladores usam async ao fazer isso para manter o Visual Studio responsivo. Aqui está a primeira versão do código que você poderia escrever para obter uma árvore sintática.

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

É possível ver que a chamada de GetSyntaxTreeAsync() cria uma instância de Parser, analisa o código e retorna um objeto Task, Task<SyntaxTree>. A parte cara é alocar a instância de Parser e analisar o código. A função retorna um Task, para que os chamadores possam aguardar o trabalho de análise e liberar a thread da interface do usuário para que ela responda à entrada do usuário.

Como diversos recursos do Visual Studio podem tentar obter a mesma árvore de sintaxe, convém gravar o código a seguir para armazenar em cache o resultado da análise a fim de economizar tempo e alocações. Porém, esse código acarreta uma alocaçã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 campo SyntaxTree chamado cachedResult. Quando esse campo é nulo, GetSyntaxTreeAsync() faz o trabalho e salva o resultado no cache. GetSyntaxTreeAsync() retorna o objeto SyntaxTree. O problema é que quando você tem uma função async do tipo Task<SyntaxTree> e retorna um valor do tipo SyntaxTree, o compilador emite um código para alocar uma Tarefa e manter o resultado (usando Task<SyntaxTree>.FromResult()). A Tarefa é marcada como concluída, e o resultado é disponibilizado imediatamente. No código dos novos compiladores, os objetos Task que já estavam concluídos ocorriam com tanta frequência que a correção dessas alocações melhorou claramente a capacidade de resposta.

Correção para o exemplo 6

Para remover a alocação concluída de Task, é possível armazenar em cache o objeto Tarefa 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 função auxiliar async que mantém o código original de GetSyntaxTreeAsync(). GetSyntaxTreeAsync() agora usa o operador de união nula para retornar cachedResult, caso ele não seja nulo. Se cachedResult for nulo, GetSyntaxTreeAsync() chamará GetSyntaxTreeUncachedAsync() e armazenará o resultado em cache. GetSyntaxTreeAsync() não aguarda a chamada para GetSyntaxTreeUncachedAsync() como o código faria normalmente. Não usar a espera significa que, quando GetSyntaxTreeUncachedAsync() retorna seu objeto Task, GetSyntaxTreeAsync() retorna imediatamente o Task. Agora, o resultado armazenado em cache é um Task. Assim, 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 em aplicativos que processam muitos dados.

Dicionários

Os dicionários são amplamente usados em muitos programas, e bons dicionários são muito práticos e naturalmente eficientes. Porém, eles costumam ser usados incorretamente. No Visual Studio e nos novos compiladores, a análise mostra que muitos dos dicionários apresentavam um único elemento ou estavam vazios. Um Dictionary<TKey,TValue> vazio tem dez campos e ocupa 48 bytes no heap em um computador x86. Os dicionários são ótimos quando você precisa de um mapeamento ou de uma estrutura de dados associativa com pesquisa constante. No entanto, quando tem apenas alguns elementos, você perde muito espaço usando um dicionário. Em vez disso, por exemplo, você pode analisar iterativamente um List<KeyValuePair\<K,V>> com a mesma rapidez. Se você usa um dicionário apenas para carregá-lo com dados e, em seguida, lê a partir dele (um padrão muito comum), o uso de uma matriz classificada com uma operação de leitura N(log(N)) pode ser quase tão rápida, dependendo do número de elementos que você estiver usando.

Classes vs. estruturas

De certa forma, classes e estruturas oferecem uma contrapartida de espaço/tempo clássica para ajustar os aplicativos. As classes têm uma sobrecarga de 12 bytes em um computador x86, mesmo que não tenham campos; porém, são baratas de passar porque apenas é necessário um ponteiro para referenciar uma instância de classe. As estruturas não acarretam alocações de heap se não forem encapsuladas, mas, quando você passa estruturas grandes como argumentos de função ou valores de retorno, isso consome tempo de CPU para copiar atomicamente todos os membros de dados. Cuidado com chamadas repetidas para propriedades que retornem estruturas, e salve em cache o valor da propriedade em uma variável local para evitar a cópia excessiva de dados.

caches

Um truque de desempenho comum é armazenar resultados em cache. Porém, um cache sem um limite de tamanho ou uma política de descarte pode causar perda de memória. Ao processar grandes volumes de dados, se mantiver muita memória em caches, você poderá fazer a coleta de lixo substituir os benefícios das pesquisas armazenadas em cache.

Neste artigo, abordamos como você deve dar atenção a sintomas de gargalo de desempenho que possam afetar a capacidade de resposta do aplicativo, especialmente para sistemas grandes ou sistemas que processem um grande volume de dados. Entre os responsáveis mais comuns estão boxing, manipulação de strings, LINQ e lambda, cache em métodos assíncronos, cache sem um limite de tamanho ou uma política de descarte, uso incorreto de dicionários e uso de estruturas. Lembre-se dos quatro fatos para ajustar os aplicativos:

  • Não otimize antes – seja produtivo e ajuste o aplicativo quando identificar problemas.

  • Os perfis não mentem – se você não está medindo, está adivinhando.

  • As boas ferramentas fazem toda a diferença – baixe o PerfView e faça um teste.

  • É tudo uma questão de alocação – é onde a equipe da plataforma do compilador passa boa parte do tempo melhorando o desempenho dos novos compiladores.

Confira também