Concorrência e Paralelismo Ruby: Um Tutorial Prático

Publicados: 2022-03-11

Vamos começar esclarecendo um ponto de confusão muito comum entre os desenvolvedores Ruby; a saber: Simultaneidade e paralelismo não são a mesma coisa (ou seja, concorrente != paralelo).

Em particular, a simultaneidade do Ruby é quando duas tarefas podem ser iniciadas, executadas e concluídas em períodos de tempo sobrepostos . Isso não significa necessariamente, porém, que ambos estarão rodando no mesmo instante (por exemplo, vários threads em uma máquina de núcleo único). Em contraste, paralelismo é quando duas tarefas são executadas literalmente ao mesmo tempo (por exemplo, vários threads em um processador multicore).

O ponto chave aqui é que threads e/ou processos simultâneos não serão necessariamente executados em paralelo.

Este tutorial fornece um tratamento prático (em vez de teórico) das várias técnicas e abordagens disponíveis para concorrência e paralelismo em Ruby.

Para mais exemplos reais de Ruby, veja nosso artigo sobre Ruby Interpreters and Runtimes.

Nosso caso de teste

Para um caso de teste simples, criarei uma classe Mailer e adicionarei uma função Fibonacci (em vez do método sleep() ) para tornar cada solicitação mais intensiva da CPU, da seguinte maneira:

 class Mailer def self.deliver(&block) mail = MailBuilder.new(&block).mail mail.send_mail end Mail = Struct.new(:from, :to, :subject, :body) do def send_mail fib(30) puts "Email from: #{from}" puts "Email to : #{to}" puts "Subject : #{subject}" puts "Body : #{body}" end def fib(n) n < 2 ? n : fib(n-1) + fib(n-2) end end class MailBuilder def initialize(&block) @mail = Mail.new instance_eval(&block) end attr_reader :mail %w(from to subject body).each do |m| define_method(m) do |val| @mail.send("#{m}=", val) end end end end

Podemos então invocar esta classe Mailer da seguinte forma para enviar e-mail:

 Mailer.deliver do from "[email protected]" to "[email protected]" subject "Threading and Forking" body "Some content" end

(Nota: O código-fonte para este caso de teste está disponível aqui no github.)

Para estabelecer uma linha de base para fins de comparação, vamos começar fazendo um benchmark simples, invocando o mailer 100 vezes:

 puts Benchmark.measure{ 100.times do |i| Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end }

Isso gerou os seguintes resultados em um processador quad-core com MRI Ruby 2.0.0p353:

 15.250000 0.020000 15.270000 ( 15.304447)

Vários processos x multithreading

Não existe uma resposta “tamanho único” quando se trata de decidir se deve usar vários processos ou multithread em seu aplicativo Ruby. A tabela abaixo resume alguns dos principais fatores a serem considerados.

Processos Tópicos
Usa mais memória Usa menos memória
Se o pai morrer antes que os filhos tenham saído, os filhos podem se tornar processos zumbis Todos os threads morrem quando o processo morre (sem chance de zumbis)
Mais caro para processos bifurcados para alternar o contexto, pois o sistema operacional precisa salvar e recarregar tudo Os threads têm consideravelmente menos sobrecarga, pois compartilham espaço de endereço e memória
Os processos bifurcados recebem um novo espaço de memória virtual (isolamento do processo) Os threads compartilham a mesma memória, portanto, é necessário controlar e lidar com problemas de memória simultâneos
Requer comunicação entre processos Pode "comunicar" através de filas e memória compartilhada
Mais lento para criar e destruir Mais rápido para criar e destruir
Mais fácil de codificar e depurar Pode ser significativamente mais complexo para codificar e depurar

Exemplos de soluções Ruby que usam vários processos:

  • Resque: Uma biblioteca Ruby com suporte do Redis para criar trabalhos em segundo plano, colocá-los em várias filas e processá-los posteriormente.
  • Unicorn: Um servidor HTTP para aplicativos Rack projetado para atender apenas clientes rápidos em conexões de baixa latência e alta largura de banda e aproveitar os recursos em kernels Unix/Unix-like.

Exemplos de soluções Ruby que usam multithreading:

  • Sidekiq: Um framework de processamento em segundo plano completo para Ruby. Pretende ser simples de integrar com qualquer aplicação Rails moderna e com desempenho muito superior às outras soluções existentes.
  • Puma: Um servidor web Ruby construído para simultaneidade.
  • Thin: Um servidor web Ruby muito rápido e simples.

Vários processos

Antes de examinarmos as opções de multithreading do Ruby, vamos explorar o caminho mais fácil de gerar vários processos.

Em Ruby, a chamada de sistema fork() é usada para criar uma “cópia” do processo atual. Esse novo processo é agendado no nível do sistema operacional, para que possa ser executado simultaneamente com o processo original, assim como qualquer outro processo independente. ( Nota: fork() é uma chamada de sistema POSIX e, portanto, não está disponível se você estiver executando Ruby em uma plataforma Windows.)

OK, então vamos executar nosso caso de teste, mas desta vez usando fork() para empregar vários processos:

 puts Benchmark.measure{ 100.times do |i| fork do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end Process.waitall }

( Process.waitall aguarda a saída de todos os processos filho e retorna uma matriz de status de processo.)

Este código agora produz os seguintes resultados (novamente, em um processador quad-core com MRI Ruby 2.0.0p353):

 0.000000 0.030000 27.000000 ( 3.788106)

Não muito pobre! Tornamos o mailer ~5x mais rápido apenas modificando algumas linhas de código (ou seja, usando fork() ).

Não fique muito animado embora. Embora possa ser tentador usar bifurcação, já que é uma solução fácil para a simultaneidade do Ruby, ele tem uma grande desvantagem que é a quantidade de memória que ele consumirá. A bifurcação é um pouco cara, especialmente se um Copy-on-Write (CoW) não for utilizado pelo interpretador Ruby que você está usando. Se seu aplicativo usa 20 MB de memória, por exemplo, bifurcá-lo 100 vezes pode consumir até 2 GB de memória!

Além disso, embora o multithreading também tenha suas próprias complexidades, há várias complexidades que precisam ser consideradas ao usar fork() , como descritores de arquivos compartilhados e semáforos (entre processos bifurcados pai e filho), a necessidade de se comunicar via pipes , e assim por diante.

Ruby Multithreading

OK, então agora vamos tentar tornar o mesmo programa mais rápido usando técnicas de multithreading do Ruby.

Vários threads dentro de um único processo têm uma sobrecarga consideravelmente menor do que um número correspondente de processos, pois compartilham espaço de endereço e memória.

Com isso em mente, vamos revisitar nosso caso de teste, mas desta vez usando a classe Thread do Ruby:

 threads = [] puts Benchmark.measure{ 100.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }

Este código agora produz os seguintes resultados (novamente, em um processador quad-core com MRI Ruby 2.0.0p353):

 13.710000 0.040000 13.750000 ( 13.740204)

Desapontamento. Isso com certeza não é muito impressionante! Então o que está acontecendo? Por que isso está produzindo quase os mesmos resultados que obtivemos quando executamos o código de forma síncrona?

A resposta, que é a ruína da existência de muitos programadores Ruby, é o Global Interpreter Lock (GIL) . Graças ao GIL, o CRuby (a implementação de MRI) não suporta realmente o encadeamento.

O Global Interpreter Lock é um mecanismo usado em intérpretes de linguagem de computador para sincronizar a execução de threads para que apenas um thread possa ser executado por vez. Um interpretador que usa GIL sempre permitirá que exatamente um thread e apenas um thread execute por vez , mesmo se executado em um processador multi-core. Ruby MRI e CPython são dois dos exemplos mais comuns de intérpretes populares que possuem um GIL.

Então, de volta ao nosso problema, como podemos explorar multithreading em Ruby para melhorar o desempenho à luz do GIL?

Bem, na ressonância magnética (CRuby), a resposta infeliz é que você está basicamente preso e há muito pouco que o multithreading pode fazer por você.

A simultaneidade do Ruby sem paralelismo ainda pode ser muito útil, no entanto, para tarefas que são pesadas de E/S (por exemplo, tarefas que precisam esperar frequentemente na rede). Portanto, os encadeamentos ainda podem ser úteis na ressonância magnética, para tarefas pesadas de E/S. Há uma razão pela qual os threads foram, afinal, inventados e usados ​​antes mesmo que os servidores multi-core fossem comuns.

Mas dito isso, se você tiver a opção de usar uma versão diferente do CRuby, você pode usar uma implementação alternativa do Ruby, como JRuby ou Rubinius, já que eles não têm um GIL e suportam threads paralelos reais do Ruby.

rosqueado com JRuby

Para provar o ponto, aqui estão os resultados que obtemos quando executamos exatamente a mesma versão encadeada do código de antes, mas desta vez executamos em JRuby (em vez de CRuby):

 43.240000 0.140000 43.380000 ( 5.655000)

Agora estamos falando!

Mas…

Tópicos não são gratuitos

O desempenho aprimorado com vários threads pode levar alguém a acreditar que podemos continuar adicionando mais threads – basicamente infinitamente – para continuar fazendo nosso código rodar cada vez mais rápido. Isso seria realmente bom se fosse verdade, mas a realidade é que os encadeamentos não são gratuitos e, portanto, mais cedo ou mais tarde, você ficará sem recursos.

Digamos, por exemplo, que queremos executar nosso mailer de amostra não 100 vezes, mas 10.000 vezes. Vamos ver o que acontece:

 threads = [] puts Benchmark.measure{ 10_000.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }

Estrondo! Recebi um erro com meu OS X 10.8 depois de gerar cerca de 2.000 threads:

 can't create Thread: Resource temporarily unavailable (ThreadError)

Como esperado, mais cedo ou mais tarde começamos a bater ou ficar sem recursos por completo. Portanto, a escalabilidade dessa abordagem é claramente limitada.

Agrupamento de threads

Felizmente, existe uma maneira melhor; ou seja, pool de threads.

Um pool de encadeamentos é um grupo de encadeamentos pré-instanciados e reutilizáveis ​​que estão disponíveis para executar o trabalho conforme necessário. Os conjuntos de encadeamentos são particularmente úteis quando há um grande número de tarefas curtas a serem executadas em vez de um pequeno número de tarefas mais longas. Isso evita ter que incorrer na sobrecarga de criar um encadeamento um grande número de vezes.

Um parâmetro de configuração de chave para um conjunto de encadeamentos geralmente é o número de encadeamentos no conjunto. Esses threads podem ser instanciados de uma só vez (ou seja, quando o pool é criado) ou lentamente (ou seja, conforme necessário até que o número máximo de threads no pool seja criado).

Quando o pool recebe uma tarefa a ser executada, ele atribui a tarefa a um dos threads ociosos no momento. Se nenhum encadeamento estiver ocioso (e o número máximo de encadeamentos já tiver sido criado), ele espera que um encadeamento conclua seu trabalho e fique ocioso e, em seguida, atribui a tarefa a esse encadeamento.

Agrupamento de threads

Então, retornando ao nosso exemplo, começaremos usando Queue (já que é um tipo de dados seguro para threads) e empregaremos uma implementação simples do pool de threads:

exigir “./lib/mailer” exigir “benchmark” exigir 'thread'

 POOL_SIZE = 10 jobs = Queue.new 10_0000.times{|i| jobs.push i} workers = (POOL_SIZE).times.map do Thread.new do begin while x = jobs.pop(true) Mailer.deliver do from "eki_#{x}@eqbalq.com" to "jill_#{x}@example.com" subject "Threading and Forking (#{x})" body "Some content" end end rescue ThreadError end end end workers.map(&:join)

No código acima, começamos criando uma fila de jobs para os jobs que precisam ser executados. Usamos o Queue para esse propósito, pois é thread-safe (portanto, se vários threads o acessarem ao mesmo tempo, ele manterá a consistência), o que evita a necessidade de uma implementação mais complicada que exija o uso de um mutex.

Em seguida, enviamos os IDs dos remetentes para a fila de tarefas e criamos nosso pool de 10 threads de trabalho.

Dentro de cada thread de trabalho, retiramos itens da fila de trabalhos.

Assim, o ciclo de vida de um thread de trabalho é esperar continuamente que as tarefas sejam colocadas na fila de tarefas e as executem.

Portanto, a boa notícia é que isso funciona e é dimensionado sem problemas. Infelizmente, porém, isso é bastante complicado, mesmo para nosso tutorial simples.

Celulóide

Graças ao ecossistema Ruby Gem, grande parte da complexidade do multithreading é perfeitamente encapsulada em vários Ruby Gems fáceis de usar prontos para uso.

Um ótimo exemplo é a Celuloide, uma das minhas gemas de rubi favoritas. O framework Celluloid é uma maneira simples e limpa de implementar sistemas concorrentes baseados em atores em Ruby. O celulóide permite que as pessoas construam programas simultâneos a partir de objetos simultâneos com a mesma facilidade com que constroem programas sequenciais a partir de objetos sequenciais.

No contexto de nossa discussão neste post, estou focando especificamente no recurso Pools, mas faça um favor a si mesmo e confira com mais detalhes. Usando Celluloid você será capaz de construir programas Ruby multithread sem se preocupar com problemas desagradáveis ​​como deadlocks, e você achará trivial usar outros recursos mais sofisticados como Futures e Promises.

Veja como é simples uma versão multithread do nosso programa de mala direta usando Celluloid:

 require "./lib/mailer" require "benchmark" require "celluloid" class MailWorker include Celluloid def send_email(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end mailer_pool = MailWorker.pool(size: 10) 10_000.times do |i| mailer_pool.async.send_email(i) end

Limpo, fácil, escalável e robusto. O que mais você pode pedir?

Trabalhos em segundo plano

Obviamente, outra alternativa potencialmente viável, dependendo de seus requisitos e restrições operacionais, seria empregar empregos em segundo plano. Existem vários Ruby Gems para dar suporte ao processamento em segundo plano (ou seja, salvar trabalhos em uma fila e processá-los posteriormente sem bloquear o thread atual). Exemplos notáveis ​​incluem Sidekiq, Resque, Delayed Job e Beanstalkd.

Para esta postagem, usarei o Sidekiq e o Redis (um cache e armazenamento de valor-chave de código aberto).

Primeiro, vamos instalar o Redis e executá-lo localmente:

 brew install redis redis-server /usr/local/etc/redis.conf

Com nossa instância local do Redis em execução, vamos dar uma olhada em uma versão do nosso programa mailer de amostra ( mail_worker.rb ) usando o Sidekiq:

 require_relative "../lib/mailer" require "sidekiq" class MailWorker include Sidekiq::Worker def perform(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end

Podemos acionar o Sidekiq com o arquivo mail_worker.rb :

 sidekiq -r ./mail_worker.rb

E então do IRB:

 ⇒ irb >> require_relative "mail_worker" => true >> 100.times{|i| MailWorker.perform_async(i)} 2014-12-20T02:42:30Z 46549 TID-ouh10w8gw INFO: Sidekiq client with redis options {} => 100

Impressionantemente simples. E pode ser dimensionado facilmente apenas alterando o número de trabalhadores.

Outra opção é usar o Sucker Punch, uma das minhas bibliotecas de processamento de RoR assíncronas favoritas. A implementação usando o Sucker Punch será muito semelhante. Só precisaremos incluir SuckerPunch::Job em vez de Sidekiq::Worker e MailWorker.new.async.perform() em vez MailWorker.perform_async() .

Conclusão

Alta simultaneidade não é apenas alcançável em Ruby, mas também é mais simples do que você imagina.

Uma abordagem viável é simplesmente bifurcar um processo em execução para multiplicar seu poder de processamento. Outra técnica é aproveitar o multithreading. Embora os encadeamentos sejam mais leves que os processos, exigindo menos sobrecarga, você ainda pode ficar sem recursos se iniciar muitos encadeamentos simultaneamente. Em algum momento, você pode achar necessário usar um pool de threads. Felizmente, muitas das complexidades do multithreading são facilitadas aproveitando qualquer uma das várias gemas disponíveis, como Celluloid e seu modelo Actor.

Outra maneira de lidar com processos demorados é usar o processamento em segundo plano. Existem muitas bibliotecas e serviços que permitem implementar trabalhos em segundo plano em seus aplicativos. Algumas ferramentas populares incluem estruturas de trabalho baseadas em banco de dados e filas de mensagens.

Bifurcação, encadeamento e processamento em segundo plano são alternativas viáveis. A decisão sobre qual usar depende da natureza de seu aplicativo, seu ambiente operacional e requisitos. Esperamos que este tutorial tenha fornecido uma introdução útil às opções disponíveis.