Presente de Natal: Ruby 2.2.0 Lançado!

2014 December 29, 18:38 h - tags: ruby obsolete

Update 30/12/2014: existe um bug/regressão no Ruby 2.2.0 que não existia no 2.2.0-preview1.Se estiver usando Rails 3.2 você deve encontrar o warning de "circular argument reference". Então não atualize pra 2.2.0 se estiver no Rails 3.2 ou use este patch que o Aman Gupta fez.

Como prometido pelo Ruby Core Team, novamente como presente de Natal, no dia 25 de dezembro de 2014 tivemos o lançamento do esperado Ruby 2.2.0. Para saber o que mudou veja este artigo da InfoQ.

Para começar, é bom lembrar que a versão 1.8.7 está terminada desde 2013 com o End of Life oficial.

A série 1.9 trouxe diversas mudanças drásticas estruturais que, embora não quebrem tanto em termos de sintaxe, quebram nas estruturas de dados, como suporte nativo a Unicode, nova sintaxe de hash, Fibers, por exemplo. Peter Cooper tem um Walkthrough que vale a pena dar uma olhada. As versões 1.9.1 (experimental), 1.9.2 (estável mas ruim) e 1.9.3 (a melhor da série) foram grandes avanços desde a série 1.8, tanto em funcionalidades quanto performance.

A série 2.0 trouxe keyword arguments, module prepend, lazy enumerators, refinements (experimental). E a série 2.1 trouxe refinements como feature estável, inteiros de 128bits, melhoria no cache de métodos (um grande problema numa linguagem dinâmica com classes abertas), e a introdução da primeira geração do Restricted Generational Garbage Collector (RGenGC). Eu fiz um artigo chamado O que tem de novo no Ruby 2.1? que explica um pouco mais disso. Também fiz um artigo chamado Indo de Ruby 1.8 e Rails 2.3 para Ruby 2.0 e Rails 3.2 cujas técnicas continuam válidas para as novas versões de Ruby e Rails.

O mais relevante desde a versão 1.9.3 (lançada em outubro de 2011), seguindo pra 2.0.0 (lançada em fevereiro de 2013), depois a série 2.1 (lançada em dezembro de 2013) e finalmente com a 2.2.0 (lançada agora em dezembro de 2014), são 3 anos de evolução rápida e constante do Garbage Collector e algumas estruturas internas importantes como method caching e symbols.

Assistam minha palestra sobre Garbage Collector de Ruby para entender o funcionamento exato:

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.

Comments

comentários deste blog disponibilizados por Disqus