Nós da
Phusion temos um proxy HTTP multiencadeado simples em Ruby (distribui pacotes DEB e RPM). Vi nele um consumo de memória de 1,3 GB. Mas isso é louco por um processo sem estado ...
Pergunta: O que é isso? Resposta: Ruby usa memória ao longo do tempo!Acontece que não estou sozinho nesse problema. Aplicativos Ruby podem usar muita memória. Mas porque? De acordo com
Heroku e
Nate Burkopek , o
inchaço deve-se principalmente à fragmentação da memória e à distribuição excessiva de
heap .
Berkopek concluiu que existem duas soluções:
- Use um alocador de memória completamente diferente do glibc - geralmente jemalloc , ou:
- Defina a variável de ambiente mágica
MALLOC_ARENA_MAX=2
.
Estou preocupado com a descrição do problema e as soluções propostas. Há algo errado aqui ... Não tenho certeza de que o problema esteja totalmente descrito corretamente ou que essas sejam as únicas soluções disponíveis. Também me incomoda que muitos se refiram ao jemalloc como uma piscina mágica de prata.
Magia é apenas uma ciência que ainda não entendemos . Então, eu fui em uma viagem de pesquisa para descobrir toda a verdade. Este artigo abordará os seguintes tópicos:
- Como a alocação de memória funciona.
- O que é essa "fragmentação" e "distribuição excessiva" de memória que todo mundo está falando?
- O que causa um grande consumo de memória? A situação é consistente com o que as pessoas estão dizendo ou há algo mais? (spoiler: sim, há outra coisa).
- Existem soluções alternativas? (spoiler: eu encontrei um).
Nota: este artigo é relevante apenas para Linux e somente para aplicativos Ruby com vários threads.Conteúdo
Alocação de memória Ruby: uma introdução
Ruby aloca memória em três níveis, de cima para baixo:
- Intérprete Ruby que gerencia objetos Ruby.
- A biblioteca do alocador de memória do sistema operacional.
- O núcleo.
Vamos passar por cada nível.
Ruby
Por outro lado, Ruby organiza objetos em áreas da memória chamadas
páginas de pilha do
Ruby . Essa página de heap é dividida em slots do mesmo tamanho, onde um objeto ocupa um slot. Seja uma sequência de caracteres, uma tabela de hash, uma matriz, uma classe ou outra coisa, ela ocupa um slot.
Os slots na página da pilha podem estar ocupados ou livres. Quando Ruby seleciona um novo objeto, ele imediatamente tenta ocupar um espaço livre. Se não houver slots livres, uma nova página de heap será destacada.
O slot é pequeno, cerca de 40 bytes. Obviamente, alguns objetos não cabem nele, por exemplo, linhas de 1 MB. Em seguida, Ruby armazena as informações em outro local fora da página da pilha e coloca um ponteiro para essa área de memória externa no slot.
Os dados que não cabem no slot são armazenados fora da página da pilha. Ruby coloca um ponteiro para esses dados externos no slotAs páginas de heap do Ruby e quaisquer áreas de memória externa são alocadas usando o alocador de memória do sistema.
Alocador de memória do sistema
O alocador de memória do sistema operacional faz parte do glibc (C runtime). É usado por quase todas as aplicações, não apenas pelo Ruby. Possui uma API simples:
- A memória é alocada chamando
malloc(size)
. Você fornece o número de bytes que deseja alocar e retorna o endereço de alocação ou um erro. - A memória alocada é liberada chamando
free(address)
.
Diferentemente do Ruby, onde slots do mesmo tamanho são alocados, o alocador de memória lida com solicitações para alocar memória de qualquer tamanho. Como você aprenderá mais adiante, esse fato leva a algumas complicações.
Por sua vez, o alocador de memória acessa a API do kernel. É necessária uma quantidade muito maior de memória do kernel do que seus próprios assinantes solicitam, pois a chamada do kernel é cara e a API do kernel tem uma limitação: ela só pode alocar memória em múltiplos de 4 KB.
O alocador de memória aloca grandes pedaços - eles são chamados de heaps do sistema - e compartilha seu conteúdo para satisfazer solicitações de aplicativosA área de memória que o alocador de memória aloca do kernel é chamada heap. Observe que isso não tem nada a ver com as páginas da pilha Ruby, portanto, para maior clareza, usaremos o termo
pilha do sistema .
O alocador de memória atribui partes do sistema a seus chamadores até que haja espaço livre. Nesse caso, o alocador de memória aloca um novo heap do sistema a partir do kernel. É semelhante à maneira como o Ruby seleciona objetos das páginas de uma pilha de Ruby.
Ruby aloca memória do alocador de memória, que, por sua vez, aloca memória do kernelO núcleo
O kernel pode alocar memória apenas em unidades de 4 KB. Um desses blocos de 4K é chamado de página. Para evitar confusão com as páginas de heap do Ruby, para maior clareza, usaremos o termo
página do sistema (página do SO).
O motivo é difícil de explicar, mas é assim que todos os kernels modernos funcionam.
A alocação de memória através do kernel tem um impacto significativo no desempenho, e é por isso que os alocadores de memória tentam minimizar o número de chamadas do kernel.
Definição de uso de memória
Assim, a memória é alocada em vários níveis, e cada nível aloca mais memória do que realmente precisa. As páginas de heap Ruby podem ter slots livres, bem como heaps do sistema. Portanto, a resposta para a pergunta "Quanta memória é usada?" depende completamente de qual nível você pergunta!
Ferramentas como
top
ou
ps
mostram o uso de memória da perspectiva do
kernel . Isso significa que níveis mais altos devem funcionar em conjunto para liberar memória do ponto de vista do kernel. Como você aprenderá mais tarde, isso é mais difícil do que parece.
O que é fragmentação?
Fragmentação de memória significa que as alocações de memória são espalhadas aleatoriamente. Isso pode causar problemas interessantes.
Fragmentação em nível de rubi
Considere a coleta de lixo do Ruby. A coleta de lixo para um objeto significa marcar o slot da página de pilha do Ruby como livre, permitindo que ele seja reutilizado. Se a página inteira do heap Ruby consistir apenas em slots livres, sua página inteira poderá ser liberada de volta ao alocador de memória (e, possivelmente, de volta ao kernel).
Mas o que acontece se nem todos os slots forem gratuitos? E se tivermos muitas páginas da pilha Ruby e o coletor de lixo liberar objetos em lugares diferentes, para que, no final, haja muitos espaços livres, mas em páginas diferentes? Nessa situação, o Ruby possui slots livres para colocar objetos, mas o alocador de memória e o kernel continuarão alocando memória!
Fragmentação de Alocação de Memória
O alocador de memória tem um problema semelhante, mas completamente diferente. Ele não precisa limpar imediatamente pilhas inteiras do sistema. Teoricamente, ele pode liberar qualquer página do sistema. Mas como o alocador de memória lida com alocações de memória de tamanho arbitrário, pode haver várias alocações na página do sistema. Ele não pode liberar a página do sistema até que todas as seleções sejam liberadas.
Pense no que acontece se tivermos uma alocação de 3 KB, bem como uma alocação de 2 KB, dividida em duas páginas do sistema. Se você liberar os primeiros 3 KB, as duas páginas do sistema permanecerão parcialmente ocupadas e não poderão ser liberadas.
Portanto, se as circunstâncias falharem, haverá muito espaço livre nas páginas do sistema, mas elas não serão totalmente liberadas.
Pior ainda: e se houver muitos locais gratuitos, mas nenhum deles for grande o suficiente para atender a uma nova solicitação de alocação? O alocador de memória precisará alocar um heap de sistema totalmente novo.
A fragmentação da página de pilha Ruby está causando inchaço na memória?
É provável que a fragmentação esteja causando uso excessivo de memória no Ruby. Em caso afirmativo, qual das duas fragmentações é mais prejudicial? Isto é ...
- Fragmentação de página de pilha Ruby? Ou
- Fragmentação de alocador de memória?
A primeira opção é bastante simples de verificar. O Ruby fornece duas APIs:
ObjectSpace.memsize_of_all
e
GC.stat
. Graças a essas informações, você pode calcular toda a memória que Ruby recebeu do alocador.
ObjectSpace.memsize_of_all
retorna a memória ocupada por todos os objetos Ruby ativos. Ou seja, todo o espaço em seus slots e quaisquer dados externos. No diagrama acima, esse é o tamanho de todos os objetos azuis e laranja.
GC.stat
permite descobrir o tamanho de todos os slots livres, ou seja, toda a área cinza da ilustração acima. Aqui está o algoritmo:
GC.stat[:heap_free_slots] * GC::INTERNAL_CONSTANTS[:RVALUE_SIZE]
Para resumir, essa é toda a memória que Ruby conhece e envolve a fragmentação das páginas da pilha de Ruby. Se, do ponto de vista do kernel, o uso da memória for maior, a memória restante ficará fora do controle do Ruby, por exemplo, para bibliotecas ou fragmentação de terceiros.
Eu escrevi um programa de teste simples que cria um monte de threads, cada um dos quais seleciona linhas em um loop. Aqui está o resultado depois de um tempo:
é ... apenas ... louco!
O resultado mostra que o Ruby tem um efeito tão fraco na quantidade total de memória usada, não importa se as páginas da pilha do Ruby estão fragmentadas ou não.
Tem que procurar o culpado em outro lugar. Pelo menos agora sabemos que Ruby não tem culpa.
Estudo de fragmentação de alocação de memória
Outro suspeito provável é um alocador de memória. No final, Nate Berkopek e Heroku notaram que a confusão com o alocador de memória (uma substituição completa do jemalloc ou a configuração da variável de ambiente mágica
MALLOC_ARENA_MAX=2
) reduz drasticamente o uso de memória.
Vamos primeiro ver o que
MALLOC_ARENA_MAX=2
faz e por que ajuda. Em seguida, examinamos a fragmentação no nível do distribuidor.
Alocação excessiva de memória e glibc
A razão
MALLOC_ARENA_MAX=2
qual
MALLOC_ARENA_MAX=2
ajuda é
MALLOC_ARENA_MAX=2
multithreading. Quando vários threads simultaneamente tentam alocar memória do mesmo heap do sistema, eles lutam pelo acesso. Somente um thread por vez pode receber memória, o que reduz o desempenho da alocação de memória multithread.
Somente um encadeamento de cada vez pode trabalhar com o heap do sistema. Nas tarefas multithread, surge um conflito e, consequentemente, o desempenho diminuiNo alocador de memória para esse caso, há otimização. Ele tenta criar vários heaps do sistema e atribuí-los a diferentes threads. Na maioria das vezes, um encadeamento funciona apenas com seu próprio heap, evitando conflitos com outros encadeamentos.
De fato, o número máximo de pilhas do sistema alocadas dessa maneira é, por padrão, igual ao número de processadores virtuais multiplicados por 8. Ou seja, em um sistema de núcleo duplo com dois hiperencadeamentos, cada um produz
2 * 2 * 8 = 32
pilhas do sistema! Isso é o que eu chamo de
distribuição excessiva .
Por que o multiplicador padrão é tão grande? Porque o principal desenvolvedor do alocador de memória é a Red Hat. Seus clientes são grandes empresas com servidores poderosos e uma tonelada de RAM. A otimização acima permite aumentar o desempenho médio de multithreading em 10% devido a um aumento significativo no uso de memória. Para os clientes da Red Hat, esse é um bom compromisso. Para a maioria do resto - dificilmente.
Nate em seu blog e artigo em Heroku argumentam que aumentar o número de pilhas do sistema aumenta a fragmentação e cita a documentação oficial. A variável
MALLOC_ARENA_MAX
reduz o número máximo de heaps do sistema alocados para multithreading. Por essa lógica, reduz a fragmentação.
Visualização de pilhas do sistema
A afirmação de Nate e Heroku é verdadeira de que aumentar o número de pilhas do sistema aumenta a fragmentação? De fato, há algum problema com a fragmentação no nível do alocador de memória? Eu não queria tomar nenhuma dessas premissas como garantidas, então comecei o estudo.
Infelizmente, não existem ferramentas para visualizar pilhas de sistemas, então
eu mesmo escrevi esse visualizador .
Primeiro, você precisa preservar o esquema de distribuição dos heaps do sistema. Estudei a
fonte do alocador de memória e observei como ele representa internamente a memória. Em seguida, ele escreveu uma biblioteca que itera sobre essas estruturas de dados e grava o esquema em um arquivo. Por fim, ele escreveu uma ferramenta que usa esse arquivo como entrada e compila a visualização como imagens HTML e PNG (
código fonte ).
Aqui está um exemplo de visualização de um heap de sistema específico (há muito mais). Pequenos blocos nesta visualização representam páginas do sistema.
- Áreas vermelhas são usadas células de memória.
- Os cinzas são áreas livres que não são liberadas de volta ao núcleo.
- Áreas brancas são liberadas para o núcleo.
As seguintes conclusões podem ser tiradas da visualização:
- Existe alguma fragmentação. Pontos vermelhos estão espalhados na memória e algumas páginas do sistema estão apenas meio vermelhas.
- Para minha surpresa, a maioria dos pacotes de sistema contém uma quantidade significativa de páginas do sistema totalmente gratuitas (cinza)!
E então me dei conta:
Embora a fragmentação continue sendo um problema, não é esse o ponto!Em vez disso, o problema é muito cinza: esse alocador de memória
não envia memória de volta ao kernel !
Depois de re-estudar o código fonte do alocador de memória, descobriu-se que, por padrão, ele envia apenas páginas do sistema para o kernel no final do heap do sistema, e
raramente o faz. Provavelmente, esse algoritmo é implementado por razões de desempenho.
Truque de Mágica: Circuncisão
Felizmente, encontrei um truque. Há uma interface de programação que forçará o alocador de memória a liberar para o kernel não apenas a última, mas
todas as páginas relevantes do sistema. É chamado
malloc_trim .
Eu conhecia essa função, mas não achei que fosse útil, porque o manual diz o seguinte:
A função malloc_trim () tenta liberar memória livre na parte superior da pilha.
O manual está errado! A análise do código fonte diz que o programa libera todas as páginas relevantes do sistema, não apenas as principais.
O que acontece se essa função for chamada durante a coleta de lixo?
malloc_trim()
código-fonte do Ruby 2.6 para chamar
malloc_trim()
na função gc_start do gc.c, por exemplo:
gc_prof_timer_start(objspace); { gc_marks(objspace, do_full_mark);
E aqui estão os resultados do teste:
Que grande diferença! Um patch simples reduziu o consumo de memória para quase
MALLOC_ARENA_MAX=2
.
Veja como fica na visualização:
Vemos muitas áreas brancas que correspondem às páginas do sistema liberadas de volta ao kernel.
Conclusão
Aconteceu que a fragmentação, basicamente, não tinha nada a ver com isso. A desfragmentação ainda é útil, mas o principal problema é que o alocador de memória não gosta de liberar memória de volta ao kernel.
Felizmente, a solução acabou sendo muito simples. O principal foi encontrar a causa raiz.
Código-fonte do Visualizer
Código fonteE o desempenho?
O desempenho continuou sendo uma das principais preocupações. Chamar
malloc_trim()
não pode ser
malloc_trim()
de graça, mas de acordo com o código, o algoritmo funciona em tempo linear. Então, virei-me para
Noah Gibbs , que lançou o benchmark Rails Ruby Bench. Para minha surpresa, o patch causou um ligeiro
aumento no desempenho.
Isso me impressionou. O efeito é incompreensível, mas as notícias são boas.
Precisa de mais testes.
Dentro deste estudo, apenas um número limitado de casos foi verificado. Não se sabe qual é o impacto em outras cargas de trabalho. Se você quiser ajudar nos testes, entre em
contato comigo .