Pode me chamar de Juscélio Reis
Posted on February 7, 2021
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>
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
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:
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:
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:
- Running with Server GC in a Small Container Scenario Part 0
- Running with Server GC in a Small Container Scenario Part 1 – Hard Limit for the GC Heap
Referencia
Vou deixar uma lista de conteúdo para você conhecer mais sobre o assunto.
- Workstation GC for server applications?
- Garbage Collection at Food Courts
- Run-time configuration options for garbage collection
- Understanding different GC modes with Concurrency Visualizer
- Middle Ground between Server and Workstation GC
- Workstation and server garbage collection
- Fundamentals of garbage collection
Posted on February 7, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.