Performance e Alocação - Aula Prática

Programação Aplicada em .NET • Prof. Me. William Tenório

Sobre esta aula

Na aula teórica vimos que existem dois pontos importantes de performance: quanto tempo o código leva para executar e quanto ele aloca na memória durante a execução. Também vimos que alocar tem custo, que o Garbage Collector (GC) é quem recupera a memória de objetos não utilizados, e que Span<T> permite enxergar uma região da memória sem criar uma nova coleção.

Nesta aula prática, em vez de só acreditar que "alocar menos é mais rápido", vamos medir isso com código: quanto tempo cada abordagem leva e quantos bytes cada uma aloca. Ao final, vamos dar um primeiro passo em pooling — reaproveitar objetos já alocados em vez de criar novos — que será o assunto da próxima aula teórica.

Criando o Projeto no VS Code

Siga este roteiro para criar o projeto de console em .NET desta aula:

  1. Abra o Visual Studio Code.
  2. Crie uma pasta com o seu nome em um local do computador de que você vá se lembrar depois (ex.: Área de Trabalho ou Documentos).
  3. No VS Code, vá em File > Open Folder e selecione a pasta criada.
  4. Abra o terminal integrado em Terminal > New Terminal.
  5. No terminal, execute um comando por vez, apertando Enter e esperando ele terminar antes de digitar o próximo — não cole os três de uma vez.

Primeiro, crie o projeto:

dotnet new console -n PerformanceAlocacao

Depois, entre na pasta do projeto que acabou de ser criada:

cd PerformanceAlocacao

Só então rode o projeto:

dotnet run

Todo código abaixo pode ser colado dentro do método Main (ou substituindo o conteúdo de Program.cs, no caso dos top-level statements). Depois de colar e alterar o código, salve o arquivo e rode dotnet run novamente para ver o novo resultado.

Rode sempre em Release para medir tempo Por padrão o dotnet run compila em modo Debug, que pode deixar as medições de tempo menos realistas (o JIT otimiza menos). Para os exemplos desta aula que medem tempo, prefira:

dotnet run -c Release

As medições de alocação (em bytes) não sofrem esse problema e funcionam bem mesmo em Debug.

Atenção à ordem no Program.cs Se o seu projeto foi criado com o SDK mais recente, o Program.cs vem sem um Main visível (são os chamados top-level statements). Nesse formato, todo código "solto" (as instruções dos exemplos) precisa vir antes de qualquer class ou método auxiliar declarado no mesmo arquivo. Cole cada declaração de método/classe dos exemplos sempre por último, depois de todo o código de uso.

Um conceito novo: StringBuilder

Antes do primeiro exemplo, um conceito que a teoria ainda não cobriu. Como vimos, string é imutável: cada vez que você "altera" uma string (por exemplo, concatenando com +=), o C# na verdade cria uma string nova e descarta a antiga. Em um laço com muitas repetições, isso significa muitas strings intermediárias sendo alocadas e logo em seguida jogadas fora.

StringBuilder (do namespace System.Text) mantém um buffer interno — um array de caracteres mutável, que Append(...) vai preenchendo. Isso também é alocação: o buffer em si precisa ser alocado. A diferença é que, quando o buffer enche, o StringBuilder aloca um novo buffer maior (em geral com o dobro do tamanho) e copia o conteúdo antigo para dentro dele — em vez de alocar um buffer novo a cada Append. Ou seja: em 100 mil chamadas a Append, em vez de 100 mil alocações (uma por chamada, como acontece com += em uma string), temos só um punhado — cada uma valendo por um período cada vez maior de uso do buffer. Só ao final, quando você chama ToString(), uma última string é produzida a partir do conteúdo do buffer. Vamos medir essa diferença já no primeiro exemplo.

Exemplo 1 — Medindo tempo com Stopwatch

A classe Stopwatch (do namespace System.Diagnostics) mede quanto tempo um trecho de código leva para executar. Vamos comparar duas formas de montar uma mesma string 100 mil vezes.

using System.Diagnostics;

int repeticoes = 100_000;

// Abordagem 1: concatenando com +=
Stopwatch cronometro1 = Stopwatch.StartNew();
string resultado1 = "";
for (int i = 0; i < repeticoes; i++)
{
    resultado1 += i;
}
cronometro1.Stop();
Console.WriteLine($"Concatenação com +=: {cronometro1.ElapsedMilliseconds} ms");

// Abordagem 2: usando StringBuilder
Stopwatch cronometro2 = Stopwatch.StartNew();
System.Text.StringBuilder sb = new();
for (int i = 0; i < repeticoes; i++)
{
    sb.Append(i);
}
string resultado2 = sb.ToString();
cronometro2.Stop();
Console.WriteLine($"StringBuilder: {cronometro2.ElapsedMilliseconds} ms");

Desafios:

Pense e responda:

Exemplo 2 — Medindo alocação com GC

Tempo não conta a história toda: duas abordagens podem ter tempos parecidos e alocar quantidades de memória bem diferentes. O .NET permite consultar quantos bytes a thread atual alocou até agora, com GC.GetAllocatedBytesForCurrentThread().

static long MedirAlocacao(Action acao)
{
    long antes = GC.GetAllocatedBytesForCurrentThread();
    acao();
    long depois = GC.GetAllocatedBytesForCurrentThread();
    return depois - antes;
}

int repeticoes = 100_000;

long alocadoComMais = MedirAlocacao(() =>
{
    string resultado = "";
    for (int i = 0; i < repeticoes; i++)
    {
        resultado += i;
    }
});

long alocadoComBuilder = MedirAlocacao(() =>
{
    System.Text.StringBuilder sb = new();
    for (int i = 0; i < repeticoes; i++)
    {
        sb.Append(i);
    }
    string resultado = sb.ToString();
});

Console.WriteLine($"Concatenação com +=: {alocadoComMais:N0} bytes alocados");
Console.WriteLine($"StringBuilder: {alocadoComBuilder:N0} bytes alocados");

Desafios:

Pense e responda:

Exemplo 3 — Substring x Span<T> na prática

A teoria mostrou que texto.Substring(...) cria uma nova string, enquanto texto.AsSpan(...) apenas enxerga os caracteres existentes. Vamos confirmar isso com números.

string texto = "ABC123456";
int repeticoes = 1_000_000;

long alocadoComSubstring = MedirAlocacao(() =>
{
    for (int i = 0; i < repeticoes; i++)
    {
        string parte = texto.Substring(3, 3);
    }
});

long alocadoComSpan = MedirAlocacao(() =>
{
    for (int i = 0; i < repeticoes; i++)
    {
        ReadOnlySpan<char> parte = texto.AsSpan(3, 3);
    }
});

Console.WriteLine($"Substring: {alocadoComSubstring:N0} bytes alocados");
Console.WriteLine($"Span: {alocadoComSpan:N0} bytes alocados");

Desafios:

Pense e responda:

Exemplo 4 — Reaproveitando objetos: um primeiro pooling

Se criar objetos repetidamente tem custo e gera trabalho para o GC, uma ideia natural é: em vez de descartar um objeto depois de usá-lo, guardá-lo em algum lugar e devolvê-lo na próxima vez que precisarmos de um objeto daquele tipo. Isso é a ideia central de pooling — o tema da próxima aula teórica. Aqui vamos só sentir o ganho na prática, com uma implementação bem simples usando Stack<T>.

public class Cliente
{
    public string? Nome { get; set; }
    public string? Email { get; set; }
}

public class ClientePool
{
    private readonly Stack<Cliente> _disponiveis = new();

    public Cliente Alugar()
    {
        if (_disponiveis.Count > 0)
        {
            return _disponiveis.Pop();
        }
        return new Cliente();
    }

    public void Devolver(Cliente cliente)
    {
        cliente.Nome = null;
        cliente.Email = null;
        _disponiveis.Push(cliente);
    }
}

int repeticoes = 1_000_000;

// Sem pooling: um Cliente novo a cada iteração
long alocadoSemPool = MedirAlocacao(() =>
{
    for (int i = 0; i < repeticoes; i++)
    {
        Cliente cliente = new Cliente();
        cliente.Nome = "Teste";
        // ... "usa" o cliente e descarta ...
    }
});

// Com pooling: o mesmo objeto é reaproveitado
ClientePool pool = new();
long alocadoComPool = MedirAlocacao(() =>
{
    for (int i = 0; i < repeticoes; i++)
    {
        Cliente cliente = pool.Alugar();
        cliente.Nome = "Teste";
        // ... "usa" o cliente ...
        pool.Devolver(cliente);
    }
});

Console.WriteLine($"Sem pool: {alocadoSemPool:N0} bytes alocados");
Console.WriteLine($"Com pool: {alocadoComPool:N0} bytes alocados");

Desafios:

Pense e responda:

Esta implementação de ClientePool é só para você sentir a ideia na prática. A aula teórica sobre pooling vai aprofundar quando usar essa técnica, seus riscos (como o do desafio acima) e as ferramentas que o próprio .NET oferece prontas.

Exercícios

Agora é sua vez de montar comparações completas, combinando medição de tempo e de alocação.

Exercício 1 — Lista com tamanho conhecido

Compare, com MedirAlocacao, o custo de preencher uma List<int> com 500 mil números de duas formas: (a) criando a lista com new List<int>() (sem capacidade inicial) e (b) criando com new List<int>(500_000) (informando a capacidade esperada). Explique a diferença encontrada considerando como uma List<T> cresce internamente (ela usa um array por trás dos panos, que precisa ser recriado quando enche).

Exercício 2 — Benchmark genérico

Escreva um método void Comparar(string nome, Action acao) que mede tempo (Stopwatch) e alocação (GC.GetAllocatedBytesForCurrentThread) de uma vez só, e imprime os dois resultados junto com nome. Use esse método para comparar pelo menos três abordagens diferentes vistas nesta aula (por exemplo: concatenação vs StringBuilder, ou sem pool vs com pool) em uma única tabela de saída no console.

Exercício 3 — Pool de StringBuilder (desafio)

Crie uma classe StringBuilderPool, seguindo o mesmo padrão do ClientePool do Exemplo 4, com métodos Alugar() (devolve um StringBuilder limpo, reaproveitado ou novo) e Devolver(StringBuilder sb) (limpa o conteúdo com sb.Clear() antes de guardar). Compare, com MedirAlocacao, a alocação de criar um StringBuilder novo em cada iteração de um laço de 100 mil repetições contra alugar/devolver do pool.

Cuidado: o texto acumulado dentro do StringBuilder também ocupa memória. Se você não chamar Clear() ao devolver, o buffer interno pode crescer sem necessidade a cada reuso.

Para refletir

Nesta aula você trocou "eu acho que é mais rápido" por números reais: milissegundos e bytes. Esse é o hábito mais importante que fica — antes de otimizar qualquer código, meça primeiro, porque a intuição sobre o que é caro nem sempre corresponde ao que o runtime realmente faz. Guarde também a ideia central do Exemplo 4: se um objeto pode ser reaproveitado em vez de recriado, cada aluguel evitado é uma alocação e um pouco de trabalho do GC que deixam de existir. Isso é exatamente o que a próxima aula teórica sobre pooling vai formalizar — com padrões, cuidados e as ferramentas prontas do .NET para isso.