Como provavelmente sabe, o Ruby tem algumas implementações, tais como MRI, JRuby, Rubinius, Opal, RubyMotion, etc., e cada uma delas pode usar um padrão diferente de execução de código. Este artigo centrar-se-á nas três primeiras e comparará o MRI
Como provavelmente sabe, Rubi tem algumas implementações, tais como MRI, JRuby, Rubinius, Opal, RubyMotion, etc., e cada uma delas pode utilizar um padrão diferente de código execução. Este artigo centrar-se-á nos três primeiros e comparará o MRI (atualmente a implementação mais popular) com o JRuby e o Rubinius, executando alguns exemplos de scripts que deverão avaliar a adequação da bifurcação e do threading em várias situações, como o processamento de algoritmos com uso intensivo de CPU, a cópia de ficheiros, etc. Antes de começar a "aprender fazendo", é necessário rever alguns termos básicos.
Garfo
é um novo processo filho (uma cópia do processo pai)
comunica com outros através de canais de comunicação inter-processos (IPC), como filas de mensagens, ficheiros, tomadas, etc.
existe mesmo quando o processo principal termina
é uma chamada POSIX - funciona principalmente em plataformas Unix
Linha
é "apenas" um contexto de execução, funcionando no âmbito de um processo
partilha toda a memória com outros (por defeito, utiliza menos memória do que um fork)
comunica com outros através de objectos de memória partilhada
morre com um processo
introduz problemas típicos de multithreading, como a fome, os bloqueios, etc.
Existem muitas ferramentas que utilizam forks e threads e que são utilizadas diariamente, por exemplo, o Unicorn (forks) e o Puma (threads) ao nível dos servidores de aplicações, o Resque (forks) e o Sidekiq (threads) ao nível dos trabalhos em segundo plano, etc.
A tabela seguinte apresenta o suporte para forking e threading nas principais implementações de Ruby.
Implementação do Ruby
Bifurcação
Enfiamento
RMN
Sim
Sim (limitado pelo GIL**)
JRuby
–
Sim
Rubinius
Sim
Sim
Mais duas palavras mágicas estão a voltar como um boomerang neste tópico - paralelismo e concorrência - precisamos de as explicar um pouco. Antes de mais, estes termos não podem ser utilizados indistintamente. Em poucas palavras - podemos falar de paralelismo quando duas ou mais tarefas estão a ser processadas exatamente ao mesmo tempo. A concorrência ocorre quando duas ou mais tarefas estão a ser processadas em períodos de tempo sobrepostos (não necessariamente ao mesmo tempo). Sim, é uma explicação abrangente, mas suficientemente boa para o ajudar a perceber a diferença e a compreender o resto deste artigo.
O quadro seguinte apresenta o suporte para paralelismo e concorrência.
Implementação do Ruby
Paralelismo (via forks)
Paralelismo (através de threads)
Concorrência
RMN
Sim
Não
Sim
JRuby
–
Sim
Sim
Rubinius
Sim
Sim (desde a versão 2.X)
Sim
Acabou-se a teoria - vamos ver na prática!
Ter memória separada não implica necessariamente consumir a mesma quantidade de memória que o processo principal. Existem algumas técnicas de otimização da memória. Uma delas é a Copy on Write (CoW), que permite ao processo pai partilhar a memória atribuída com o processo filho sem a copiar. Com a CoW, só é necessária memória adicional no caso de modificação da memória partilhada por um processo filho. No contexto do Ruby, nem todas as implementações são favoráveis à CoW, por exemplo, o MRI suporta-a totalmente desde a versão 2.X. Antes desta versão, cada bifurcação consumia tanta memória como um processo pai.
Uma das maiores vantagens/desvantagens do MRI (risque a alternativa inadequada) é a utilização do GIL (Global Interpreter Lock). Em poucas palavras, esse mecanismo é responsável por sincronizar a execução das threads, o que significa que apenas uma thread pode ser executada por vez. Mas espere... Isso significa que não faz sentido usar threads no MRI? A resposta vem com o entendimento da parte interna do GIL... ou pelo menos dando uma olhada nos exemplos de código deste artigo.
Caso de teste
Para apresentar como funciona o forking e o threading nas implementações do Ruby, criei uma classe simples chamada Teste e algumas outras que dela herdam. Cada classe tem uma tarefa diferente para processar. Por defeito, cada tarefa é executada quatro vezes num ciclo. Além disso, cada tarefa é executada em três tipos de execução de código: seqüencial, com bifurcações e com threads. Além disso, Referência.bmbm executa o bloco de código duas vezes - a primeira vez para colocar o ambiente de tempo de execução em funcionamento, a segunda vez para medir. Todos os resultados apresentados neste artigo foram obtidos na segunda execução. É claro que, mesmo bmbm não garante um isolamento perfeito, mas as diferenças entre várias execuções de código são insignificantes.
requerer "benchmark"
classe Teste
MONTANTE = 4
def run
Benchmark.bmbm do |b|
b.report("sequencial") { sequencial }
b.report("forking") { forking }
b.report("threading") { threading }
end
fim
privado
def sequencial
MONTANTE.vezes { executar }
end
def bifurcação
AMOUNT.times do
fork do
executar
fim
fim
Process.waitall
rescue NotImplementedError => e
# O método fork não está disponível no JRuby
puts e
end
def threading
threads = []
AMOUNT.times do
threads << Thread.new do
executar
fim
fim
threads.map(&:join)
end
def perform
raise "não implementado"
end
fim
Teste de carga
Executa cálculos num ciclo para gerar uma grande carga de CPU.
classe LoadTest < Teste
def perform
1000.vezes { 1000.vezes { 2**3**4 } }
end
fim
Vamos lá a correr...
LoadTest.new.run
...e verificar os resultados
RMN
JRuby
Rubinius
sequencial
1.862928
2.089000
1.918873
bifurcação
0.945018
–
1.178322
enfiamento
1.913982
1.107000
1.213315
Como se pode ver, os resultados das execuções sequenciais são semelhantes. É claro que há uma pequena diferença entre as soluções, mas ela é causada pela implementação subjacente dos métodos escolhidos em vários intérpretes.
A bifurcação, neste exemplo, tem um ganho de desempenho significativo (o código é executado quase duas vezes mais rápido).
A utilização de threads dá resultados semelhantes aos da bifurcação, mas apenas para JRuby e Rubinius. Executar a amostra com threads no MRI consome um pouco mais de tempo do que o método sequencial. Há pelo menos duas razões. Em primeiro lugar, o GIL força a execução de threads sequenciais, portanto, num mundo perfeito, o tempo de execução deveria ser o mesmo da execução sequencial, mas também ocorre uma perda de tempo para as operações do GIL (alternar entre threads, etc.). Em segundo lugar, também é necessário algum tempo de sobrecarga para a criação de threads.
Este exemplo não dá nós uma resposta à pergunta sobre o sentido dos fios de utilização na ressonância magnética. Vamos ver outra.
Teste da soneca
Executa um método sleep.
classe SnoozeTest < Teste
def executar
dormir 1
fim
fim
Eis os resultados
RMN
JRuby
Rubinius
sequencial
4.004620
4.006000
4.003186
bifurcação
1.022066
–
1.028381
enfiamento
1.001548
1.004000
1.003642
Como se pode ver, cada implementação dá resultados semelhantes não só nas execuções sequenciais e forking, mas também nas execuções com threads. Então, porque é que o MRI tem o mesmo ganho de desempenho que o JRuby e o Rubinius? A resposta está na implementação de dormir.
Ressonâncias magnéticas dormir é implementado com o método rb_thread_wait_for C, que utiliza uma outra função chamada nativo_dormir. Vamos dar uma olhadela rápida à sua implementação (o código foi simplificado, a implementação original pode ser encontrada aqui):
A razão pela qual esta função é importante é que, para além de utilizar o contexto estrito do Ruby, também muda para o contexto do sistema para efetuar algumas operações. Em situações como esta, o processo Ruby não tem nada para fazer... Grande exemplo de perda de tempo? Nem por isso, porque existe um GIL que diz: "Nada para fazer nesta thread? Vamos mudar para outra e voltar aqui depois de um tempo". Isto pode ser feito desbloqueando e bloqueando a GIL com GVL_UNLOCK_BEGIN() e GVL_UNLOCK_END() funções.
A situação torna-se clara, mas dormir raramente é útil. Precisamos de mais exemplos da vida real.
Teste de descarregamento de ficheiros
Executa um processo que descarrega e guarda um ficheiro.
requer "net/http"
classe DownloadFileTest < Teste
def executar
Net::HTTP.get("upload.wikimedia.org", "/wikipedia/commons/thumb/7/73/Ruby_logo.svg/2000px-Ruby_logo.svg.png")
fim
fim
Não é necessário comentar os resultados seguintes. São bastante semelhantes aos do exemplo anterior.
1.003642
JRuby
Rubinius
sequencial
0.327980
0.334000
0.329353
bifurcação
0.104766
–
0.121054
enfiamento
0.085789
0.094000
0.088490
Outro bom exemplo pode ser o processo de cópia de ficheiros ou qualquer outra operação de E/S.
Conclusões
Rubinius suporta totalmente tanto a bifurcação como o encadeamento (desde a versão 2.X, quando o GIL foi removido). O seu código pode ser concorrente e executado em paralelo.
JRuby faz um bom trabalho com threads, mas não suporta bifurcação de forma alguma. O paralelismo e a concorrência podem ser alcançados com threads.
RMN suporta forking, mas o threading é limitado pela presença do GIL. A concorrência pode ser alcançada com threads, mas apenas quando o código em execução sai do contexto do interpretador Ruby (por exemplo, operações de IO, funções do kernel). Não há forma de conseguir paralelismo.