Pooling - Aula Prática

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

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:

  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 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:

Pense e responda:

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:

Pense e responda:

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:

Pense e responda:

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:

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.