paint-brush
Como identificar e evitar a fragmentação de heap em aplicativos Rustby@tomhacohen
906
906

Como identificar e evitar a fragmentação de heap em aplicativos Rust

Tom Hacohen8m2023/06/27
Read on Terminal Reader

O projeto Rust teve um crescimento inesperado da memória. O uso de memória aumentou desproporcionalmente. O crescimento ilimitado da memória pode forçar a saída dos serviços. A combinação mágica para mim foi: *corpos de solicitação maiores* e *maior simultaneidade. As particularidades deste "degrau" específico são específicas do próprio aplicativo.
featured image - Como identificar e evitar a fragmentação de heap em aplicativos Rust
Tom Hacohen HackerNoon profile picture
0-item
1-item
2-item

O mistério do perfil do degrau da escada

Recentemente, vimos um de nossos projetos Rust , um serviço axum , exibir um comportamento estranho no que diz respeito ao uso de memória. Um perfil de memória de aparência estranha é a última coisa que eu esperaria de um programa Rust, mas aqui estamos nós.



O serviço seria executado com memória "plana" por um período de tempo e, de repente, saltaria para um novo patamar. Esse padrão se repetia ao longo de horas, às vezes sob carga, mas nem sempre. A parte preocupante foi que, quando vimos um aumento acentuado, era raro a memória cair de volta. Era como se a memória tivesse se perdido ou "vazado" de vez em quando.


Em circunstâncias normais, esse perfil de "degrau" era apenas de aparência estranha, mas em um ponto o uso de memória aumentou desproporcionalmente. O crescimento ilimitado da memória pode forçar a saída dos serviços. Quando os serviços terminam abruptamente, isso pode diminuir a disponibilidade... o que é ruim para os negócios . Eu queria cavar e descobrir o que estava acontecendo.


Normalmente, quando penso em crescimento inesperado de memória em um programa, penso em vazamentos. Ainda assim, isso parecia diferente. Com um vazamento, você tende a ver um padrão de crescimento mais estável e regular.


Muitas vezes, isso parece uma linha inclinada para cima e para a direita. Então, se nosso serviço não estava vazando, o que ele estava fazendo?


Se eu pudesse identificar as condições que causaram o salto no uso da memória, talvez pudesse mitigar o que estava acontecendo.

Cavando

Eu tinha duas perguntas candentes:


  • Algo mudou em nosso código para promover esse comportamento?
  • Caso contrário, surgiu um novo padrão de tráfego?


Olhando para as métricas históricas, pude ver padrões semelhantes de aumentos acentuados entre longos períodos de estabilidade, mas nunca antes tivemos esse tipo de crescimento. Para saber se o crescimento em si era novo (apesar do padrão "degrau" ser normal para nós), eu precisaria de uma maneira confiável de reproduzir esse comportamento.


Se eu pudesse forçar o "passo" a se mostrar, terei uma maneira de verificar uma mudança de comportamento quando tomar medidas para conter o crescimento da memória. Eu também seria capaz de retroceder em nosso histórico do git e procurar um ponto no tempo em que o serviço não exibisse um crescimento aparentemente ilimitado.

As dimensões que usei ao executar meus testes de carga foram:


  • O tamanho dos corpos POST enviados ao serviço.

  • A taxa de solicitação (ou seja, solicitações por segundo).

  • O número de conexões de cliente simultâneas.


A combinação mágica para mim foi: corpos de solicitação maiores e maior simultaneidade .


Ao executar testes de carga em um sistema local, existem todos os tipos de fatores limitantes, incluindo o número finito de processadores disponíveis para executar clientes e o próprio servidor. Ainda assim, consegui ver o "degrau" na memória da minha máquina local nas circunstâncias certas, mesmo com uma taxa de solicitação geral mais baixa.


Usando uma carga útil de tamanho fixo e enviando solicitações em lotes, com um breve descanso entre elas, consegui aumentar a memória do serviço repetidamente, uma etapa por vez.


Achei interessante que, embora pudesse aumentar a memória com o tempo, acabei atingindo um ponto de retornos decrescentes. Eventualmente, haveria algum teto (ainda muito maior do que o esperado) para o crescimento. Brincando um pouco mais, descobri que poderia atingir um teto ainda mais alto enviando solicitações com tamanhos de carga variados.


Assim que identifiquei minha entrada, fui capaz de retroceder em nosso histórico do git, descobrindo que nosso susto na produção provavelmente não era o resultado de mudanças recentes de nossa parte.

Os detalhes da carga de trabalho para acionar esse "degrau" são específicos do próprio aplicativo, embora eu tenha conseguido forçar um gráfico semelhante a acontecer com um projeto de brinquedo .


 #[derive(serde::Deserialize, Clone)] struct Widget { payload: serde_json::Value, } #[derive(serde::Serialize)] struct WidgetCreateResponse { id: String, size: usize, } async fn create_widget(Json(widget): Json<Widget>) -> Response { ( StatusCode::CREATED, Json(process_widget(widget.clone()).await), ) .into_response() } async fn process_widget(widget: Widget) -> WidgetCreateResponse { let widget_id = uuid::Uuid::new_v4(); let bytes = serde_json::to_vec(&widget.payload).unwrap_or_default(); // An arbitrary sleep to pad the handler latency as a stand-in for a more // complex code path. // Tweak the duration by setting the `SLEEP_MS` env var. tokio::time::sleep(std::time::Duration::from_millis( std::env::var("SLEEP_MS") .as_deref() .unwrap_or("150") .parse() .expect("invalid SLEEP_MS"), )) .await; WidgetCreateResponse { id: widget_id.to_string(), size: bytes.len(), } }

Acontece que você não precisava de muito para chegar lá. Consegui ver um aumento acentuado semelhante (mas neste caso muito menor) de um aplicativo axum com um único manipulador recebendo um corpo JSON.


Embora os aumentos de memória em meu projeto de brinquedo não tenham sido tão dramáticos quanto vimos no serviço de produção, foi o suficiente para me ajudar a comparar e contrastar durante a próxima fase de minha investigação. Também me ajudou a ter um loop de iteração mais rígido de uma base de código menor enquanto eu experimentava diferentes cargas de trabalho. Consulte o README para obter detalhes sobre como executei meus testes de carga.

Passei algum tempo pesquisando na web relatórios de bugs ou discussões que pudessem descrever um comportamento semelhante. Um termo que surgiu repetidamente foi Heap Fragmentation e depois de ler um pouco mais sobre o assunto, parecia que poderia se encaixar no que eu estava vendo.

O que é Fragmentação de Heap?

Pessoas de certa idade podem ter tido a experiência de assistir a um utilitário de desfragmentação no DOS ou no Windows mover blocos em um disco rígido para consolidar as áreas "usadas" e "livres".



No caso do disco rígido deste PC antigo, arquivos de tamanhos variados foram gravados no disco e depois movidos ou excluídos, deixando um "buraco" de espaço disponível entre outras regiões usadas. À medida que o disco começa a encher, você pode tentar criar um novo arquivo que não caiba em uma dessas áreas menores. No cenário de fragmentação de heap, isso levará a uma falha de alocação, embora o modo de falha da fragmentação de disco seja um pouco menos drástico. No disco, o arquivo precisará ser dividido em pedaços menores, o que torna o acesso muito menos eficiente (obrigado wongarsu pela correção ). A solução para a unidade de disco é "desfragmentar" (desfragmentar) a unidade para reorganizar esses blocos abertos em espaços contínuos.


Algo semelhante pode acontecer quando o alocador (a coisa responsável por gerenciar a alocação de memória em seu programa) adiciona e remove valores de tamanhos variados ao longo de um período de tempo. As lacunas que são muito pequenas e espalhadas por todo o heap podem levar a novos blocos "novos" de memória sendo alocados para acomodar um novo valor que não caberia de outra forma. Embora, infelizmente, por causa de como o gerenciamento de memória funciona, uma "desfragmentação" não seja possível.


A causa específica para a fragmentação pode ser uma série de coisas: análise JSON com serde , algo no nível do framework em axum , algo mais profundo em tokio , ou mesmo apenas uma peculiaridade da implementação do alocador específico para o sistema em questão. Mesmo sem saber a causa raiz (se é que existe), o comportamento é observável em nosso ambiente e, de certa forma, reproduzível em um aplicativo básico. (Atualização: mais investigações são necessárias, mas temos certeza de que é a análise JSON, veja nosso comentário no HackerN ews)


Se isso é o que estava acontecendo com a memória do processo, o que pode ser feito a respeito? Parece que seria difícil mudar a carga de trabalho para evitar a fragmentação. Também parece que seria complicado desenrolar todas as dependências em meu projeto para possivelmente encontrar uma causa raiz no código de como os eventos de fragmentação estão ocorrendo. Então, o que pode ser feito?

Jemalloc para o resgate

jemalloc é autodescrito como tendo como objetivo "[enfatizar] a prevenção da fragmentação e o suporte à simultaneidade escalável". A simultaneidade era de fato uma parte do problema do meu programa, e evitar a fragmentação é o nome do jogo. jemalloc parece que pode ser exatamente o que eu preciso.

Como jemalloc é um alocador que se esforça para evitar a fragmentação em primeiro lugar, a esperança era que nosso serviço pudesse ser executado por mais tempo sem aumentar gradualmente a memória.


Não é tão trivial alterar as entradas do meu programa ou a pilha de dependências do aplicativo. No entanto, é trivial trocar o alocador.


Seguindo os exemplos no leia-me https://github.com/tikv/jemallocator , foi necessário muito pouco trabalho para fazer um test drive.


Para o meu projeto de brinquedo , adicionei um recurso de carga para trocar opcionalmente o alocador padrão por jemalloc e executei novamente meus testes de carga.


A gravação da memória residente durante minha carga simulada mostra os dois perfis de memória distintos.

Sem jemalloc , vemos o familiar perfil do degrau da escada. Com jemalloc , vemos a memória aumentar e diminuir repetidamente conforme o teste é executado. Mais importante, embora haja uma diferença considerável entre o uso de memória com jemalloc durante o carregamento e os tempos ociosos, não "perdemos terreno" como fizemos antes, pois a memória sempre volta à linha de base.

Empacotando

Se acontecer de você ver um perfil de "degrau" em um serviço Rust, considere fazer um teste de jemalloc . Se você tiver uma carga de trabalho que promova a fragmentação do heap, jemalloc pode fornecer um resultado geral melhor.


Separadamente, incluído no repositório do projeto de brinquedo está um benchmark.yml para uso com a ferramenta de teste de carga https://github.com/fcsonline/drill . Tente alterar a simultaneidade, o tamanho do corpo (e a duração arbitrária da suspensão do manipulador no próprio serviço), etc., para ver como a alteração no alocador afeta o perfil de memória.


Quanto ao impacto no mundo real, você pode ver claramente a mudança de perfil quando implementamos a mudança para jemalloc .


Onde o serviço costumava mostrar linhas planas e etapas grandes, geralmente independentemente da carga, agora vemos uma linha mais irregular que segue mais de perto a carga de trabalho ativa. Além do benefício de ajudar o serviço a evitar o crescimento desnecessário de memória, essa alteração nos deu uma visão melhor de como nosso serviço responde à carga, portanto, no geral, esse foi um resultado positivo.


Se você tem interesse em construir um serviço robusto e escalável usando Rust, estamos contratando! Confira nossa página de carreiras para mais informações.


Para mais conteúdo como este, siga-nos no Twitter , Github ou RSS para obter as atualizações mais recentes do serviço webhook Svix , ou participe da discussão em nossa comunidade Slack .


Também publicado aqui.