Mudanças no Garbage Collector do .NET 10

O garbage collector do .NET 10 traz uma mudança que pode alterar o consumo de memória e a latência das suas aplicações logo após a migração. Por isso, entender o que mudou e como medir o impacto é tão importante quanto atualizar o target framework.

O GC é um dos componentes mais críticos de performance e previsibilidade em aplicações modernas. Ele automatiza o ciclo de vida dos objetos alocados no heap gerenciado, equilibrando throughput, latência e consumo de memória.

À medida que o runtime evolui, o GC também muda, e o garbage collector do .NET 10 segue essa lógica. Isso afeta diretamente aplicações com alto volume de alocação, serviços com cargas variadas e sistemas que precisam de baixa latência.

Visão geral do GC no .NET e por que ele evolui

O GC do .NET foi projetado para tornar o gerenciamento de memória produtivo e seguro, removendo a necessidade de liberação manual. Ainda assim, ele precisa tomar decisões sofisticadas o tempo todo, como estas:

  • Quando executar uma coleta.
  • Quais regiões de memória inspecionar.
  • Como compactar e reduzir a fragmentação.
  • De que forma lidar com diferentes tamanhos e perfis de objetos.
  • Como distribuir o trabalho entre threads, no workstation GC e no server GC.

No garbage collector do .NET 10, essas decisões deixam de ser apenas técnicas. Na prática, elas se ligam diretamente a métricas de produção:

  • Latência no percentil 99 (P99).
  • Custo de infraestrutura, ou seja, a memória provisionada.
  • Estabilidade sob carga.
  • Tempo de resposta em picos de tráfego.

Se você quer revisar os fundamentos antes de seguir, o artigo garbage collector em C#: o que é e como funciona explica gerações, heap e coletas em detalhes.

Workstation GC e server GC

O .NET oferece dois modos principais de coleta. O workstation GC foi pensado para aplicações cliente e é o padrão em aplicações standalone.

Já o server GC foca em throughput e escalabilidade. Ele trata o processo como o dono da máquina e, por isso, cria um heap por núcleo de CPU disponível.

Por exemplo, aplicações ASP.NET Core usam o server GC por padrão. Por outro lado, aplicações de desktop e cenários interativos tendem a se beneficiar do workstation GC, dependendo das metas de latência e responsividade.

Você define o modo explicitamente no arquivo de projeto:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <!-- Usa o server GC, padrão em aplicações ASP.NET Core -->
    <ServerGarbageCollection>true</ServerGarbageCollection>
    <!-- Mantém o background GC ativo -->
    <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
  </PropertyGroup>

</Project>

O problema do server GC tradicional, que o garbage collector do .NET 10 tenta resolver, é que o tamanho do heap depende da máquina. Assim, o mesmo serviço pode usar muito mais memória ao ser movido para um servidor com mais núcleos, mesmo fazendo o mesmo trabalho.

O que muda no garbage collector do .NET 10

A principal mudança no garbage collector do .NET 10 é o DATAS, sigla para Dynamic Adaptation To Application Sizes. Ele surgiu como recurso opcional no .NET 8 e passou a vir ativado por padrão no .NET 9 para quem usa server GC.

Porém, o .NET 9 não é uma versão LTS. Portanto, muitas equipes vão encontrar o DATAS pela primeira vez justamente ao migrar para o .NET 10, que é LTS.

Foi por isso que Maoni, arquiteta do GC do .NET na Microsoft, escreveu o artigo Preparing for the .NET 10 GC. Segundo ela, quem usa server GC pode notar um perfil de performance bem mais diferente do que em upgrades anteriores do runtime.

A própria autora explica a escolha do título. Normalmente, features do GC não exigem intervenção do usuário, mas o DATAS é diferente, e por isso o texto fala em preparação, não apenas em novidade.

Na prática, o uso de memória no garbage collector do .NET 10 pode ficar drasticamente diferente, muito provavelmente menor. Então, cabe a cada equipe verificar se esse comportamento combina com seus objetivos de otimização.

Como o DATAS funciona no garbage collector do .NET 10

No garbage collector do .NET 10, o objetivo do DATAS é fazer o tamanho do heap acompanhar a necessidade real da aplicação. Em outras palavras, o heap fica aproximadamente proporcional ao tamanho dos dados de longa duração.

Assim, se o app faz o mesmo trabalho em máquinas com especificações diferentes, o heap fica igual ou parecido. Além disso, quando a carga fica mais leve ou mais pesada, o heap se ajusta de acordo.

Para isso, o DATAS faz quatro coisas, segundo a documentação oficial do DATAS:

  1. Define o máximo de alocações permitidas antes da próxima coleta com base no tamanho dos dados de longa duração.
  2. Define o volume real de alocações permitido com base no throughput.
  3. Ajusta o número de heaps conforme a necessidade.
  4. Executa coletas completas com compactação quando a fragmentação fica alta demais.

Orçamento da gen0 e live data size

O ponto central é o orçamento da geração 0, isto é, quanto a aplicação pode alocar antes de disparar a próxima coleta. O DATAS calcula esse teto a partir do live data size (LDS), o volume de dados que sobrevive às coletas.

A fórmula usa um multiplicador: 20 menos o valor de conserve_memory, dividido pela raiz quadrada do LDS em MB. Como o conserve_memory vale 5 por padrão no DATAS, o resultado fica 15 / sqrt(LDS em MB), limitado entre 0,1 e 10.

Veja como o orçamento muda conforme o tamanho da aplicação:

LDS Multiplicador Orçamento da gen0
1 MB 10,00 10 MB
10 MB 4,74 47 MB
100 MB 1,50 150 MB
1 GB 0,47 474 MB
10 GB 0,15 1,5 GB

Ou seja, aplicações pequenas ganham multiplicadores maiores, enquanto aplicações grandes ganham multiplicadores menores. Dessa forma, o heap não explode só porque a máquina tem muitos núcleos.

Número de heaps dinâmico

No server GC tradicional, o número de heaps acompanha o número de núcleos, mas o garbage collector do .NET 10 muda essa regra. No DATAS, o processo começa com um único heap e cresce ou diminui conforme a carga.

Com isso, o DATAS funciona como um híbrido entre os modos existentes. Ele pode usar tão pouco quanto um heap, como o workstation GC, ou tantos quanto os núcleos da máquina, como o server GC.

Contudo, começar com um heap tem um custo. Se muitas threads alocam ao mesmo tempo no início, algumas precisam esperar até o GC aumentar a quantidade de heaps.

Throughput cost percentage

Para equilibrar memória e desempenho, o DATAS mira um throughput cost percentage (TCP) de 2%. Essa métrica considera as pausas do GC e o tempo que as alocações precisam esperar.

Normalmente, as pausas dominam, então o percentual de tempo pausado em GC serve como boa aproximação. No entanto, o tempo de espera das alocações pesa mais em dois momentos: na inicialização do processo e na transição de uma carga leve para uma pesada.

Mudanças de comportamento e sintomas

O GC não é apenas uma implementação de coleta automática. Na verdade, ele é um conjunto de heurísticas com impacto direto em throughput e pausas.

Por isso, aplicações no garbage collector do .NET 10 podem ter variações de comportamento que antes eram pouco perceptíveis. Ao migrar, principalmente em sistemas de alta escala, monitore estes sinais:

  • Aumento ou redução no número total de coletas por minuto.
  • Alteração no tempo médio de pausa.
  • Crescimento do heap em padrões diferentes do esperado.
  • Mudanças na frequência de coletas da geração 2.
  • Fragmentação e custo de compactação.

Os benchmarks da Microsoft mostram o tamanho dessa mudança no garbage collector do .NET 10. Nos testes TechEmpower JSON e Fortunes, em uma máquina Linux com 48 núcleos, o working set caiu mais de 80%, enquanto o throughput máximo caiu de 2% a 3%.

Além disso, o número de coletas das gerações 0 e 1 aumenta bastante com o DATAS ativo. Isso é esperado, porque o orçamento de alocação fica menor.

Em um dos casos de cliente citados por Maoni, o orçamento da gen0 caiu de 4,22 GB para 1,64 GB. Ao mesmo tempo, o percentual de pausa subiu de 1,2% para 2,1%, bem próximo da meta de 2%.

Quando desativar o DATAS no garbage collector do .NET 10

O DATAS não serve para todos os cenários. A própria autora lista quatro situações em que desativá-lo no garbage collector do .NET 10 faz sentido.

O primeiro cenário surge quando você não tem uso para a memória liberada. Por exemplo, se o serviço roda sozinho em uma máquina dedicada e ninguém vai aproveitar a memória economizada, não há motivo para mudar.

O segundo envolve inicialização crítica. Como o DATAS sempre começa com um heap e leva tempo para crescer, o throughput na partida tende a regredir.

Em terceiro lugar vem a tolerância zero a regressões de throughput. Se o percentual de pausa atual já é muito baixo, como 1%, a meta de 2% do DATAS pode representar uma perda que você não aceita.

Por fim, cenários dominados por coletas da geração 2 também podem não se beneficiar do garbage collector do .NET 10 com DATAS. Muitas vezes, isso acontece por excesso de objetos grandes temporários, e vale investigar a causa antes de desligar o recurso.

Configurações do garbage collector do .NET 10

Com o garbage collector do .NET 10, depender de padrões implícitos vira um risco técnico. Portanto, a recomendação é definir explicitamente o modo de GC, documentar o motivo e validar com benchmark e observabilidade.

Para desativar o DATAS, use a propriedade GarbageCollectionAdaptationMode no projeto:

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
  <!-- 0 desativa o DATAS e volta ao server GC tradicional -->
  <GarbageCollectionAdaptationMode>0</GarbageCollectionAdaptationMode>
</PropertyGroup>

Também é possível configurar no runtimeconfig.template.json, que gera o runtimeconfig.json na publicação:

{
  "configProperties": {
    "System.GC.Server": true,
    "System.GC.DynamicAdaptationMode": 0
  }
}

Se preferir manter o DATAS e apenas ajustá-lo, o garbage collector do .NET 10 trouxe novas opções. A System.GC.DGen0GrowthPercent aplica um percentual sobre o multiplicador, e as opções DGen0GrowthMaxFactor e DGen0GrowthMinFactor mudam os limites em permil.

{
  "configProperties": {
    "System.GC.Server": true,
    "System.GC.DGen0GrowthPercent": 200,
    "System.GC.DTargetTCP": 5
  }
}

Nesse exemplo, o valor 200 dobra o multiplicador calculado pelo DATAS. Já o DTargetTCP igual a 5 aceita até 5% de custo de throughput, em vez dos 2% padrão.

Em contêineres, as variáveis de ambiente costumam ser mais práticas. Lembre que os valores numéricos das variáveis usam notação hexadecimal:

# Desativa o DATAS
export DOTNET_GCDynamicAdaptationMode=0

# Dobra o multiplicador da gen0 (200 em hexadecimal é C8)
export DOTNET_GCDGen0GrowthPercent=C8

# Define a meta de TCP em 5%
export DOTNET_GCDTargetTCP=5

Atenção a um detalhe importante: definir GCHeapCount desativa o DATAS. Isso acontece porque essa opção manda o GC não mudar o número de heaps, o que contradiz o funcionamento do recurso.

Essas configurações só são lidas na inicialização do GC. Assim, alterar uma variável de ambiente com o processo rodando não tem efeito até o próximo restart.

Como medir o impacto da mudança

A melhor forma de medir o garbage collector do .NET 10 é capturar um trace de GC com e sem o DATAS. No PerfView, a visão GCStats mostra a coluna Pause Time, que aproxima o TCP, e a coluna Gen0 Alloc MB, que aproxima o orçamento da gen0.

Também vale expor métricas da própria aplicação. O código abaixo usa APIs do namespace System e System.Runtime para registrar o estado do GC em um endpoint de diagnóstico:

using System.Runtime;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/diagnostics/gc", () =>
{
    // Informações da última coleta realizada
    var info = GC.GetGCMemoryInfo();

    return Results.Ok(new
    {
        IsServerGc = GCSettings.IsServerGC,
        HeapSizeMb = info.HeapSizeBytes / 1024 / 1024,
        PauseTimePercentage = info.PauseTimePercentage,
        Gen0Collections = GC.CollectionCount(0),
        Gen1Collections = GC.CollectionCount(1),
        Gen2Collections = GC.CollectionCount(2),
        // Lista as configurações efetivas do GC no processo
        Settings = GC.GetConfigurationVariables()
    });
});

app.Run();

Para acompanhar em tempo real, use a ferramenta dotnet-counters. Ela mostra tamanho do heap, coletas por geração e tempo em GC sem alterar o código:

# Instala a ferramenta global
dotnet tool install --global dotnet-counters

# Monitora os contadores de runtime do processo
dotnet-counters monitor --name OrdersApi --counters System.Runtime

Compare sempre os números do garbage collector do .NET 10 com uma linha de base sem o DATAS. Caso contrário, você não sabe se a mudança veio do GC ou de outra parte da migração.

Recomendações práticas para migrar ao garbage collector do .NET 10

Com base no artigo oficial, a melhor forma de se preparar para o garbage collector do .NET 10 envolve um conjunto direto de ações:

  1. Estabeleça uma linha de base com métricas de GC e consumo na versão atual.
  2. Migre para um ambiente de teste com o runtime do .NET 10.
  3. Execute cargas representativas, evitando testes sintéticos que não reflitam o tráfego real.
  4. Compare indicadores de latência, sobretudo P95 e P99.
  5. Valide os limites de memória, especialmente em contêineres.
  6. Documente as decisões de configuração, mantendo rastreabilidade.

Além disso, revise configurações antigas. Por exemplo, quem limitava o número de heaps manualmente para conter memória pode remover essa opção e testar o DATAS com ajustes no multiplicador.

Em um segundo caso citado por Maoni, um processo que usava dois heaps fixos teve o percentual de pausa reduzido drasticamente após adotar o DATAS com o multiplicador dobrado. Por isso, teste antes de decidir desligar o recurso.

Se a migração também envolve outras mudanças do runtime, o artigo sobre a mudança no TimeSpan.FromMilliseconds no .NET 10 mostra outro ponto que merece atenção.

Conclusão

O garbage collector não é um detalhe interno do runtime. Pelo contrário, ele é parte ativa do comportamento operacional de qualquer sistema em .NET.

No garbage collector do .NET 10, o DATAS ajusta o heap ao tamanho real da aplicação, reduzindo bastante a memória usada em troca de um pequeno custo de throughput. Para a maioria dos serviços em contêineres, essa troca vale a pena.

Ainda assim, a adoção do .NET 10 deve vir acompanhada de instrumentação, benchmark e validação. Afinal, ajustes no GC podem alterar padrões de consumo de memória e latência.

Em resumo, trate o GC como uma dimensão de arquitetura e operação. Meça, entenda e configure de forma intencional, reduzindo riscos e aumentando a previsibilidade.

FAQ

O DATAS vale para o workstation GC?

Não. O DATAS atua sobre o server GC, que é o modo padrão das aplicações ASP.NET Core.

O DATAS é novidade do .NET 10?

Não exatamente. Ele surgiu como opcional no .NET 8 e ficou ativo por padrão no .NET 9, mas muita gente vai conhecê-lo ao migrar para o .NET 10, que é LTS.

Como desativo o DATAS?

Defina GarbageCollectionAdaptationMode como 0 no projeto, System.GC.DynamicAdaptationMode como 0 no runtimeconfig ou DOTNET_GCDynamicAdaptationMode=0 no ambiente.

Por que minha aplicação faz mais coletas depois da migração?

Com o DATAS, o orçamento da gen0 fica menor e proporcional aos dados de longa duração. Por isso, coletas das gerações 0 e 1 ficam mais frequentes.

Configurar GCHeapCount afeta o DATAS?

Sim. Definir GCHeapCount desativa o DATAS, porque impede o GC de ajustar o número de heaps.