Como diagnosticar e corrigir vazamentos de memória em Python
7 de março de 2017
0 minutos de leituraNota do editor
Este blog foi publicado originalmente em fugue.co. A Fugue se juntou à Snyk em 2022 e é um componente essencial do Snyk IaC.
A Fugue usa Python extensivamente em nosso produto SaaS de segurança na nuvem e em nossas ferramentas de suporte, por ser fácil de usar, oferecer segurança em Python, uma ampla biblioteca de pacotes e ferramentas avançadas para a linguagem. Ao criar softwares complexos para a nuvem, aprendemos que uma linguagem só é tão boa quanto suas ferramentas de depuração e criação de perfil. Erros de lógica, picos de CPU e vazamentos de memória são inevitáveis, mas um bom depurador e ferramentas de criação de perfil de CPU e memória podem facilitar muito e acelerar a identificação desses problemas, permitindo que nossos desenvolvedores voltem a criar o sistema dinâmico de orquestração e aplicação de políticas na nuvem da Fugue. Vamos ver um exemplo.
No outono, nossas métricas indicaram que um componente Python da Fugue chamado reflector apresentava reinicializações aleatórias e instabilidade após alguns dias em funcionamento. Ao observar o uso de memória, vimos que o consumo do reflector aumentava continuamente, indicando um vazamento de memória. O tracemalloc, uma poderosa ferramenta de rastreamento de memória incluída na biblioteca padrão do Python, permitiu diagnosticar e corrigir o vazamento rapidamente. Descobrimos que o problema estava relacionado ao uso do requests, uma popular biblioteca HTTP de terceiros para Python. Reescrever o componente para usar o urllib, da biblioteca padrão do Python, eliminou o vazamento de memória. Neste artigo, vamos explorar os detalhes.

Alocação de memória em Python
Na maioria dos casos, não é preciso entender o gerenciamento de memória do Python além de saber que o interpretador cuida disso para você. No entanto, ao escrever programas Python grandes e complexos com requisitos rigorosos de estabilidade, é útil olhar por trás dos bastidores para entender como escrever código que funcione bem com os algoritmos de gerenciamento de memória do Python. Vale destacar que o Python usa contagem de referências e coleta de lixo para liberar blocos de memória, e só devolve memória ao sistema quando determinados requisitos internos são atendidos. Um script Python puro nunca terá controle direto sobre a alocação de memória do interpretador. Se você precisar desse controle, poderá contornar a alocação de memória do interpretador escrevendo ou usando uma extensão. Por exemplo, o numpy gerencia a memória de grandes matrizes de dados com seu próprio alocador de memória.
Em essência, Python é uma linguagem com coleta de lixo que usa contagem de referências. O interpretador aloca memória automaticamente para os objetos à medida que são criados e acompanha o número de referências a eles em uma estrutura de dados associada ao próprio objeto. Essa memória é liberada quando a contagem de referências desses objetos chega a zero. Além disso, a coleta de lixo detecta ciclos e remove objetos referenciados somente por outros objetos nesses ciclos. Esses dois mecanismos conseguem liberar cada byte de memória alocado dentro do interpretador Python, mas não é possível afirmar o mesmo sobre a memória alocada em extensões.
O Python gerencia seu próprio heap, separado do heap do sistema. A memória é alocada no interpretador Python por métodos diferentes, de acordo com o tipo de objeto a ser criado. Tipos escalares, como inteiros e números de ponto flutuante, usam métodos de alocação de memória diferentes dos tipos compostos, como listas, tuplas e dicionários. Em geral, a memória é alocada no heap do Python em blocos de tamanho fixo, conforme o tipo. Esses blocos são organizados em pools, que, por sua vez, são organizados em arenas. A memória é pré-alocada em arenas, pools e blocos, que são usados para armazenar dados conforme a necessidade ao longo da execução do programa. Como esses blocos, pools e arenas ficam no heap próprio do Python, liberar um bloco de memória apenas o marca como disponível para uso futuro pelo interpretador. Liberar memória no Python não a devolve imediatamente ao sistema. Quando uma arena inteira é marcada como livre, o interpretador Python libera sua memória e a devolve ao sistema. No entanto, isso pode ocorrer raramente devido à fragmentação da memória.
Por causa dessas abstrações, o uso de memória em Python costuma apresentar um comportamento de pico, em que o pico de consumo determina o uso de memória pelo restante da execução, independentemente de essa memória continuar sendo usada. Além disso, a relação entre a memória "liberada" no código e a memória devolvida ao sistema é vaga e difícil de prever. Por isso, entender completamente o uso de memória em programas Python complexos é notoriamente difícil.
Criação de perfil de memória com tracemalloc
O tracemalloc é um pacote incluído na biblioteca padrão do Python (a partir da versão 3.4). Ele fornece rastreamentos detalhados das alocações de memória em nível de bloco, incluindo o traceback completo até a linha em que a alocação ocorreu, além de estatísticas sobre o comportamento geral da memória de um programa. A documentação está disponível aqui e oferece uma boa introdução aos recursos da ferramenta. A proposta original de aprimoramento do Python (PEP) que a introduziu também traz algumas informações sobre seu design.
O tracemalloc pode ser usado de duas maneiras para localizar trechos do código com alto consumo de memória:
analisar estatísticas cumulativas de uso de memória para identificar quais alocações de objetos consomem mais memória; e
rastrear os frames de execução para identificar onde esses objetos são alocados no código.
Uso de memória por módulo
Começamos rastreando o uso de memória do programa inteiro para identificar, em linhas gerais, quais objetos consomem mais memória. Com isso, esperamos obter informações suficientes para saber onde e como investigar mais a fundo. O wrapper a seguir inicia o rastreamento e imprime as estatísticas quando você pressiona Ctrl-C:
tracemalloc.start(10) inicia o rastreamento de memória e salva 10 frames do traceback para cada entrada. O padrão é 1, mas salvar mais frames do traceback é útil se você pretende usá-los para localizar vazamentos de memória, como veremos adiante. tracemalloc.take_snapshot() captura um instantâneo da memória alocada no momento no heap do Python. Ele armazena o número de blocos alocados, seus tamanhos e os tracebacks que identificam quais linhas de código alocaram cada bloco de memória. Depois de criar um instantâneo, podemos calcular estatísticas de uso de memória, comparar instantâneos ou salvá-los para análise posterior. top_n é uma função auxiliar que escrevi para exibir os resultados de tracemalloc de forma organizada. Aqui, solicito as 25 principais alocações de memória do instantâneo, agrupadas por nome de arquivo. Após alguns minutos de execução, o resultado é este:
Isso mostra o total acumulado de memória alocada pelo componente durante toda a execução, agrupado por nome de arquivo. Nesse nível de detalhamento, é difícil interpretar os resultados. Por exemplo, a primeira linha mostra que são criados 17 MB de objetos collections, mas essa visualização não detalha o suficiente para sabermos quais são esses objetos nem onde são usados. Precisamos de outra abordagem para isolar o problema.
Como interpretar os resultados do tracemalloc
O tracemalloc mostra o uso líquido de memória no momento em que um instantâneo é capturado. Ao comparar dois instantâneos, mostra o uso líquido de memória entre eles. Se houver alocações e liberações de memória entre os instantâneos, elas não aparecerão no resultado. Portanto, se os instantâneos forem capturados no mesmo ponto de um loop, qualquer alocação de memória visível nas diferenças entre eles estará contribuindo para o total de memória usado a longo prazo, em vez de ser uma alocação temporária ocorrida durante a execução.
No caso de ciclos de referência que exigem coleta de lixo, os ciclos ainda não coletados aparecem no resultado, mas os já coletados não. Todos os blocos liberados pelo coletor de lixo durante o período abrangido por um instantâneo serão registrados como memória liberada. Por isso, forçar a coleta de lixo com gc.collect() antes de capturar um instantâneo reduz o ruído no resultado.
Uso de memória por iteração
Como estamos procurando um vazamento de memória, é útil entender como o uso de memória do programa muda ao longo do tempo. Podemos instrumentar o loop principal do componente para ver quanta memória é alocada em cada iteração, chamando o método a seguir no loop principal:
Esse código captura e salva um instantâneo de memória e, em seguida, usa snapshot.compare_to(other_snapshot, group_by='filename') para comparar o instantâneo mais recente com o anterior, agrupando os resultados por nome de arquivo. Após algumas iterações para estabilizar o uso de memória, o resultado é este:
As alocações de linecache (1) e tracemalloc (2) fazem parte da instrumentação, mas também vemos algumas alocações de memória feitas pelo pacote HTTP requests (3) que merecem uma investigação mais aprofundada. Lembre-se de que o tracemalloc acompanha o uso líquido de memória; portanto, essas alocações se acumulam a cada iteração. Embora as alocações individuais sejam pequenas e não pareçam problemáticas à primeira vista, o vazamento só fica evidente ao longo de alguns dias. É provável, portanto, que pequenas perdas estejam se acumulando.
Filtragem de instantâneos
Agora que temos uma ideia de onde investigar, podemos usar os filtros do tracemalloc para exibir somente as alocações de memória relacionadas ao pacote requests:
snapshot.filter_traces() recebe uma lista de Filters para aplicar ao instantâneo. Aqui, criamos um Filter no modo inclusive, que inclui somente os rastreamentos correspondentes a filename_pattern. Quando inclusive é False, o filtro exclui os rastreamentos que correspondem a filename_pattern. O filename_pattern usa curingas no estilo UNIX para encontrar nomes de arquivo no traceback. Neste exemplo, os curingas em "requests" encontram ocorrências de "requests" no meio de um caminho, como "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/sessions.py".
Em seguida, usamos compare_to() para comparar os resultados com o instantâneo anterior. Veja abaixo o resultado filtrado:
Com o Filter aplicado, fica claro como o requests está usando memória. A linha (4) mostra que cerca de 50 KiB de memória são perdidos no requests a cada iteração do loop principal. Observe que o resultado também mostra alocações de memória negativas, como a (5). Elas indicam a liberação de memória alocada em iterações anteriores do loop.
Localização das alocações de memória
Para descobrir quais usos do requests estão causando o vazamento de memória, podemos analisar em detalhes onde ocorrem as alocações problemáticas. Para isso, chamamos compare_to() com traceback em vez de filename e usamos um Filter para restringir os resultados:
Isso exibe 10 frames do traceback (já que iniciamos o rastreamento com tracemalloc.start(10)) para cada entrada do resultado. Veja abaixo um exemplo resumido:
O traceback completo nos permite rastrear as alocações de memória até as linhas do código do nosso projeto que as geram. No caso desse componente, os usos do requests vinham de uma biblioteca interna de armazenamento que usava uma API HTTP. Reescrever a biblioteca para usar diretamente o urllib eliminou o vazamento de memória.

Criação de perfil de memória: arte ou ciência?
tracemalloc é uma ferramenta poderosa para entender o uso de memória por programas Python. Ela nos ajudou a compreender o uso de memória no nível dos módulos, descobrir quais objetos são alocados com mais frequência e observar como o uso de memória do reflector mudava a cada iteração. A ferramenta oferece recursos úteis de filtragem e permite visualizar o rastreamento completo de cada alocação de memória. Apesar de todos esses recursos, encontrar vazamentos de memória em Python ainda pode parecer mais uma arte do que uma ciência. Os profilers de memória permitem ver como a memória está sendo usada, mas muitas vezes é difícil identificar a alocação exata que está causando problemas. Cabe a nós sintetizar as informações fornecidas pelas ferramentas para chegar a uma conclusão sobre o comportamento de memória do programa e, então, decidir o que fazer.
Usamos praticamente todas as ferramentas Python disponíveis (frameworks de teste, cProfile etc.) para tornar o sistema da Fugue confiável, eficiente e fácil de manter. Tanto o broker quanto o reflector aproveitam a introspecção do Python para tomar decisões sobre chamadas dinâmicas à API da AWS, permitindo que nos concentremos na lógica em vez de programar casos exaustivos. Fugue aproveita os pontos fortes do Python onde isso faz sentido no sistema, o que, em última análise, resulta em mais estabilidade e extensibilidade do produto para os usuários finais.
Segurança de IaC pensada para quem desenvolve
A Snyk protege sua infraestrutura como código do ciclo de vida do desenvolvimento de software até a execução na nuvem, com um mecanismo unificado de políticas como código para que todas as equipes possam desenvolver, implantar e operar com segurança.
