Diagnóstico e Perfilamento - Aula Prática

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

Sobre esta aula

Nas aulas anteriores medimos tempo com Stopwatch, alocação com GC e limitamos operações com SemaphoreSlim. Hoje juntamos tudo para responder a uma pergunta comum no dia a dia: "o sistema está lento?"

O fluxo de trabalho é sempre o mesmo: medir antes de otimizar.

  1. Definir uma meta (ex.: "95% das requisições respondem em até 200 ms").
  2. Rodar um teste de carga e calcular os percentis p95/p99.
  3. Se a meta não foi cumprida, fazer um benchmark do trecho suspeito e otimizar.
  4. Medir de novo para confirmar a melhora.

Esta é uma aula só prática, de 30 minutos. Sugestão de ritmo:

  • 5 min — criar o projeto;
  • 10 min — Exemplo 1 (BenchmarkDotNet);
  • 5 min — Exemplo 2 (p95/p99);
  • 10 min — Exemplo 3 (teste de carga).

Criando o Projeto no VS Code

  1. Abra o Visual Studio Code.
  2. Crie uma pasta com o seu nome em um local de que você vá se lembrar depois.
  3. No VS Code, vá em File > Open Folder e selecione a pasta criada.
  4. Abra o terminal em Terminal > New Terminal e execute um comando por vez:
dotnet new console -n Diagnostico
cd Diagnostico
dotnet add package BenchmarkDotNet

Em cada exemplo abaixo, apague todo o conteúdo do Program.cs, cole o código novo, salve e rode o comando indicado no exemplo.

Exemplo 1 — Benchmark com BenchmarkDotNet

Na aula de Performance comparamos concatenação de string com StringBuilder usando Stopwatch. O problema: uma única medição sofre com "ruído" (JIT, GC, outros programas abertos). O BenchmarkDotNet resolve isso: ele aquece o código, executa muitas vezes e entrega estatísticas confiáveis de tempo e memória.

using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<TesteTexto>();

[MemoryDiagnoser]   // mostra também a memória alocada
[ShortRunJob]       // execução mais curta, boa para sala de aula
public class TesteTexto
{
    [Params(10, 1000)]  // roda cada método com N = 10 e N = 1000
    public int N;

    [Benchmark(Baseline = true)]
    public string Concatenando()
    {
        string texto = "";
        for (int i = 0; i < N; i++)
            texto += i;
        return texto;
    }

    [Benchmark]
    public string ComStringBuilder()
    {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < N; i++)
            sb.Append(i);
        return sb.ToString();
    }
}

Rode em modo Release (o BenchmarkDotNet se recusa a medir código em modo Debug):

dotnet run -c Release

Lendo a tabela de resultado

  • Mean — tempo médio de uma execução do método (ns = nanossegundos, μs = microssegundos).
  • Error / StdDev — o quanto as medições variaram. Valores altos indicam ruído.
  • Ratio — comparação com o método marcado como Baseline (1,00). Um Ratio de 0,10 significa "10 vezes mais rápido".
  • Gen0 — quantas coletas do GC aconteceram (a cada 1000 execuções).
  • Allocated — memória alocada por execução.

A classe precisa ser public, e os métodos devem retornar o resultado: se o valor não for usado, o compilador pode eliminar o código e a medição perde o sentido.

Desafios:

Exemplo 2 — Por que a média engana: p95 e p99

Em sistemas reais, a maioria das requisições é rápida, mas algumas são muito lentas. A média esconde essas requisições lentas. Por isso usamos percentis:

O código abaixo simula 1000 latências: 97% entre 20 e 40 ms e 3% bem lentas (300 a 800 ms). A meta do sistema é p95 ≤ 100 ms e p99 ≤ 300 ms.

Random rnd = new Random(42);   // semente fixa: todos veem os mesmos números
List<double> latencias = new List<double>();

for (int i = 0; i < 1000; i++)
{
    if (rnd.Next(100) < 3)                      // 3% das requisições são lentas
        latencias.Add(rnd.Next(300, 800));
    else
        latencias.Add(rnd.Next(20, 40));
}

double Percentil(List<double> valores, double p)
{
    List<double> ordenados = valores.OrderBy(v => v).ToList();
    int posicao = (int)Math.Ceiling(p / 100 * ordenados.Count) - 1;
    return ordenados[Math.Max(posicao, 0)];
}

double p95 = Percentil(latencias, 95);
double p99 = Percentil(latencias, 99);

Console.WriteLine($"Média: {latencias.Average():F1} ms");
Console.WriteLine($"p50:   {Percentil(latencias, 50)} ms");
Console.WriteLine($"p95:   {p95} ms  -> meta 100 ms: {(p95 <= 100 ? "OK" : "FALHOU")}");
Console.WriteLine($"p99:   {p99} ms  -> meta 300 ms: {(p99 <= 300 ? "OK" : "FALHOU")}");
Console.WriteLine($"Máx:   {latencias.Max()} ms");
dotnet run

Como o percentil é calculado: ordenamos as latências e pegamos a que está na posição correspondente. Com 1000 valores, o p95 é o 950º menor valor e o p99 é o 990º.

Desafios:

Exemplo 3 — Teste de carga

Um teste de carga simula vários usuários usando o sistema ao mesmo tempo e mede a latência de cada requisição. Aqui o "servidor" é simulado: ele atende 4 requisições por vez (um SemaphoreSlim(4), como na aula de Concorrência) e cada atendimento leva cerca de 50 ms. Cada usuário faz 10 requisições, uma após a outra.

using System.Diagnostics;

SemaphoreSlim servidor = new SemaphoreSlim(4);   // o servidor atende 4 por vez
List<double> latencias = new List<double>();

async Task RequisicaoAsync()
{
    Stopwatch relogio = Stopwatch.StartNew();
    await servidor.WaitAsync();                  // espera vaga (fila)
    try
    {
        await Task.Delay(50);                    // simula o processamento
    }
    finally
    {
        servidor.Release();
    }
    lock (latencias)                             // lista compartilhada: protege com lock
    {
        latencias.Add(relogio.Elapsed.TotalMilliseconds);   // latência = fila + processamento
    }
}

async Task UsuarioAsync()
{
    for (int i = 0; i < 10; i++)
        await RequisicaoAsync();
}

double Percentil(List<double> valores, double p)
{
    List<double> ordenados = valores.OrderBy(v => v).ToList();
    int posicao = (int)Math.Ceiling(p / 100 * ordenados.Count) - 1;
    return ordenados[Math.Max(posicao, 0)];
}

foreach (int usuarios in new[] { 1, 4, 10, 40 })
{
    latencias.Clear();
    Stopwatch total = Stopwatch.StartNew();

    List<Task> tarefas = new List<Task>();
    for (int u = 0; u < usuarios; u++)
        tarefas.Add(UsuarioAsync());
    await Task.WhenAll(tarefas);

    double vazao = latencias.Count / total.Elapsed.TotalSeconds;
    double p95 = Percentil(latencias, 95);
    Console.WriteLine($"{usuarios,3} usuários | {vazao,5:F0} req/s | " +
                      $"p50 {Percentil(latencias, 50),4:F0} ms | p95 {p95,4:F0} ms | " +
                      $"p99 {Percentil(latencias, 99),4:F0} ms | meta p95 ≤ 200 ms: {(p95 <= 200 ? "OK" : "FALHOU")}");
}
dotnet run

Vazão x latência

  • Vazão (throughput) — quantas requisições o sistema conclui por segundo.
  • Enquanto há vagas livres no servidor, mais usuários = mais vazão, com a latência estável.
  • Quando o servidor satura, a vazão para de crescer e as requisições começam a esperar na fila: é a latência que sobe.

No Windows, o Task.Delay(50) costuma durar ~60 ms, porque o relógio do sistema "anda" em passos de ~15 ms. Isso é normal e não muda as conclusões.

Desafios:

Aqui tudo roda dentro do mesmo programa, só para entender os conceitos. Em sistemas reais, o teste de carga é feito com ferramentas próprias (ex.: k6, NBomber, JMeter) disparando requisições HTTP contra a API, e o diagnóstico do processo em execução usa ferramentas como dotnet-counters e dotnet-trace.

Para refletir

FerramentaPergunta que respondeEscala
BenchmarkDotNet Qual versão deste método é mais rápida e aloca menos? Um trecho de código (micro)
Percentis p95/p99 Como está a experiência dos usuários mais prejudicados? Conjunto de requisições
Teste de carga Quantos usuários o sistema aguenta dentro da meta? Sistema inteiro (macro)