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:
- Abra o Visual Studio Code.
- 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).
- No VS Code, vá em File > Open Folder e selecione a pasta criada.
- Abra o terminal integrado em Terminal > New Terminal.
- 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:
- Rode o código e anote os dois tempos. Depois aumente
repeticoespara1_000_000e rode de novo. A diferença entre as duas abordagens cresce, diminui ou se mantém proporcional? stringem C# é imutável — toda vez que você "altera" uma string, na verdade uma nova string é criada na memória. Com base nisso, explique por queresultado1 += identro de um laço de 100 mil iterações é, na prática, uma fábrica de alocações.
Pense e responda:
StringBuildermantém um buffer interno que cresce sob demanda, em vez de criar uma string nova a cadaAppend. Isso se parece com qual ideia vista na teoria: alocar menos vezes, ou alocar menos bytes por vez?
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:
- Rode e compare os dois valores. A diferença em bytes é maior, menor ou parecida com a diferença de tempo que você viu no Exemplo 1?
- Crie uma terceira medição, criando
Cliente(uma classe simples comNomeeEmail) dentro de um laçoforde 100 mil iterações, sem guardar as instâncias em lugar nenhum (igual ao exemplo da teoria). Quantos bytes isso aloca? Divida o total pelo número de iterações — esse é aproximadamente o custo de alocar um objetoCliente.
Pense e responda:
MedirAlocacaorecebe umAction(um bloco de código) como parâmetro, em vez de repetir o código de medição em cada teste. Que vantagem prática isso traz quando você quer comparar várias abordagens diferentes?
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:
- Rode o código (reaproveite o método
MedirAlocacaodo Exemplo 2). O que você espera ver no valor alocado pela versão comSpan? O resultado confirma a ideia de "janela sobre os dados existentes"? - Troque
repeticoespara10_000_000. Em uma aplicação real que processa um arquivo de texto gigantesco extraindo pequenos trechos repetidas vezes, qual seria o impacto de usarSubstringem vez deSpan?
Pense e responda:
Span<T>não substituistring/arrayem todo lugar. Que limitação você imagina que ele tem, já que ele é apenas uma "janela" e não uma cópia independente dos dados? (dica: o que acontece se os dados originais forem alterados enquanto o Span existe?)
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:
- Rode o código. A diferença de bytes alocados entre as duas versões é grande? Faz sentido com a
ideia de que, na versão com pool, praticamente nenhum
Clientenovo é criado depois da primeira iteração? - No método
Devolver, limpamosNomeeEmailantes de guardar o objeto de volta. O que aconteceria (do ponto de vista de bugs, não de performance) se essa limpeza fosse esquecida? - Meça também o tempo (com
Stopwatch, como no Exemplo 1) das duas versões. O ganho de tempo é proporcional ao ganho de alocação?
Pense e responda:
ClientePoolnão tem nenhum limite de tamanho — ele guarda todo objeto que for devolvido. Que problema isso pode causar em uma aplicação que, em um pico de uso, aluga 1 milhão de objetos e depois os devolve?- O .NET já tem uma classe pronta para esse tipo de reaproveitamento,
ArrayPool<T>(para arrays), amplamente usada em código de alta performance. Com base no que você acabou de implementar manualmente, o que você imagina que ela faz por debaixo dos panos?
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.