Olá colegas.
Nossa longa pesquisa por livros atemporais e mais vendidos sobre otimização de código produziu apenas os primeiros resultados, mas estamos prontos para agradar a você que a tradução do lendário livro de Ben Watson, "
Writing High Performance .NET .NET Code ", foi literalmente concluída. Nas lojas - provisoriamente em abril, preste atenção à publicidade.
Hoje, oferecemos a você a leitura de um artigo puramente prático sobre os tipos mais prementes de vazamento de memória, escrito por
Nelson Ilheidzhe (Strike).
Portanto, você tem um programa que leva mais tempo para ser concluído, mais tempo leva. Provavelmente, não será difícil para você entender que esse é um sinal claro de vazamento de memória.
No entanto, o que exatamente queremos dizer com "vazamento de memória"? Na minha experiência, vazamentos de memória explícita são divididos em três categorias principais, cada uma das quais é caracterizada por um comportamento especial e, para depuração de cada uma das categorias, são necessárias ferramentas e técnicas especiais. Neste artigo, quero descrever todas as três classes e sugerir como reconhecer corretamente, com
com qual classe você está lidando e como encontrar um vazamento.
Tipo (1): Fragmento de memória inacessível alocado
Este é um vazamento de memória clássico em C / C ++. Alguém alocou memória usando
new
ou
malloc
e não chamou
free
ou
delete
para liberar memória depois de terminar de trabalhar com ele.
void leak_memory() { char *leaked = malloc(4096); use_a_buffer(leaked); }
Como determinar se um vazamento pertence a esta categoria- Se você escreve em C ou C ++, especialmente em C ++, sem o amplo uso de ponteiros inteligentes para controlar o tempo de vida útil dos segmentos de memória, essa é a opção que estamos considerando primeiro.
- Se o programa for executado em um ambiente com coleta de lixo, é possível que um vazamento desse tipo seja provocado por uma extensão de código nativa ; no entanto, os vazamentos dos tipos (2) e (3) devem ser eliminados primeiro.
Como encontrar um vazamento- Use ASAN . Use ASAN. Use ASAN.
- Use um detector diferente. Eu tentei as ferramentas Valgrind ou tcmalloc para trabalhar com muitos, também existem outras ferramentas em outros ambientes.
- Alguns alocadores de memória permitem despejar o perfil de heap, que mostrará todas as áreas de memória não alocada. Se houver um vazamento, depois de algum tempo, quase todas as descargas ativas sairão dele, portanto, provavelmente não será difícil encontrá-lo.
- Se tudo mais falhar, despeje um despejo de memória e examine-o da maneira mais meticulosa possível . Mas definitivamente não deve começar com isso.
Tipo (2): alocações não planejadas de memória de longa duraçãoTais situações não são "vazamentos" no sentido clássico da palavra, uma vez que um link de algum lugar para esse pedaço de memória ainda é preservado, portanto, no final, ele pode ser liberado (se o programa conseguir chegar lá sem usar toda a memória).
Situações nesta categoria podem surgir por vários motivos específicos. Os mais comuns são:
- Acúmulo não intencional de estado em uma estrutura global; por exemplo, o servidor HTTP grava na lista global cada objeto de
Request
recebido. - Caches sem uma política de obsolescência bem pensada. Por exemplo, um cache ORM que armazena em cache todos os objetos carregados, ativos durante a migração, nos quais todos os registros presentes na tabela são carregados sem exceção.
- Um estado muito volumoso é capturado no circuito. Esse caso é especialmente comum no Java Script, mas também pode ocorrer em outros ambientes.
- Em um sentido mais amplo, a retenção inadvertida de cada elemento de uma matriz ou fluxo, enquanto se supunha que esses elementos seriam processados por streaming online.
Como determinar se um vazamento pertence a esta categoria- Se o programa for executado em um ambiente com coleta de lixo, essa é a opção que estamos considerando primeiro.
- Compare o tamanho do heap exibido nas estatísticas do coletor de lixo com o tamanho da memória livre gerada pelo sistema operacional. Se um vazamento se encaixar nessa categoria, os números serão comparáveis e, o mais importante, seguirão um ao outro ao longo do tempo.
Como encontrar um vazamentoUse as ferramentas de criação de perfil ou despejo de heap disponíveis em seu ambiente. Eu sei que há
guppy no Python ou
memory_profiler no Ruby, e também escrevi o
ObjectSpace diretamente no Ruby.
Tipo (3): memória livre, mas não utilizada ou inutilizávelEssa categoria é mais difícil de caracterizar, mas é precisamente a mais importante para entender e levar em conta.
Vazamentos desse tipo ocorrem na zona cinza, entre a memória, que é considerada "livre" do ponto de vista do alocador dentro da VM ou no ambiente de tempo de execução, e a memória, que é "livre" do ponto de vista do sistema operacional. A razão mais comum (mas não a única) para esse fenômeno é a
fragmentação da pilha . Alguns alocadores simplesmente aceitam e não retornam memória ao sistema operacional depois que ele foi alocado.
Um caso desse tipo pode ser considerado com um exemplo de um programa curto escrito em Python:
import sys from guppy import hpy hp = hpy() def rss(): return 4096 * int(open('/proc/self/stat').read().split(' ')[23]) def gcsize(): return hp.heap().size rss0, gc0 = (rss(), gcsize()) buf = [bytearray(1024) for i in range(200*1024)] print("start rss={} gcsize={}".format(rss()-rss0, gcsize()-gc0)) buf = buf[::2] print("end rss={} gcsize={}".format(rss()-rss0, gcsize()-gc0))
Alocamos 200.000 buffers de 1 kb e salvamos cada subsequente. A cada segundo, exibimos o estado da memória do ponto de vista do sistema operacional e do ponto de vista do nosso próprio coletor de lixo Python.
No meu laptop, recebo algo assim:
start rss=232222720 gcsize=11667592
end rss=232222720 gcsize=5769520
Podemos garantir que o Python realmente liberou metade dos buffers, porque o nível de gcsize caiu quase metade do valor de pico, mas não foi possível retornar um byte dessa memória ao sistema operacional. A memória liberada permanece acessível para o mesmo processo Python, mas não para qualquer outro processo nesta máquina.
Esses fragmentos de memória livres, mas não utilizados, podem ser problemáticos e inofensivos. Se um programa Python age dessa maneira e depois aloca um punhado de fragmentos de 1kb, esse espaço é simplesmente reutilizado e tudo está bem.
Porém, se fizéssemos isso durante a instalação inicial e subsequentemente alocássemos a memória ao mínimo, ou se todos os fragmentos alocados subsequentemente tivessem 1,5kb cada e não se ajustassem a esses buffers deixados com antecedência, toda a memória alocada dessa maneira sempre permaneceria ociosa seria desperdiçado.
Problemas desse tipo são especialmente relevantes em um ambiente específico, a saber, em sistemas de servidores com vários processos para trabalhar com linguagens como Ruby ou Python.
Digamos que configuramos um sistema no qual:
- Em cada servidor, N trabalhadores de thread único são usados para atender solicitações com competência. Vamos usar N = 10 para precisão.
- Como regra, cada funcionário tem uma quantidade quase constante de memória. Para maior precisão, vamos usar 500 MB.
- Com alguma frequência baixa, recebemos solicitações que exigem muito mais memória que a solicitação mediana. Para maior precisão, vamos supor que, uma vez por minuto, recebamos uma solicitação, cujo tempo de execução requer adicionalmente 1 GB de memória extra e, quando a solicitação é processada, essa memória é liberada.
A cada minuto, chega uma solicitação "cetáceo", cujo processamento confiamos a um dos 10 trabalhadores, por exemplo, aleatoriamente:
~random
. Idealmente, durante o processamento dessa solicitação, esse funcionário deve alocar 1 GB de RAM e, após o término do trabalho, devolver essa memória ao sistema operacional para que possa ser reutilizada posteriormente. Para processar solicitações de forma ilimitada por esse princípio, o servidor precisará de apenas 10 * 500 MB + 1 GB = 6 GB de RAM.
No entanto, vamos supor que, devido à fragmentação ou por algum outro motivo, a máquina virtual nunca possa retornar essa memória ao sistema operacional. Ou seja, a quantidade de RAM necessária para o sistema operacional é igual à maior quantidade de memória que você precisa alocar por vez. Nesse caso, quando um funcionário específico atende a uma solicitação que consome muitos recursos, a área ocupada por esse processo na memória inchará para sempre por um gigabyte inteiro.
Quando você inicia o servidor, verá que a quantidade de memória usada é 10 * 500MB = 5GB. Assim que a primeira grande solicitação chega, o primeiro trabalhador ocupa 1 GB de memória e não a devolve. A quantidade total de memória usada aumentará para 6 GB. As seguintes solicitações de entrada podem ocasionalmente ser redirecionadas para o processo que processou a “baleia” anteriormente; nesse caso, a quantidade de memória usada não será alterada. Às vezes, porém, uma solicitação tão grande é entregue a outro funcionário, por causa do qual a memória é inflada por mais 1 GB e assim sucessivamente até que cada funcionário tenha a oportunidade de processar uma solicitação tão grande pelo menos uma vez. Nesse caso, você terá até 10 * (500 MB + 1 GB) = 15 GB de RAM com essas operações, o que é muito mais do que os 6 GB ideais! Além disso, se você considerar como a frota de servidores é usada ao longo do tempo, poderá ver como a quantidade de memória usada aumenta gradualmente de 5 GB para 15 GB, o que lembrará muito um vazamento "real".
Como determinar se um vazamento pertence a esta categoria- Compare o tamanho do heap exibido nas estatísticas do coletor de lixo com o tamanho da memória livre gerada pelo sistema operacional. Se o vazamento pertencer a esta (terceira) categoria, os números divergirão ao longo do tempo.
- Gosto de configurar meus servidores de aplicativos para que esses dois números se afastem periodicamente da infraestrutura de séries temporais, por isso é conveniente exibir gráficos neles.
- No Linux, visualize o status do sistema operacional no campo 24 de
/proc/self/stat
e visualize o alocador de memória por meio de uma API específica de linguagem ou máquina virtual.
Como encontrar um vazamentoComo já mencionado, essa categoria é um pouco mais insidiosa do que as anteriores, pois o problema geralmente surge mesmo quando todos os componentes funcionam "como pretendido". No entanto, existem vários truques úteis para ajudar a mitigar ou reduzir o impacto de tais "vazamentos virtuais":
- Reinicie seus processos com mais frequência. Se o problema crescer lentamente, talvez não seja difícil reiniciar todos os processos de aplicativos uma vez a cada 15 minutos ou uma vez por hora.
- Uma abordagem ainda mais radical: você pode ensinar todos os processos a reiniciarem independentemente, assim que o espaço que eles ocupam na memória exceder um determinado valor limite ou aumentar por um valor predeterminado. No entanto, tente prever que toda a sua frota de servidores não possa iniciar uma reinicialização síncrona espontânea.
- Mude o alocador de memória. A longo prazo, tcmalloc e jemalloc geralmente lidam com a fragmentação muito melhor que o alocador padrão, e experimentar com eles é muito conveniente usando a variável
LD_PRELOAD
. - Descubra se você possui consultas individuais que consomem muito mais memória que o restante. No Stripe, nossos servidores de API medem o RSS (consumo constante de memória) antes e depois de atender a cada solicitação de API e registram o delta. Em seguida, consultamos com facilidade nossos sistemas de agregação de logs para determinar se existem terminais e usuários (e padrões) que podem ser usados para amortizar surtos de consumo de memória.
- Ajuste o coletor de lixo / alocador de memória. Muitos deles possuem parâmetros personalizáveis que permitem especificar com que intensidade esse mecanismo retornará memória ao sistema operacional, como otimizado para eliminar a fragmentação; existem outras opções úteis. Tudo aqui também é bastante complicado: entenda exatamente o que você está medindo e otimizando e tente encontrar um especialista na máquina virtual apropriada e consulte-o.