GC: Otimizando para o ambiente do Azure

juscelior

Pode me chamar de Juscélio Reis

Posted on February 7, 2021

GC: Otimizando para o ambiente do Azure

Neste artigo, pretendo abordar um assunto complicado e que pode ser usado para melhorar nossas aplicações hospedados na nuvem da Microsoft Azure. Vou abordar o que exatamente acontece durante a coleta de lixo (GC) e como diferentes modos de GC podem afetar significativamente o desempenho do aplicativo. Mas antes vamos entender qual o ambiente que estamos executando nossas aplicações.

Azure

O que mais consumimos do Azure aqui na Wiz é o serviço de Aplicativo, ou se preferir chamar App Service. Esse serviço quando criado é composto de alguns nomes ou outros serviços. Sendo eles:

  • App Service Plan (Plano de Serviço de Aplicativo): Um Plano de Serviço de Aplicativo consiste nas máquinas virtuais alocadas que hospedarão os Serviços de Aplicativo do Azure. Possui vários níveis, do Gratuito ao Premium, seu orçamento é o limite. O Plano de Serviço de Aplicativo define a região do servidor físico onde seu aplicativo será hospedado e a quantidade de armazenamento, RAM e CPU que os servidores físicos terão.

  • App Service (Serviço de Aplicativo): O Serviço de Aplicativo do Azure é um serviço baseado em HTTP para hospedar aplicativos da web, APIs REST e back-ends móveis.

  • Resource Group (grupo de recursos): Um grupo de recursos é um conjunto que contém recursos relacionados para uma solução do Azure. O grupo de recursos pode incluir todos os recursos da solução ou apenas os recursos que você deseja gerenciar como um grupo.

Agora que temos a nomenclatura do Azure para os serviços de APIs, vamos observas os planos de serviço, ou como aprendemos, vamos olhar nas opções do App Service Plan. Podemos dividir esse plano de serviço em três categorias:

  • Computação compartilhada: Free ou F1 e Shared, as duas camadas de base, executa um aplicativo na mesma VM do Azure que outros aplicativos de serviço de aplicativo, incluindo aplicativos de outros clientes. Essas camadas alocam cotas de CPU para cada aplicativo executado nos recursos compartilhados, e os recursos não podem ser escalonados. Esse plano possui um limite de tempo e não permite a opção de configurar as apis em dotnet como 64 bits. Esse plano é um grande problema para nossas apis, pois a combinação do dotnet core 3.1 com uma configuração de 32 bits vai resultar em erro de estouro de memória, em outras palavras não usem em ambiente de homologação ou produção.

  • Computação dedicada: as camadas Basic, Standard, Premium, PremiumV2 e PremiumV3 executam aplicativos em VMs do Azure dedicadas. Somente aplicativos no mesmo App Service Plan compartilham os mesmos recursos de computação. Quanto mais alto o nível, mais instâncias de VM estão disponíveis para você escalar horizontalmente. Essa é a opção que usamos em homologação e produção, em sua grande maioria usamos o plano S1 ou Standard 1, em alguns casos usamos o plano B1 ou Basic, lembrando que não é recomendado pela Microsoft a utilização do plano Basic para produção.

  • Isolado: esta camada executa VMs do Azure dedicadas em Redes Virtuais do Azure dedicadas. Ele fornece isolamento de rede além do isolamento de computação para seus aplicativos. Ele fornece os recursos de expansão máxima. No momento vamos ignorar esse modo aqui.

Preste atenção aqui

O plano B1 possui 1 núcleo de CPU e 1.75 GB de ram, pode receber até 3 instancias de aplicativos, além de outras limitações do plano Basic. Mais informação aqui.

O plano S1 possui 1 núcleo de CPU e 1.75 GB de ram, mas pode receber até 10 instancias de aplicativos, além de outras limitações do plano Standard. Mais informação aqui.

Bom agora podemos voltar ao tema principal desse artigo garbage collection ou GC pros íntimos.

Garbage Collection

Vou colocar um pequeno resumo do GC aqui, só para entender qual o seu papel no nosso ambiente computacional. Mas será um breve resumo, mais informação ler esse artigo aqui.

O garbage collection (GC) atua como um gerenciador de memória automático. O garbage collection gerencia a alocação e liberação de memória para um aplicativo. O gerenciamento automático de memória pode eliminar problemas comuns, como esquecer de liberar um objeto e causar um vazamento de memória ou tentar acessar a memória de um objeto que já foi liberado.

A coleta de lixo ocorre quando uma das seguintes condições é verdadeira:

  • O sistema está com pouca memória física. Isso é detectado pela notificação de memória insuficiente do sistema operacional ou memória insuficiente, conforme indicado pelo host.

  • A memória usada por objetos alocados no heap gerenciado ultrapassa um limite aceitável. Este limite é continuamente ajustado conforme o processo é executado.

  • O método GC.Collect é chamado. Em quase todos os casos, você não precisa chamar esse método, porque o coletor de lixo é executado continuamente. Este método é usado principalmente para situações e testes únicos.

O algoritmo GC é baseado em várias premissas:

  • É mais rápido compactar a memória para uma parte do heap gerenciado do que para todo o heap gerenciado.
  • Objetos mais novos têm vida útil mais curta e objetos mais antigos têm vida útil mais longa.
  • Objetos mais novos tendem a estar relacionados entre si e acessados pelo aplicativo ao mesmo tempo.

Como vemos a estratégia do GC é dividir o heap em partes considerando a idade dos objetos para otimizar o desempenho, o heap gerenciado é dividido em três gerações, 0, 1 e 2.

  • Geração 0: Esta é a geração mais jovem e contém objetos de curta duração. Um exemplo de objeto de vida curta é uma variável temporária. A coleta de lixo ocorre com mais frequência nesta geração.

  • Geração 1: Esta geração contém objetos de vida curta e serve como um buffer entre objetos de vida curta e objetos de vida longa.

  • Geração 2: Esta geração contém objetos de vida longa. Um exemplo de objeto de longa duração é um objeto em um aplicativo de servidor que contém dados estáticos que estão ativos durante o processo. Mais um motivo para nunca encher nossa aplicação com objetos estáticos.

Objetos que não são recuperados em uma coleta de lixo são conhecidos como sobreviventes e são promovidos para a próxima geração:

  • Os objetos que sobrevivem a uma coleta de lixo da geração 0 são promovidos para a geração 1.
  • Os objetos que sobrevivem a uma coleta de lixo da geração 1 são promovidos para a geração 2.
  • Os objetos que sobrevivem a uma coleta de lixo da geração 2 permanecem na geração 2.

Até aqui podemos entender que os objetos da geração 0 e 1 são os mais voláteis ou como está na documentação são objetos efêmeros. Já os objetos da geração 2 são mais permanentes então não faz tanto sentido fazer uma coleta de lixo completa, podemos focar mais nas gerações 0 e 1 que vamos obter mais sucesso. Mais um motivo para evitar a utilização de objetos estáticos e de tentar chamar o GC.Collect manualmente.

*Estamos chegando na parte de otimização, até aqui foi um grande resumo do resumo para começar o processo de otimização. *

O tamanho do segmento efêmero varia dependendo se um sistema é de 32 ou 64 bits e do tipo de coletor de lixo que está executando (Workstation ou Server GC). A tabela a seguir mostra os tamanhos padrão do segmento efêmero.

Workstation/server GC 32-bit 64-bit
Workstation GC 16 MB 256 MB
Server GC 64 MB 4 GB
Server GC with > 4 logical CPUs 32 MB 2 GB
Server GC with > 8 logical CPUs 16 MB 1 GB

Acabamos de saber que o existem dois tipos de GC, um chamado Workstation GC e outro chamado Server GC. Bora entender esses caras, pois aqui está a chave para a otimização das nossas APIs que estão executando no Azure. Se não entendeu olha o tamanho que elas possuem no ambiente de 64 bits e compara com o tamanho do App Service Plan B1 e S1, vai perceber que a matemática não vai bater.

O GC é inteligente e pode funcionar em uma ampla variedade de cenários. No entanto, podemos definir o tipo de GC com base nas características da carga de trabalho, em outras palavras de acordo com o App Service Plan. O dotnet fornece os seguintes tipos de GC:

  • Workstation: GC da estação de trabalho, que é projetada para aplicativos cliente. É o padrão do GC para aplicativos independentes. Para aplicativos hospedados, por exemplo, aqueles hospedados pelo ASP.NET, o host determina o padrão do GC. Vai um spoiler vai ser o tipo Server.

  • Server: GC que se destina a aplicativos de servidor que precisam de alto rendimento e escalabilidade.

Otimização

Aqui é onde vou citar o blog da microsoft, a visão que eles possuíam do ambiente de hospedagem das aplicações dotnet:

Keep in mind that it’s very common to run only one server process on a dedicated machine so you don’t get into this situation.
-- Maoni, Workstation GC for server applications?

Não é o que estamos utilizando no contexto atual, usamos servidores com 1 núcleo e 1.75GB de ram para hospedar de 3 a 10 aplicações. Essa é a questão!!! Precisamos mudar o padrão e utilizar como uma aplicação que vai funcionar na máquina do cliente em outras palavras precisamos sair do Server GC para o Workstation GC. Mas ao começar essa otimização precisamos alterar uma tag no .Api.csproj, vamos colocar a tag *ServerGarbageCollection como false. Mais informação aqui.

Porem ao alterar essa tag precisamos alterar outra ConcurrentGarbageCollection para false, se você está executando várias instâncias de aplicativo, considere o uso do Workstation GC com a coleta de lixo simultânea desabilitada, ConcurrentGarbageCollection false. Isso resultará em menos troca de contexto, o que pode melhorar o desempenho.

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

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <IncludeOpenAPIAnalyzers>true</IncludeOpenAPIAnalyzers>
    <ServerGarbageCollection>false</ServerGarbageCollection>
    <ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
  </PropertyGroup>
Enter fullscreen mode Exit fullscreen mode

Fiz um teste e você pode ver como a combinação de ServerGarbageCollection false com ConcurrentGarbageCollection true aumenta o tempo de processamento.


BenchmarkDotNet=v0.12.1, OS=ubuntu 20.04
Intel Core i7-7500U CPU 2.70GHz (Kaby Lake), 1 CPU, 2 logical cores and 1 physical core
.NET Core SDK=3.1.405
  [Host]                : .NET Core 3.1.11 (CoreCLR 4.700.20.56602, CoreFX 4.700.20.56604), X64 RyuJIT
  Server                : .NET Core 3.1.11 (CoreCLR 4.700.20.56602, CoreFX 4.700.20.56604), X64 RyuJIT
  ServerConcurrent      : .NET Core 3.1.11 (CoreCLR 4.700.20.56602, CoreFX 4.700.20.56604), X64 RyuJIT
  Workstation           : .NET Core 3.1.11 (CoreCLR 4.700.20.56602, CoreFX 4.700.20.56604), X64 RyuJIT
  WorkstationConcurrent : .NET Core 3.1.11 (CoreCLR 4.700.20.56602, CoreFX 4.700.20.56604), X64 RyuJIT

IterationCount=15  LaunchCount=2  WarmupCount=10  

Enter fullscreen mode Exit fullscreen mode
Method Job Concurrent Server Mean Error StdDev Median Gen 0 Gen 1 Gen 2 Allocated
WithoutHttpCompletionOption Server False True 428.7 ms 72.57 ms 108.62 ms 428.8 ms - - - 389.94 KB
WithHttpCompletionOption Server False True 483.2 ms 54.60 ms 81.72 ms 516.0 ms - - - 146.13 KB
WithGetStreamAsync Server False True 458.3 ms 63.28 ms 94.72 ms 514.9 ms - - - 146.64 KB
WithoutHttpCompletionOption ServerConcurrent True True 519.6 ms 12.83 ms 17.98 ms 515.4 ms - - - 381.52 KB
WithHttpCompletionOption ServerConcurrent True True 354.8 ms 50.80 ms 72.85 ms 327.6 ms - - - 145.77 KB
WithGetStreamAsync ServerConcurrent True True 490.3 ms 75.63 ms 110.86 ms 516.2 ms - - - 148.85 KB
WithoutHttpCompletionOption Workstation False False 484.2 ms 57.89 ms 83.03 ms 513.9 ms - - - 307.69 KB
WithHttpCompletionOption Workstation False False 432.8 ms 75.27 ms 105.52 ms 508.2 ms - - - 146.98 KB
WithGetStreamAsync Workstation False False 580.9 ms 60.71 ms 87.07 ms 534.8 ms - - - 148.18 KB
WithoutHttpCompletionOption WorkstationConcurrent True False 544.1 ms 61.84 ms 90.64 ms 530.3 ms - - - 307.95 KB
WithHttpCompletionOption WorkstationConcurrent True False 661.8 ms 94.76 ms 132.85 ms 710.2 ms - - - 151.16 KB
WithGetStreamAsync WorkstationConcurrent True False 565.5 ms 129.98 ms 182.21 ms 541.9 ms - - - 143.86 KB

Exemplo

No mês de janeiro uma API começou a ter muitos problemas de estouro de memória, lembrando que essa aplicação está utilizando o plano B1 e não está sozinha no Service Plan. Olha como estava o consumo de memória na época:

Consumo de memória na epoca

Podemos notar que essa api consumia uns 280mb em média, mas teve um pico de 800mb. Depois de aplica essa otimização observe como está o consumo de memória um mês depois:

Consumo de memória um mês depois

O consumo de memória não passa de 200mb. Já está rodando a alguns dias e não voltamos a ter problemas com essa API.

O que foi possível observar é uma considerável redução do consumo de memoria e uma aplicação mais estável no Service Plan B1.

Estudos futuros

Existem outras opções que posso alterar, mas como é um assunto bem complicado fica para uma publicação futura. Nem comentei quando estamos trabalhando em um ambiente de container (ex: docker), podemos definir um limite para a heap sem precisar altera o tipo de GC. Caso queriam saber mais, curte esse material:

Referencia

Vou deixar uma lista de conteúdo para você conhecer mais sobre o assunto.


💖 💪 🙅 🚩
juscelior
Pode me chamar de Juscélio Reis

Posted on February 7, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related