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?"
- Diagnóstico — descobrir se existe um problema e onde ele está, olhando métricas como latência (tempo de resposta), vazão (requisições por segundo) e erros.
- Perfilamento — medir com precisão um trecho específico de código (tempo e memória) para entender por que ele é lento e comparar alternativas.
O fluxo de trabalho é sempre o mesmo: medir antes de otimizar.
- Definir uma meta (ex.: "95% das requisições respondem em até 200 ms").
- Rodar um teste de carga e calcular os percentis p95/p99.
- Se a meta não foi cumprida, fazer um benchmark do trecho suspeito e otimizar.
- 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
- Abra o Visual Studio Code.
- Crie uma pasta com o seu nome em um local de que você vá se lembrar depois.
- No VS Code, vá em File > Open Folder e selecione a pasta criada.
- 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:
- Com
N = 10, a diferença entre os dois métodos é grande? E comN = 1000? - Compare a coluna Allocated dos dois métodos com
N = 1000. Por que a concatenação aloca tanto? - Adicione um terceiro método com
[Benchmark]usandonew StringBuilder(N * 4)(capacidade inicial). Ele aloca menos?
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:
- p50 (mediana) — metade das requisições foi mais rápida que esse valor.
- p95 — 95% foram mais rápidas; só 5% demoraram mais (de cada 100 usuários, 5 esperaram mais que isso).
- p99 — 99% foram mais rápidas; mostra a "cauda" de lentidão (1 em cada 100).
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:
- A média parece boa? Ela mostra que existem usuários esperando mais de meio segundo?
- Troque
< 3por< 6(6% de requisições lentas). O que acontece com o p95? E com a média? - Troque para
< 1. As duas metas são cumpridas?
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:
- A partir de quantos usuários a vazão para de crescer? O que acontece com o p95 a partir daí?
- Descubra (testando outros valores no
new[] { ... }) o maior número de usuários que ainda cumpre a meta de p95 ≤ 200 ms. - Mude o servidor para
SemaphoreSlim(8). Quantos usuários ele aguenta agora dentro da meta? - Mude o processamento para
Task.Delay(25)(como se tivéssemos otimizado o código). Qual mudança teve mais efeito: dobrar o servidor ou deixar o código 2× mais rápido?
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
| Ferramenta | Pergunta que responde | Escala |
|---|---|---|
| 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) |
- Um método 10× mais rápido no benchmark sempre deixa o sistema 10× mais rápido no teste de carga? O que mais pode estar dentro da latência de uma requisição?
- Por que metas de desempenho costumam ser escritas com p95/p99 e não com a média?