E acompanhem os slides dessa palestra que disponibilizei no Slideshare:
Como se evolui um Garbage Collector
Em resumo, todo o fundamento se inicia com o conceito do coletor "Mark & Sweep". Simplificando isso significa um coletor de 2 fases, uma para "marcar" os slots ainda em uso dos heaps e o segundo para coletar os slots vagos dos heaps, declarando que eles podem ser ocupados por novos objetos.
Quando falta slots livres o Ruby instancia novos heaps da memória livre do sistema operacional. Uma vez alocados, esses espaços não são devolvidos ao sistema operacional. Eles permanecem como slots disponíveis, mesmo que não estejam sendo usados. Ou seja, quanto mais rodamos as pausas de mark & sweep, menos heaps são necessários. Mas ao mesmo tempo as pausas tornam a execução lenta. Então estamos sempre fazendo um trade-off entre memória alocada e performance de execução.
O truque é: como diminuir as pausas e mesmo assim não alocar memória demais do sistema operacional? A partir da série 1.9.3 tivemos a introdução de Lazy Sweeping, ou seja, quebrar uma grande pause de sweep para múltiplas menores onde podemos continuar executando nosso código nos intervalos entre um pequeno sweep e outro. Essa foi a primeira "quebra" das pausas.
O segundo problema é quebrar a fase de full marking. Pra isso normalmente assumimos a hipótese de geração fraca, ou Weak Generational Hyphotesis. A premissa é simples: objetos novos morrem mais cedo, objetos antigos permanecem mais tempo. Então que tal "marcar" objetos que sobrevivam a um certo número de ciclos de marcação como "antigo" e no próximo ciclo não passar mais por eles? Então se tivermos 1 milhão de objetos e 90% deles sobreviverem a um ciclo de marking, então etiquetamos eles como "antigos" e no próximo ciclo precisamos passar por apenas 100 mil objetos, o que é um enorme ganho de tempo numa fase de marking.
Objetos marcados como "antigos" é o que consideramos a geração "old/tomb" e os novos objetos recém alocados são a geração "young/eden", por isso chamamos o garbage collector de "generational". No caso do Ruby ele se chama "Restricted" porque temos alguns objetos que não podem ser "movidos" de uma geração para outra (o que significaria mover seus ponteiros), é o caso de objetos de extensions em C. Nesse caso se usa um Write Barrier para evitar que eles se movem na fase de marking. Com tudo isso, o RGenGC do Ruby 2.1 tem fases de major marking, espaçadas com fases mais rápidas de minor markings (que não passam pela geração velha) e lazy sweeping. Então o nome completo do Garbage Collector poderia ser "Full Marking for Shady Objects, Major/Minor interleaved Marking for Non-Shady Objects & Lazy Sweep".
Finalmente, na versão 2.2.0 temos uma nova fase: toda vez que falta slots livres, o garbage collector vai executar uma fase de Full/Major Marking em todos os objetos, incluindo os da geração velha. Essa fase não roda o tempo todo mas quanto roda é um longo Stop-the-World, onde seu código precisa esperar a fase terminar para poder continuar executando. Então o que a 2.2 trás é o equivalente ao Lazy Sweep: Incremental Major Marking. Basicamente em vez de ir linearmente por toda a lista de slots ocupados, ele vai "paginando" e intercalando a fase de marking com a execução do seu código, o que tecnicamente é como um multitasking cooperativo. Na prática "parece" que as pausas são menores.
Somando isso com o fato que Symbols agora são dividos em "mortais" e "imortais" (até agora, Symbols sempre eram imortais por serem representados por singletons integers), o 2.2.0 "deve" ter uma performance melhor que a série 2.1 e usar um pouco menos de memória total.
Se não me engano (ainda não encontrei exatamente no código fonte), a versão 2.2 também altera um pouco a estratégia de promoção de um objeto novo para a geração velha. Esse é o modo conhecido internamente como 3GEN/THREEGEN e renomeado para AGE2_PROMOTION. A idéia é simples: na série 2.1 se um objeto sobrevive uma fase de marking ele já é marcado como velho e não se passa mais por ele até faltar espaço e precisar de um novo Major GC. Mas se você tem objetos intermediários demais (pense uma coleção de activerecord numa request Rails) que sobrevivem 1 ciclo mas poderiam ir embora rápido, eles vão se acumular na geração velha e o processo vai usar mais memória do que precisaria até dar trigger num novo full GC. E se rodar muitos full GC, volta a ficar na mesma velocidade da série 1.9/2.0. Pelas notas que linkei acima, me dá impressão que esse patch foi backported pra série 2.1 (a partir do 2.1.2), mas novamente não tenho certeza.
Na Prática
O que expliquei é um pequeno resumo, tem bem mais por baixo dos panos e desde a série 2.1 temos várias variáveis de ambiente para tuning. O objetivo até aqui foi quebrar as longas pausas de marking e sweep em pequenos intervalos incrementais e criar oportunidades e tuning para balancear entre performance de execução (menos execuções GC) e menos uso de memória (mais execuções de GC). Na série 2.1 e 2.2 significa que na prática ele vai alocar mais heaps do que o 1.9/2.0 porque ele precisa balancear entre duas gerações (Eden e Tomb), a vantagem é que ele roda menos GCs e por isso a execução é melhor.
Usando a aplicação Rails que é meu próprio blog, subindo o servidor e navegando nas mesmas páginas, depois medindo o processo Ruby com o comando pmap -x PID podemos ver o uso real de memória (RSS) entre cada versão de Ruby:
- 1.9.3-p551 - 147252 kb
- 2.0.0-p598 - 149048 kb
- 2.1.5 - 179528 kb
- 2.2.0 - 162752 kb
Como previsto, veja que a série 2.1 e 2.2 usam mais memória que o 1.9 e 2.0. Porém, com as otimizações do 2.2 veja que ele usa menos memória já de início que o 2.1, o que por si só já é uma grande melhoria. Um upgrade de 2.1 pra 2.2 deve ser razoavelmente trivial, mas pular de 1.9/2.0 para 2.1/2.2 exige um pouco mais de atenção porque ele vai usar pelo menos 20% mais memória por processo. Habilite seu New Relic e monitore o consumo de memória e o tempo médio de execução por request (além do 99th percentile) para garantir que está tudo bem, especialmente em ambientes com pouca memória como dynos de Heroku.