Sobre esta aula
Na aula teórica vimos pooling: reutilizar objetos que já foram alocados, em vez de criar e
destruir novos objetos o tempo todo. Partimos de um cenário concreto — o upload de um arquivo de
2 GB para um servidor. Em vez de exigir 2 GB de RAM de uma vez, a aplicação recebe o arquivo
progressivamente, em pequenos pedaços (por exemplo, 64 KB por vez), usando um
byte[] como buffer: uma área temporária de memória
para armazenar dados enquanto eles são transferidos ou processados.
O problema aparece quando o servidor atende milhares de uploads ao mesmo tempo: se cada
operação cria o seu próprio buffer, são milhares de alocações, mais trabalho para o
Garbage Collector (GC) e menor performance. A solução é o pooling — manter um
conjunto de buffers prontos para reutilização, em vez de criar um novo a cada operação. O .NET já
oferece isso pronto com ArrayPool<T>. Nesta aula prática
vamos usar essa classe, medir a diferença que ela faz e, ao final, construir nosso próprio pool
simples do zero.
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 Pooling
Depois, entre na pasta do projeto que acabou de ser criada:
cd Pooling
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.
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.
Relembrando: medindo alocação
Vamos reaproveitar, em todos os exemplos desta aula, o mesmo método auxiliar usado na aula de
performance para medir quantos bytes um trecho de código aloca, usando
GC.GetAllocatedBytesForCurrentThread(). Cole este método
uma única vez no seu projeto (lembre-se: métodos vêm sempre depois do código de uso, se você
estiver usando top-level statements):
static long MedirAlocacao(Action acao)
{
long antes = GC.GetAllocatedBytesForCurrentThread();
acao();
long depois = GC.GetAllocatedBytesForCurrentThread();
return depois - antes;
}
Exemplo 1 — Simulando uploads sem pooling
Vamos simular um servidor recebendo vários "pedaços" de arquivos, do jeito ingênuo: um
byte[] novo a cada pedaço processado.
int quantidadeDeUploads = 10_000;
int tamanhoDoBuffer = 64 * 1024; // 64 KB
long alocadoSemPool = MedirAlocacao(() =>
{
for (int i = 0; i < quantidadeDeUploads; i++)
{
byte[] buffer = new byte[tamanhoDoBuffer];
// ... aqui a aplicação receberia dados e gravaria no disco ...
}
});
Console.WriteLine($"Sem pool: {alocadoSemPool:N0} bytes alocados");
Console.WriteLine($"Isso equivale a aproximadamente {alocadoSemPool / 1024 / 1024} MB");
Desafios:
- Rode o código e observe o total de MB alocados. Isso é próximo do que você esperaria
(
quantidadeDeUploads×tamanhoDoBuffer)? - Aumente
quantidadeDeUploadspara100_000. O crescimento na alocação é proporcional?
Pense e responda:
- Cada
buffercriado no laço é descartado logo depois — ele nem chega a ser usado de verdade neste exemplo. Ainda assim, por que isso já é um problema real de performance em um servidor que atende muitos uploads simultâneos?
Exemplo 2 — Usando ArrayPool<T>
ArrayPool<T> (do namespace
System.Buffers) é um pool de arrays já pronto no .NET.
Rent(...) pega um array disponível no pool (ou aloca um novo,
se não houver nenhum disponível); Return(...) devolve o array
para que possa ser reaproveitado. O padrão de uso é sempre com
try/finally, para garantir
que o array é devolvido mesmo se algo der errado no meio do uso.
using System.Buffers;
int quantidadeDeUploads = 10_000;
int tamanhoDoBuffer = 64 * 1024;
long alocadoComPool = MedirAlocacao(() =>
{
for (int i = 0; i < quantidadeDeUploads; i++)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(tamanhoDoBuffer);
try
{
// ... aqui a aplicação receberia dados e gravaria no disco ...
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
});
Console.WriteLine($"Com pool: {alocadoComPool:N0} bytes alocados");
Desafios:
- Rode o código e compare o resultado com o do Exemplo 1 (mesmo
quantidadeDeUploads). A diferença é grande? - Comente (ou apague) a chamada a
ArrayPool<byte>.Shared.Return(buffer)e rode de novo. O que acontece com a alocação? Isso faz sentido com a ideia de que, sem devolver, o pool nunca tem um array "disponível" para reaproveitar na próxima iteração?
Pense e responda:
- O array devolvido por
Rent(tamanhoDoBuffer)pode vir com tamanho maior do que o pedido (o pool trabalha com tamanhos "de prateleira"). Por que, mesmo assim, ainda é seguro usar esse array para guardar atétamanhoDoBufferbytes?
Exemplo 3 — Pooling não elimina alocações
A teoria foi clara nesse ponto: pooling não elimina alocações, ele reduz novas alocações através
da reutilização. Se não houver buffers disponíveis no pool no momento do
Rent, o pool aloca um novo array para atender ao pedido. Vamos
visualizar isso simulando um pico de uso: muitos "uploads" acontecendo ao mesmo tempo, sem nenhum
terminar antes do próximo começar.
using System.Buffers;
int uploadsSimultaneos = 500;
int tamanhoDoBuffer = 64 * 1024;
List<byte[]> buffersEmUso = new();
long alocadoNoPico = MedirAlocacao(() =>
{
// Todos alugam um buffer ANTES de qualquer devolução — como uploads
// simultâneos que ainda não terminaram.
for (int i = 0; i < uploadsSimultaneos; i++)
{
buffersEmUso.Add(ArrayPool<byte>.Shared.Rent(tamanhoDoBuffer));
}
});
Console.WriteLine($"Alocado no pico de {uploadsSimultaneos} uploads simultâneos: {alocadoNoPico:N0} bytes");
// Só agora, no final, devolvemos tudo.
foreach (byte[] buffer in buffersEmUso)
{
ArrayPool<byte>.Shared.Return(buffer);
}
buffersEmUso.Clear();
// Repetindo o mesmo pico: os buffers já devolvidos estão disponíveis.
long alocadoNoSegundoPico = MedirAlocacao(() =>
{
for (int i = 0; i < uploadsSimultaneos; i++)
{
buffersEmUso.Add(ArrayPool<byte>.Shared.Rent(tamanhoDoBuffer));
}
});
Console.WriteLine($"Alocado no segundo pico (buffers já devolvidos): {alocadoNoSegundoPico:N0} bytes");
Desafios:
- Rode o código. Compare os dois valores impressos. Por que o segundo pico aloca muito menos (ou quase nada) comparado ao primeiro?
- Aumente
uploadsSimultaneospara2000e rode de novo. A diferença entre o primeiro e o segundo pico se mantém?
Pense e responda:
- Com base neste exemplo, explique com suas palavras por que a frase "pooling não elimina alocações, reduz novas alocações através da reutilização" é mais precisa do que dizer "pooling elimina alocações".
- Se você rodou o código, provavelmente viu que o segundo pico não chegou perto de zero
bytes — mesmo devolvendo os 500 buffers antes de pedir os 500 de novo. Isso acontece porque o
ArrayPool<byte>.Sharednão guarda uma quantidade ilimitada de arrays por tamanho: ele tem uma capacidade interna limitada, dividida entre partições por thread/núcleo. Quando essa capacidade enche, osReturns extras simplesmente não são guardados — o array vira lixo normal para o GC. Por isso, no segundo pico, parte dos 500 pedidos reaproveita buffers devolvidos, mas o restante precisa alocar de novo. Que relação você enxerga entre esse comportamento e o problema de um pool sem limite guardar objetos demais? OArrayPool<T>.Sharedresolve esse problema de que jeito?
Exercícios
Agora é sua vez de aplicar pooling em cenários um pouco mais completos.
Exercício 1 — Classificando funcionários: Pattern Matching + DTO + Otimização
Este exercício reúne três assuntos já vistos na disciplina — pattern matching, DTOs e a técnica de redução de alocação vista hoje. Justifique cada decisão, não só faça o código compilar.
Considere a hierarquia:
public class Funcionario
{
public int Id { get; set; }
public string Nome { get; set; }
public int TempoExperienciaAnos { get; set; }
public string CPF { get; set; }
public string Endereco { get; set; }
public decimal Salario { get; set; }
public DateTime DataNascimento { get; set; }
}
public class Desenvolvedor : Funcionario
{
public string Linguagem { get; set; }
}
public class Gerente : Funcionario
{
public int TamanhoEquipe { get; set; }
}
Imagine um endpoint de API que recebe uma List<Funcionario>
(uma mistura de Desenvolvedor e
Gerente) com até dezenas de milhares de registros, e
precisa devolver, para cada um, uma classificação e os dados que podem ser expostos
externamente. Resolva os três itens abaixo:
- a) Pattern Matching. Escreva um método
string Classificar(Funcionario funcionario)usando property patterns e/ou switch expression que devolve uma classificação a partir do tipo concreto e de propriedades do objeto — por exemplo, distinguindoDesenvolvedorjúnior/sênior pelo tempo de experiência, eGerentede equipe pequena/grande peloTamanhoEquipe. Defina você mesmo(a) os critérios (ex.: "Dev júnior" abaixo de 3 anos, "Dev sênior" a partir de 3; "Gerente de equipe grande" a partir de 10 pessoas). - b) DTO. Defina um
recordapropriado para a resposta da API (por exemplo,FuncionarioClassificadoDTO), contendo apenas os dados que fazem sentido expor externamente, mais a classificação calculada em (a). Em um comentário, explique por queFuncionario/Desenvolvedor/Gerentenão deveriam ser retornados diretamente pela API. - c) Otimização. O endpoint processa dezenas de milhares de registros por chamada. Escolha
uma das técnicas vistas na disciplina —
StringBuilder,Span<T>ou Pooling/ArrayPool<T>— para otimizar algum ponto do processamento (por exemplo: montar uma mensagem de log/auditoria por funcionário processado, ou reutilizar um buffer caso o processamento envolva leitura/escrita em blocos). Implemente esse ponto do código com a técnica escolhida e, em comentário, justifique por que ela se aplica a este cenário e qual seria o ganho esperado em relação ao Garbage Collector.
Não existe uma única resposta certa aqui — o que importa é a coerência: os critérios de classificação do item (a) precisam bater com os campos do DTO do item (b), e a técnica escolhida no item (c) precisa fazer sentido para o ponto do processamento que você optou por otimizar. Esteja preparado(a) para justificar suas escolhas em voz alta.
Exercício 2 — Simulando gravação em disco por pedaços
Usando ArrayPool<byte>.Shared, escreva um método
void ProcessarArquivo(long tamanhoTotalEmBytes, int tamanhoDoPedaco)
que simula o recebimento progressivo de um arquivo: em um laço, aluga um buffer do tamanho do
pedaço, "processa" (pode ser apenas um comentário), devolve o buffer, e repete até cobrir o
tamanhoTotalEmBytes. Ao final, imprima quantas "idas ao pool"
(quantos Rent) foram necessárias para processar um arquivo de
2 GB com pedaços de 64 KB.
Para refletir
A teoria fechou com três estratégias para reduzir alocações que se complementam:
StringBuilder reutiliza um espaço de memória para evitar criar
uma nova string a cada concatenação; Span<T> acessa uma
parte dos dados existentes sem realizar uma nova alocação para representá-la; e
pooling mantém objetos temporários disponíveis para reutilização, evitando novas alocações.
As três levam ao mesmo destino: menos alocações → menos pressão no GC → potencialmente melhor
performance. Guarde também o cuidado que apareceu nos exercícios de hoje — um pool sem limite
resolve o problema da alocação, mas pode criar um problema novo, de memória ocupada por objetos
ociosos. Como em toda otimização, a decisão de usar pooling (e com que limite) deve vir de
medição, não de suposição.