Performance e distribuição: BenchmarkDotNet, Native AOT e ferramentas de linha de comando

Performance e distribuição: BenchmarkDotNet, Native AOT e ferramentas de linha de comando

Medir antes de otimizar com BenchmarkDotNet e as ferramentas de diagnóstico do .NET, o serviço de frete compilado em Native AOT numa imagem mínima, scripts C# de um arquivo só no .NET 10 e uma ferramenta administrativa com System.CommandLine, login pelo fluxo de dispositivo e três formas de distribuição.
Linguagem C#

• • 23 min de leitura

O último artigo técnico do curso trata de duas perguntas que aparecem no fim de todo projeto. A primeira é quão rápido: como saber se uma mudança deixou o código mais rápido ou só pareceu deixar. A segunda é como entregar: como transformar código C# num executável que arranca em milissegundos, ou numa ferramenta que qualquer pessoa instala com um comando. A Livraria serve de laboratório para as duas.

Medir, não adivinhar

Todo programador tem intuições sobre desempenho, e boa parte delas está errada. Algumas estavam certas em versões antigas do runtime e deixaram de estar. Outras nunca estiveram. O único método confiável é o experimental: medir, formular uma hipótese, mudar uma coisa, medir de novo.

Medir com Stopwatch parece suficiente:

var sw = Stopwatch.StartNew();
for (var i = 0; i < 1_000_000; i++) Isbn.TryCriar("978-85-359-1066-3", out _);
Console.WriteLine(sw.ElapsedMilliseconds);

Esse número engana de pelo menos cinco formas:

  • Aquecimento e compilação em camadas. As primeiras chamadas de um método rodam em código compilado rapidamente pelo JIT, sem otimizações. Depois de várias chamadas, o runtime o recompila com otimizações, e desde o .NET 8 usa também a PGO dinâmica, que otimiza a partir do comportamento observado. Uma medição curta mede uma mistura das duas versões.
  • Eliminação de código morto. O resultado de TryCriar não é usado. O JIT pode perceber isso e remover parte do trabalho, e a medição mostra algo mais rápido que a realidade.
  • Coleta de lixo. Uma coleta que acontece no meio da medição entra no tempo de um lado da comparação e não do outro.
  • Ruído do sistema. Outros processos, a frequência variável do processador, o estado dos caches. Uma única medição não diz nada sobre a variação.
  • Alocações invisíveis. O tempo não diz quanta memória foi alocada, e alocação, como a Fase 9 mostrou, é custo que aparece depois.

O BenchmarkDotNet, a biblioteca de medição usada pela própria equipe do .NET nos seus artigos anuais de desempenho, trata de tudo isso: executa cada caso num processo separado, faz aquecimento até estabilizar, roda muitas iterações, aplica estatística para descartar valores atípicos e calcular intervalos de confiança, e impede a eliminação de código morto consumindo os resultados.

Um benchmark da Livraria

A validação de ISBN foi escrita de três formas ao longo do curso. A primeira, em "Validação, ProblemDetails e OpenAPI", usava LINQ. A segunda, no objeto de valor de "Arquitetura: Clean Architecture, DDD com moderação e os padrões que já usamos", usava Span e stackalloc. Uma terceira pode usar SearchValues, um tipo do .NET 8 feito para buscar conjuntos de caracteres. Qual é melhor, e por quanto?

dotnet new console -n Livraria.Benchmarks -o benchmarks/Livraria.Benchmarks
dotnet add benchmarks/Livraria.Benchmarks package BenchmarkDotNet
dotnet add benchmarks/Livraria.Benchmarks reference src/Livraria.Dominio
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<ValidacaoDeIsbn>();

[MemoryDiagnoser]
public class ValidacaoDeIsbn
{
    private static readonly SearchValues<char> Digitos = SearchValues.Create("0123456789");

    [Params("9788535910667", "978-85-359-1066-7", "978 85 359 1066 X")]
    public string Entrada { get; set; } = "";

    [Benchmark(Baseline = true)]
    public bool ComLinq()
    {
        var d = Entrada.Where(char.IsAsciiDigit).ToArray();
        if (d.Length != 13) return false;
        var soma = 0;
        for (var i = 0; i < 12; i++) soma += (d[i] - '0') * (i % 2 == 0 ? 1 : 3);
        return (10 - soma % 10) % 10 == d[12] - '0';
    }

    [Benchmark]
    public bool ComSpan() => Isbn.TryCriar(Entrada, out _);

    [Benchmark]
    public bool SemAlocar()
    {
        ReadOnlySpan<char> texto = Entrada;
        int soma = 0, n = 0, verificador = -1;
        while (!texto.IsEmpty)
        {
            var i = texto.IndexOfAny(Digitos);
            if (i < 0) break;
            var d = texto[i] - '0';
            if (n < 12) soma += d * (n % 2 == 0 ? 1 : 3);
            else if (n == 12) verificador = d;
            else return false;
            n++;
            texto = texto[(i + 1)..];
        }
        return n == 13 && (10 - soma % 10) % 10 == verificador;
    }
}
dotnet run -c Release --project benchmarks/Livraria.Benchmarks

Benchmarks sempre rodam em Release. O BenchmarkDotNet se recusa a medir código compilado em Debug e explica por quê. A saída é uma tabela com, para cada método e cada entrada: o tempo médio, o erro e o desvio padrão, a razão em relação à linha de base (Ratio), e, graças ao [MemoryDiagnoser], os bytes alocados por operação e as coletas por mil operações.

Rodando o benchmark, o padrão esperado para este caso é:

  • A versão com LINQ aloca a cada chamada: o enumerador, o delegate e o arranjo final.
  • As versões com Span e SearchValues alocam zero bytes na validação. A versão do objeto de valor aloca só a string final, quando o ISBN é válido, porque precisa guardá-la.
  • A diferença de tempo existe, mas é bem menor que a de alocação: estamos falando de algumas dezenas de nanossegundos por chamada em qualquer das versões.

A conclusão prática depende de onde o código roda. A validação de ISBN acontece uma vez por requisição de cadastro, ou uma vez por leitura de código de barras no caixa. Nesses contextos, qualquer das três versões é irrelevante perto de uma consulta ao banco. Otimizá-la seria gastar legibilidade sem ganho. Se a mesma função rodasse dentro do loop do jogo, 60 vezes por segundo para cada livro na tela, a alocação da versão LINQ seria a diferença entre zero e milhares de objetos por minuto para o coletor. O benchmark não diz o que fazer. Ele dá os números para decidir.

Os resultados só valem para a máquina, o sistema e o runtime em que foram medidos. O BenchmarkDotNet imprime todos esses dados no cabeçalho da tabela, e compará-los entre máquinas diferentes é o mesmo erro de comparar medições com Stopwatch. Para comparar runtimes, o atributo [SimpleJob(RuntimeMoniker.Net80)] ao lado de [SimpleJob(RuntimeMoniker.Net10_0)] roda o mesmo benchmark nas duas versões.

O que costuma valer a pena

Com medições em mãos, algumas otimizações aparecem com frequência em aplicações .NET. Todas já passaram pelo curso:

Otimização Onde apareceu Quando vale
Span<T>, stackalloc, string.Create "Span<T> e Memory<T>: Olhar Sem Copiar, Processar Sem Alocar", o objeto Isbn laços quentes, parsing, formatação
ArrayPool<T>.Shared buffers temporários grandes leitura de arquivos, compressão, serialização
SearchValues<T> a validação acima busca de conjuntos de caracteres ou bytes
FrozenDictionary / FrozenSet tabelas montadas uma vez e só lidas configurações, mapas de rotas, códigos fixos
AsNoTracking e projeções "EF Core com Desempenho: N+1, AsNoTracking e o SQL por Trás de Cada Consulta" toda leitura que não vai alterar entidades
Geração de código para JSON contextos JsonSerializerContext APIs de alto volume, Native AOT
Cache "Cache com Redis, HybridCache e output caching" leituras frequentes que toleram atraso
ValueTask métodos que costumam completar de forma síncrona caches, pools, leituras em buffer

E algumas coisas deixaram de valer a pena. LINQ ficou muito mais rápido nas versões recentes do .NET, com implementações especializadas para arranjos e listas, e evitar LINQ "por desempenho" fora de laços realmente quentes costuma ser prejuízo de legibilidade sem ganho mensurável. Concatenar strings com + em expressões simples é convertido pelo compilador em chamadas eficientes. O próprio JIT, com a PGO dinâmica, desvirtualiza chamadas de interfaces quando observa sempre o mesmo tipo concreto. Cada versão do .NET muda essa lista, e é por isso que medir vale mais que decorar regras.

Encontrando o que medir

Benchmarks medem trechos isolados. Para descobrir quais trechos importam numa aplicação inteira, as ferramentas são outras, e algumas já apareceram de passagem nos artigos de jogos:

  • dotnet-counters: contadores em tempo real de um processo em execução, como uso de CPU, taxa de alocação, coletas por geração, requisições por segundo e tamanho do pool de threads. É o primeiro olhar.
  • dotnet-trace: coleta rastros de CPU e de eventos do runtime. O resultado abre no PerfView, no Visual Studio ou, convertido, no speedscope do navegador, como um flame graph: a largura de cada função é o tempo gasto nela e no que ela chama.
  • dotnet-gcdump e dotnet-dump: fotografias do heap para investigar vazamentos de memória.
  • Os rastros do OpenTelemetry de "Produção": numa requisição lenta, mostram em qual serviço e em qual consulta o tempo foi gasto, antes mesmo de abrir um profiler.

A ordem natural é essa: a telemetria aponta o endpoint lento, o rastro aponta o trecho, o profiler aponta a função, e o benchmark compara as alternativas para essa função.

Native AOT

Desde "Do Código-Fonte ao Processo", sabemos que o C# vira IL, e o IL vira código de máquina pelo JIT durante a execução. O Native AOT (ahead-of-time) faz essa compilação antes, no build, e produz um executável nativo que não precisa do JIT nem do runtime instalado.

As vantagens:

  • Arranque em milissegundos, sem compilar nada na inicialização.
  • Menos memória, sem o JIT e sem os metadados de tipos que não são usados.
  • Um único arquivo, pequeno, sem dependências externas além das bibliotecas do sistema.
  • Imagens de contêiner menores, sobre a imagem runtime-deps, que nem inclui o runtime do .NET.

O custo é que o compilador precisa saber, no build, todo o código que pode ser executado. Para isso ele aplica o trimming, removendo o que não é referenciado, e não há JIT para compilar código novo depois. Com isso:

  • Reflexão sobre tipos não referenciados estaticamente pode falhar, porque eles foram removidos.
  • Geração de código em tempo de execução (System.Reflection.Emit, expressões compiladas) não funciona.
  • Carregamento dinâmico de assemblies (plugins) não é suportado.
  • Algumas bibliotecas ainda não são compatíveis. O EF Core, em particular, tem suporte a AOT experimental nas versões recentes, com consultas pré-compiladas, e ainda não é a escolha para uma API com EF em produção.

Boa parte das técnicas do curso existe justamente para contornar essas limitações: os source generators de JSON, de logging, de validação, de binding de Minimal APIs e de bindings compilados no Avalonia fazem em tempo de compilação o que antes exigia reflexão.

O serviço de frete em Native AOT

Entre os serviços da Livraria, o Livraria.Frete é o candidato ideal: não usa EF Core, é pequeno, é chamado com prazo curto, e o gRPC do ASP.NET Core suporta Native AOT desde o .NET 8. Um serviço que arranca em milissegundos também pode escalar de zero a várias réplicas em segundos, conforme a demanda.

<PropertyGroup>
  <PublishAot>true</PublishAot>
  <InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>

A propriedade PublishAot faz duas coisas. Na publicação, gera o executável nativo. Durante o desenvolvimento, liga os analisadores de trimming e AOT, e o compilador passa a emitir avisos (IL2026, IL3050 e parentes) em cada ponto do código que usa algo incompatível. Esses avisos são o trabalho de migração: cada um aponta uma reflexão, uma serialização sem contexto gerado, ou uma biblioteca que precisa ser trocada. Um projeto que compila sem avisos com PublishAot funciona em AOT como funciona no JIT.

O Program.cs troca o construtor padrão pelo enxuto:

var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddGrpc();
builder.Services.AddSingleton<TabelaDeTransportadoras>();

var app = builder.Build();
app.MapGrpcService<ServicoDeFrete>();
app.Run();

CreateSlimBuilder inclui só o essencial: sem HTTPS configurado por padrão, sem os provedores de configuração menos comuns, sem hospedagem no IIS. O resultado é menor e mais rápido de arrancar. Recursos necessários são acrescentados explicitamente. Para uma API com Minimal APIs e JSON, o contexto de serialização gerado entra na cadeia de resolvedores de tipos:

builder.Services.ConfigureHttpJsonOptions(o =>
    o.SerializerOptions.TypeInfoResolverChain.Insert(0, ContextoDaApi.Default));

A publicação:

dotnet publish src/Livraria.Frete -c Release -r linux-x64
dotnet publish src/Livraria.Frete -c Release -r linux-x64 /t:PublishContainer

Para AOT, o SDK usa automaticamente uma imagem base runtime-deps, sem o runtime do .NET, e a combinação com a família chiseled de "Deploy: contêineres, nginx, Linux e a orquestração com Aspire" produz imagens de poucas dezenas de megabytes. Comparando as duas versões do serviço de frete com os contadores e com o tempo de resposta ao primeiro pedido depois do arranque, a versão AOT arranca bem mais rápido e usa menos memória em repouso. A versão com JIT, depois de aquecida, pode ter vazão igual ou até maior em trabalho intenso, porque o JIT otimiza com informações de execução que o compilador AOT não tem. AOT é a escolha para arranque rápido, memória baixa e distribuição simples, e não automaticamente para velocidade máxima em regime.

Ferramentas de linha de comando

A última forma de entregar C# é a mais antiga: um programa de linha de comando. A Livraria precisa de uma para tarefas administrativas que não justificam uma tela: importar um catálogo de fornecedor em CSV, reprocessar os vetores do artigo anterior, consultar o estado das filas, gerar um relatório de vendas do dia.

Scripts de um arquivo só

Para tarefas pequenas, o .NET 10 trouxe algo que o C# nunca teve: executar um único arquivo .cs sem projeto, sem .csproj, sem pasta bin:

#!/usr/bin/env dotnet
#:package Spectre.Console@<versão>
using System.Globalization;

using Spectre.Console;

var arquivo = args.Length > 0 ? args[0] : "vendas.csv";
var linhas = File.ReadLines(arquivo).Skip(1).Select(l => l.Split(';'));
var total = linhas.Sum(c => decimal.Parse(c[3], CultureInfo.InvariantCulture));

AnsiConsole.MarkupLine($"Total de vendas em [bold]{arquivo}[/]: [green]{total:C}[/]");
dotnet run resumo-vendas.cs vendas-2026-09-24.csv

As diretivas #:package declaram pacotes NuGet dentro do próprio arquivo. No Linux e no macOS, com a linha #! e permissão de execução, o arquivo roda como qualquer script (./resumo-vendas.cs). Quando o script cresce, dotnet project convert o transforma num projeto comum. É o fim da desculpa de que "C# é pesado demais para scripts", e é o que o artigo de arquitetura disse sobre o script que roda uma vez: a arquitetura certa dele é um arquivo.

Uma ferramenta de verdade

A ferramenta administrativa da Livraria é um projeto com vários comandos. A biblioteca System.CommandLine, usada pelo próprio SDK do .NET, que passou anos em pré-lançamento antes de estabilizar sua API, cuida da análise dos argumentos, da ajuda automática, da validação e do autocompletar no terminal:

using System.CommandLine;

var termo = new Option<string?>("--termo") { Description = "Filtra por título ou autor." };
var formato = new Option<Formato>("--formato") { Description = "Saída em tabela ou JSON.", DefaultValueFactory = _ => Formato.Tabela };

var listar = new Command("listar", "Lista produtos do catálogo.") { termo, formato };
listar.SetAction(async (resultado, ct) =>
{
    var api = await ClienteAutenticado.CriarAsync(ct);
    var produtos = await api.ListarAsync(resultado.GetValue(termo), ct);
    Saida.Escrever(produtos, resultado.GetValue(formato));
    return 0;
});

var arquivo = new Argument<FileInfo>("arquivo") { Description = "CSV do fornecedor." };
var simular = new Option<bool>("--simular") { Description = "Valida sem gravar." };

var importar = new Command("importar", "Importa um catálogo de fornecedor.") { arquivo, simular };
importar.SetAction(async (resultado, ct) =>
    await Importacao.ExecutarAsync(resultado.GetValue(arquivo)!, resultado.GetValue(simular), ct));

var raiz = new RootCommand("Ferramenta administrativa da Livraria")
{
    new Command("produtos", "Catálogo") { listar, importar },
};

return await raiz.Parse(args).InvokeAsync();
livraria produtos listar --termo machado
livraria produtos listar --formato json | jq '.[] | select(.estoque == 0) | .titulo'
livraria produtos importar fornecedor-setembro.csv --simular
livraria --help

Uma boa ferramenta de linha de comando segue convenções que outros programas esperam:

  • Códigos de saída: 0 para sucesso, diferente de zero para falha, para que scripts e pipelines de CI saibam o que aconteceu.
  • Saída para máquinas e para pessoas: tabelas coloridas no terminal (a biblioteca Spectre.Console desenha tabelas, barras de progresso e árvores), e JSON com --formato json para ser encadeado com outras ferramentas por pipe.
  • Erros na saída de erro (stderr), e dados na saída padrão (stdout), para que um redirecionamento não misture os dois.
  • Cancelamento com Ctrl+C: o CancellationToken passado a SetAction é cancelado, e uma importação longa para de forma ordenada, sem deixar um lote gravado pela metade.
  • --simular (ou --dry-run) para operações destrutivas ou em massa, mostrando o que seria feito sem fazer.

Login num terminal

A ferramenta chama a API com a identidade de um administrador, e precisa de um token. Num terminal não há navegador embutido nem porta de retorno conveniente, e digitar a senha na ferramenta seria o erro que o OpenID Connect existe para evitar. O fluxo certo é o Device Authorization Grant (RFC 8628), o mesmo das TVs e consoles:

$ livraria login
Acesse https://auth.livraria.exemplo/device e digite o código: WDJB-MJHT
Aguardando autorização... ✓ Conectada como ana@livraria.exemplo

A ferramenta pede um código ao provedor de identidade, mostra o endereço e o código, e consulta o provedor periodicamente até o usuário aprovar no navegador, em qualquer dispositivo, com senha, segundo fator e tudo o mais. O Keycloak de "Login, área administrativa, upload de capas e notificações em tempo real" suporta o fluxo, e a biblioteca Duende.IdentityModel tem os métodos para iniciá-lo e acompanhá-lo. O refresh token fica guardado criptografado no perfil do usuário, com a mesma proteção de dados do caixa em Avalonia.

Distribuição

Há três formas de entregar a ferramenta, para públicos diferentes:

Como ferramenta .NET, para quem já tem o SDK:

<PropertyGroup>
  <PackAsTool>true</PackAsTool>
  <ToolCommandName>livraria</ToolCommandName>
  <PackageId>Livraria.Ferramenta</PackageId>
  <Version>1.4.0</Version>
</PropertyGroup>
dotnet pack -c Release
dotnet tool install --global Livraria.Ferramenta --add-source ./nupkg

O pacote vai para um feed NuGet (público ou privado da empresa), e a instalação e as atualizações usam os comandos que qualquer desenvolvedor .NET já conhece. O .NET 10 também trouxe o dnx, que executa uma ferramenta de um pacote sem instalá-la, como o npx do ecossistema JavaScript: dnx Livraria.Ferramenta produtos listar.

Como executável nativo, para quem não tem .NET instalado, como a equipe de operações ou um servidor mínimo: publicação com Native AOT para cada plataforma, anexada a uma release do repositório. A ferramenta, que não usa EF Core nem reflexão, compila em AOT sem avisos, arranca instantaneamente e cabe num binário pequeno. Gerenciadores de pacotes como winget, Homebrew e Scoop podem apontar para esses binários.

Como imagem de contêiner, para rodar em pipelines e em tarefas agendadas do orquestrador, com o mesmo /t:PublishContainer.

Em todas as formas, o que se publica merece o cuidado de qualquer artefato de produção: versão semântica, builds determinísticos (o mesmo código gera os mesmos bytes), SourceLink (para que depuradores encontrem o código-fonte exato da versão), e um SBOM, a lista de todas as dependências incluídas, para que uma vulnerabilidade descoberta numa biblioteca possa ser rastreada até cada artefato que a contém.

A Livraria agora sabe medir antes de otimizar, com o BenchmarkDotNet dando números confiáveis e as ferramentas de diagnóstico dizendo onde olhar. Ela tem um serviço compilado em Native AOT que arranca em milissegundos numa imagem mínima, scripts de um arquivo para tarefas pequenas, e uma ferramenta administrativa com comandos, saída para pessoas e máquinas, login seguro pelo navegador e três formas de distribuição. Com isso a Fase 10 termina, e com ela todo o conteúdo técnico do curso. Resta juntar as pontas.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um colega mediu com Stopwatch que a versão LINQ da validação de ISBN leva "0 ms para 1.000 chamadas", e concluiu que otimizar é inútil. Aponte os problemas da medição e diga o que o BenchmarkDotNet mostraria de diferente.

Ver resposta

✓ Resposta: (1) Resolução: ElapsedMilliseconds arredonda para milissegundos, e 1.000 chamadas de algumas dezenas de nanossegundos somam dezenas de microssegundos, que aparecem como 0. (2) Aquecimento: as 1.000 chamadas podem ter rodado quase todas no código não otimizado da primeira camada do JIT. (3) Resultado descartado: o JIT pode ter eliminado parte do trabalho. (4) Uma única medição, sem variação nem intervalo de confiança. (5) Alocações invisíveis: o tempo não mostra que cada chamada aloca. O BenchmarkDotNet mostraria o tempo por operação em nanossegundos, com erro e desvio, depois do aquecimento, em processo isolado, e, com [MemoryDiagnoser], os bytes alocados por operação e as coletas de geração 0 por mil operações. A conclusão do colega pode até estar certa para o contexto (a validação roda poucas vezes por requisição), mas deve vir dos números certos, e a alocação é o dado que muda a conclusão em contextos como o loop do jogo.

Exercício 2

Ao ligar PublishAot na API da Livraria, o compilador emite dezenas de avisos IL2026 e IL3050 vindos do EF Core e de um JsonSerializer.Serialize(objeto) sem contexto. Explique o que cada tipo de aviso significa e por que a API da Livraria não deve ir para AOT agora, enquanto o frete pode.

Ver resposta

✓ Resposta: IL2026 indica uso de um membro marcado com [RequiresUnreferencedCode]: código que depende de reflexão sobre tipos que o trimming pode remover, e que portanto pode falhar em tempo de execução. IL3050 indica uso de um membro marcado com [RequiresDynamicCode]: código que gera código em tempo de execução, impossível sem JIT. O JsonSerializer.Serialize(objeto) sem contexto usa reflexão para descobrir as propriedades, e a correção é passar ContextoDaApi.Default.Tipo ou registrar o contexto na cadeia de resolvedores. Os avisos do EF Core vêm do fato de o EF construir modelos e consultas por reflexão e expressões compiladas. O suporte a AOT do EF Core é experimental, com restrições às consultas suportadas, e colocar a API de vendas, a parte mais crítica do sistema, sobre um recurso experimental é um risco sem necessidade: a API já arranca em tempo aceitável e roda sempre ligada. O frete não usa EF, os avisos dele são zero ou facilmente corrigíveis, e ele se beneficia de verdade do arranque rápido e da memória baixa.

Exercício 3

Transforme o comando livraria produtos importar para mostrar uma barra de progresso no terminal e, ao mesmo tempo, funcionar bem quando rodado num pipeline de CI sem terminal interativo. Que verificação decide o modo?

Ver resposta

✓ Resposta: Com Spectre.Console: if (AnsiConsole.Profile.Capabilities.Interactive && !Console.IsOutputRedirected) { await AnsiConsole.Progress().StartAsync(async ctx => { var tarefa = ctx.AddTask("Importando", maxValue: total); foreach (var lote in lotes) { await ImportarLoteAsync(lote, ct); tarefa.Increment(lote.Count); } }); } else { foreach (var (lote, i) in lotes.Select((l, i) => (l, i))) { await ImportarLoteAsync(lote, ct); Console.Error.WriteLine($"lote {i + 1}/{totalDeLotes} importado"); } }. A verificação combina a capacidade interativa detectada pela biblioteca e se a saída está redirecionada (Console.IsOutputRedirected). Em CI, a barra animada produziria centenas de linhas de caracteres de controle no log. No modo não interativo, o progresso vai para stderr em linhas simples, e stdout fica reservado para o resultado (por exemplo, o resumo em JSON com --formato json). Uma variável de ambiente como NO_COLOR, uma convenção amplamente respeitada, também deve desligar as cores.

Exercício 4

A ferramenta administrativa roda de madrugada, num contêiner agendado, para reprocessar os vetores de busca. Por que o fluxo de dispositivo (RFC 8628) não serve aqui, e qual fluxo usar?

Ver resposta

✓ Resposta: O fluxo de dispositivo exige que uma pessoa abra o navegador e aprove o código. Num contêiner agendado para as três da manhã não há ninguém. O fluxo certo é client credentials (OAuth 2.0): a tarefa é registrada no provedor de identidade como um cliente de serviço, com seu próprio client_id e um segredo (ou, melhor, um certificado, ou uma identidade de carga de trabalho do orquestrador), e obtém tokens em nome dela mesma, e não de um usuário. Esse cliente recebe só as permissões necessárias (um papel como "reindexar-busca", e não "admin"), e as ações aparecem na auditoria com a identidade da tarefa. O segredo vem dos segredos do orquestrador, nunca da imagem. A ferramenta suporta os dois modos: livraria login para pessoas, e as variáveis LIVRARIA_CLIENT_ID e LIVRARIA_CLIENT_SECRET (ou o arquivo de credencial montado) para execução não assistida.

Exercício 5

Escreva um benchmark que compare a geração de slugs do artigo "Razor Pages" (com StringBuilder) com uma versão usando string.Create e stackalloc, e descreva como interpretar um resultado em que a segunda é mais rápida mas o Ratio de memória é igual.

Ver resposta

✓ Resposta: Classe com [MemoryDiagnoser], [Params("Dom Casmurro", "Memórias Póstumas de Brás Cubas", "O Cortiço — Edição Comentada (2ª ed.)")] e dois métodos: [Benchmark(Baseline = true)] public string ComStringBuilder() => Slugs.De(Titulo); e [Benchmark] public string ComStringCreate(), que normaliza para FormD, filtra os caracteres para um Span<char> alocado com stackalloc (limitado a um tamanho razoável, com recurso a ArrayPool para títulos maiores) e cria a string final com new string(span[..n]) ou string.Create. Se a segunda é mais rápida e aloca o mesmo número de bytes, o resultado faz sentido: as duas precisam alocar a string final, e também a string normalizada que Normalize(FormD) devolve. A economia do StringBuilder (o objeto e seu buffer interno) pode ser pequena perto dessas duas, ou o StringBuilder pode estar sendo reaproveitado de um cache interno do runtime. O ganho de tempo vem de menos cópias e verificações. A interpretação: o gargalo de memória está em Normalize, e uma otimização real teria que evitá-la (por exemplo, com uma tabela de remoção de acentos para os caracteres Latin-1 mais comuns). E, como sempre, a pergunta anterior: gerar slugs é um gargalo? Na Livraria, acontece uma vez por renderização de cartão, com cache de página, e a resposta provavelmente é não.

Comentários

Mais em Linguagem C#

Worker Services e mensageria com RabbitMQ: trabalho fora da requisição
Worker Services e mensageria com RabbitMQ: trabalho fora da requisição

Os efeitos da venda saem da requisição: BackgroundService com Channel…

Autenticação e autorização com JWT: quem é você e o que pode fazer
Autenticação e autorização com JWT: quem é você e o que pode fazer

Quem pode fazer o quê na API da Livraria: 401 contra 403, a anatomia do JWT…

Nullable Reference Types: o Fim Anunciado do NullReferenceException
Nullable Reference Types: o Fim Anunciado do NullReferenceException

Tipos de referência anuláveis no C#: string e string?, a análise de fluxo que…