DEV Community

Cover image for Multithreading e corrotinas com Ruby
Giovanny Cordeiro
Giovanny Cordeiro

Posted on • Edited on

Multithreading e corrotinas com Ruby

O mecanismo de multithreading é o que permite ao desenvolvedor executar várias rotinas simultaneamente, promovendo um resultado mais rápido e eficiente em alguns cenários. Metaforicamente, seria como em uma loja: enquanto uma pessoa atende o cliente, outra está no estoque separando o pedido, outra oferecendo um café e assim por diante, todas colaborando para a melhor experiência do cliente. Agora, relacionando esse exemplo com a computação, a loja seria o programa multithread e as pessoas, as threads. Neste artigo, explico como utilizar os principais módulos de multithreading em Ruby, como Threads, Fibers e Ractors.

Tabela de conteúdos

Como o Ruby lida com multithread

Antes de entrar de cabeça no entendimento dos módulos é necessário dar uma contextualização a respeito de como o Ruby lida por padrão com threads e como ele garante o thread safety.

O Ruby lida com Threads e garante o thread safety de suas aplicações por meio do GIL (Global Interpreter Lock), é o GIL que bloqueia que um programa Ruby tenha mais que uma thread, pois, com apenas uma thread executando, garantimos que não teremos nenhum dos problemas que multithreads podem acarretar, como race condition, deadlocks, starvation e etc, caso deseje aprender a respeito desse o conteúdo, recomendo o ártigo Deadlock, Livelock, Race condition and Starvation para começar.

A utilização do GIL é bastante frequente em linguagens de programação interpretadas. O Python, por exemplo, também tem um GIL e é isso que garante o Thread Safety.

Com base nessa explicação, geralmente não é possível implementar multithread com Ruby, a menos que ignoremos o GIL ou Ruby nos forneça outra maneira de usar um código paralelo.

E é por meio da utilização de outro artifício que a classe Thread implementa multithread. Agora vamos entender como utilizar o modulo Thread e como esse “outro artifício” funciona.

Thread: O Principal modulo para multithread em Ruby

O principal módulo para realizar multithread em Ruby, como já mencionado, é o módulo Thread. É dele que podemos criar e manipular threads no Ruby. Primeiramente vamos entender como criar uma Thread.

Para criar uma Thread em Ruby, é necessário seguir algumas etapas bastante simples, vou apresentar um exemplo e logo em seguida vou explicar como o mesmo se comporta.



Thread.new { puts 'Uma mensagem que esta executando na thread' }
puts 'Fim do programa'


Enter fullscreen mode Exit fullscreen mode
  • Primeiramente, declaramos a nova Thread, instânciando a classe Thread com o método ⁣.new. Em seguida, declaramos o código que desejamos que a Thread execute, que no nosso caso é a mensagem no terminal Uma mensagem que está executando na thread

  • Ao final do programa, mandamos uma mensagem com o texto "Fim de programa"

No entanto, caso executemos esse código, o resultado será a mensagem "Fim do programa", quando gostaríamos que fosse, "uma mensagem que está executando na thread" e, logo em seguida, a mensagem do "Fim do programa".

Para entender esse erro no fluxo de execução, precisamos entender como a classe Thread utiliza o “artificio” que mencionamos no começo do artigo e como isso impacta a execução do programa.

O Artificio da classe Thread

Para manter o Thread Safety garantido pelo GIL, o Ruby usa um mecanismo que, em resumo, "desvia" do GIL. Vamos entender isso mais de perto.

Ao usar o método .new que instância a classe, o que acontece por debaixo dos panos é que o Ruby passa a Thread recém criada para o scheduler. O scheduler agenda a execução da Thread para que ela seja executada assim que possível pelo Sistema Operacional. Quando essa ela for executada, o valor é retornado para a Thread principal, que é a Thread do GIL e do interpretador Ruby.

Por meio dessa manobra, não conseguimos controlar exatamente quando a nova Thread será executada. Pois, quando ela é passada para o scheduler não temos mais poder algum sobre ela, apenas temos a garantia de que será executada assim que possível.

Com isso, vamos ter o mecanismo semelhante ao multithreading, mas que é gerenciado diretamente pelo sistema operacional.

Agora, para resolver o problema do fluxo de execução do nosso exemplo, é importante entender que, enquanto a nova thread está sendo criada e agendada, a thread principal continuará a ser executada normalmente.

Mas no caso de a thread principal terminar, ela automaticamente encerrará todas as suas threads filhas, o que pode impedir que as threads filhas alcancem o resultado desejado.

E é justamente isso que está acontecendo no nosso caso: o programa principal termina antes que a execução da(s) filha(s), seja porque o scheduler não encontrou um momento oportuno para agendar a thread, seja porque estávamos no meio da execução do código passados a ela.

Resolvendo o problema

Para resolver esse problema, precisaríamos desenvolver um método que avise à thread principal para aguardar a conclusão da execução da thread filha (ou das threads filhas, caso haja mais de uma) antes de finalizar a principal.

Felizmente, não é necessário implementar esse método manualmente, pois o Ruby já fornece o método .join. Método esse serve exatamente para fazer com que a thread principal aguarde a conclusão de uma ou mais threads filhas antes de terminar a sua própria execução.

Vamos corrigir o nosso exemplo:



thread = Thread.new { puts 'Uma mensagem que está executando na thread' }
puts 'Fim do programa'
thread.join


Enter fullscreen mode Exit fullscreen mode


'Fim do programa'
'Uma mensagem que está executando na thread'


Enter fullscreen mode Exit fullscreen mode

Agora, temos o resultado desejado com ambas as mensagens exibidas no terminal. Note que a mensagem "Fim do programa" aparece antes da mensagem "Uma mensagem que está executando na thread". Caso você queira inverter a ordem, basta chamar o método .join antes da linha puts 'Fim do programa'.

Uma aplicação mais próxima da realidade

Vamos agora explorar um exemplo mais real, que mostra uma aplicação prática e poderosa do uso de multithreading, permitindo-nos também explorar outros conceitos.

No seguinte exemplo, temos um programa em Ruby que tem como objetivo baixar 12 imagens de maneira rápida, utilizando as URIs dessas imagens.



require 'net/http'
require 'uri'

pictures = %w[ <<lista_com_12_URIs_de_imagens>> ]
parts_list = pictures.each_slice(3).to_a

threads = parts_list.each_with_index.map do |part_list, group_index|
  Thread.new(part_list) do |arr_url|
    arr_url.each_with_index do |uri, image_index|
      total_index = group_index * 3 + image_index
      file_name = "pica-pau-#{total_index}.jpg"
      url = URI.parse(uri)
      response = Net::HTTP.get_response(url)
      if response.is_a?(Net::HTTPSuccess)
        File.open(file_name, 'wb') do |file|
          file.write(response.body)
        end
      else
        puts "Falha no momento da requisição #{response.code}: #{response.message}"
      end
    end
  end
end
threads.each(&:join)


Enter fullscreen mode Exit fullscreen mode

A lógica central do algoritmo é a seguinte: temos uma lista de 12 URIs de imagens armazenadas na variável pictures. Dividimos essa lista em 3 partes iguais, que são armazenadas na variável parts_list. Em seguida, utilizamos o método parts_list.each_with_index.map para criar uma thread para cada parte da lista.

Cada thread criada percorre a parte correspondente da lista de URIs usando o método arr_url.each_with_index. Para cada URI, a thread realiza uma requisição que baixa a imagem e a salva na máquina do usuário, desde que a resposta da requisição seja bem-sucedida (if response.is_a?(Net::HTTPSuccess)). Caso contrário, uma mensagem de falha é exibida no terminal.

No entanto, vamos alisar um ponto especifico do código, que é o seguinte:



threads = parts_list.each_with_index.map do |part_list, group_index|
  Thread.new(part_list) do |arr_url|
    arr_url.each_with_index do |uri, image_index|
      # logica restante


Enter fullscreen mode Exit fullscreen mode

Perceba que, ao criar uma thread para cada parte da lista, precisamos passar a variável como argumento para o construtor da classe Thread. Isso é feito na linha Thread.new(part_list) do |arr_url|, onde part_list é passado como argumento. Para que em seguida, utilizemos a variável como parâmetro dentro da thread, como mostrado em arr_url.each_with_index do |uri, image_index|.

Você pode se perguntar por que devemos fazer dessa forma, quando poderíamos usar a variável part_list diretamente, sem passá-la como argumento para a Thread, como mostrado no exemplo a seguir:



threads = parts_list.each_with_index.map do |part_list, group_index|
  Thread.new do
    part_list.each_with_index do |uri, image_index|
      # logica restante


Enter fullscreen mode Exit fullscreen mode

Existe uma razão bem plausível para isso, nós priorizamos a primeira abordagem que passa a variável para a Thread como argumento, para garantir o Thread safety e o compartilhamento eficaz de variáveis entre as threads.

Usar a variável part_list diretamente, por meio do escopo global como exemplificado na segunda abordagem, poderia causar problemas de execução e dessincronia entre as threads. Por exemplo: Se a Thread 01 for criada e entrar em execução, e logo em seguida a  Thread 02 ser criada, a Thread 01 poderia modificar part_list enquanto a Thread 02 ainda estaria lendo a variável. Isso pode levar a erros de sincronização e tornar a detecção e correção desses erros bastante complicada.

Para evitar esse tipo de confusão, seguimos a primeira implementação, que faz uma cópia do valor que queremos utilizar para cada nova Thread. Dessa forma, cada uma delas trabalha com sua própria cópia da variável, evitando problemas entre threads e garantindo a sincronização.

A ilustração a seguir apresenta a primeira implementação de maneira mais visual.

ilustrate_example

Esse tipo de implementação utilizando Multithreading promove uma melhoria de desempenho muito significativa quando comparado com a abordagem tradicional mono thread.

Confira no vídeo a seguir e observe a diferença de tempo entre um algoritmo executado com uma única thread tradicional e o mesmo algoritmo otimizado com multithreading.

Caso tenha curiosidade em ver a diferença de desempenho na sua máquina ou analisar o código, você pode ver ambas implementações disponíveis aqui no meu GitHub.

Fibers: Utilizando corrotinas com Ruby

Fibers é um mecanismo que torna possível interromper a execução de uma parte do código para executar outra. Esse tipo de funcionalidade é também conhecido como corrotinas (or coroutines, if you prefer to speak English).

Quando a rotina principal é interrompida, ela permite que uma rotina secundária seja executada, com a expectativa de que, em algum momento, a rotina secundária retorne o controle para a rotina principal.

É essa característica que faz com que as Fibers sejam multitarefas cooperativas, permitindo que as tarefas colaborem para que, ao final do programa, o resultado esperado seja alcançado.

Por isso, fibers não são uma ferramenta que recorre a multithreading, mas sim uma ferramenta para a implementação de corrotinas, que pode tornar sua aplicação menos complexa e/ou mais flexível.

Vamos entender os comandos principais da abstração Fiber:

  • Fiber.new é usado para criar uma nova Fiber. Com esse comando, você pode definir a lógica da Fiber por meio de um bloco.

  • example_fiber.resume: Utilizado para executar o código que esta dentro de uma fiber.

  • Fiber.yield: Utilizado para retornar o controle para a rotina principal.

Vamos analisar como esses comandos são utilizados com o exemplo prático seguinte:



fiber_example = Fiber.new do
  data = [1, 2, 3]
  data.each do |item|
    puts "Processando #{item}"
    Fiber.yield
  end
  nil
end

3.times do
  puts 'Ola'
  fiber_example.resume
end


Enter fullscreen mode Exit fullscreen mode

Neste exemplo, criamos uma Fiber usando o comando Fiber.new e a armazenamos na variável fiber_example. Dentro da Fiber, definimos uma lógica simples: percorrer cada item do array armazenado na variável data. A cada iteração do array, registramos a mensagem "Processando #{item}" e retornamos a execução para a rotina principal, que foi a que chamou a Fiber.

Toda essa lógica está apenas presente dentro da variável fiber_example e será executada somente quando a Fiber for chamada com fiber_example.resume.

Em seguida, temos um loop que executa o mesmo código três vezes. Em cada iteração, o loop envia a mensagem "Olá" e chama a Fiber.

O resultado após a execução do código é o seguinte:



Ola
Processando 1
Ola
Processando 2
Ola
Processando 3


Enter fullscreen mode Exit fullscreen mode

O que está acontecendo no algoritmo é o seguinte: ele executa o loop 3.times, que imprime a mensagem "Olá" no terminal durante a primeira iteração. Em seguida, o código da Fiber é executado, percorrendo o primeiro item do array data e registrando a mensagem "Processando 1" no terminal. Após essa iteração, a execução da Fiber é pausada e retorna ao loop 3.times na rotina principal. Esse processo se repete até que o loop 3.times termine.

Você pode estar se perguntando sobre o nil após o loop each. O retorno de nil é necessário para informar à Fiber que sua execução chegou ao fim. Neste caso, o nil explícito não é necessário, pois o loop .each já retorna nil ao final de sua execução.

Veja o video da execução do algoritmo:

Caso você tenha interesse em ver outro exemplo de uso de Fibers, vou deixar disponível no meu GitHub um algoritmo de contagem de palavras implementado com Fibers. Exemplo esse que vi no livro "Programming Ruby - The Pragmatic Programmers' Guide".

Ractors: Como passar por cima do GIL

É por meio do modulo Ractor que conseguimos realmente criar multithreads com Ruby. Pois cada Ractor mantém seu próprio GIL, o que pode melhorar o desempenho.

Com ractors podemos compartilhar valores entre threads, mas apenas por maneiras pre-definidas.

No livro "Programming Ruby - The Pragmatic Programmer's Guide", os autores explicam metaforicamente que um Ractor é como uma sala com uma porta de entrada e uma porta de saída, cada uma com possíveis filas.

O conteúdo que preenche a sala representa o código que o Ractor executa, a porta de entrada é por onde os inputs chegam, e a porta de saída é por onde os outputs são gerados. É através dessas "portas" (metaforicamente falando) que o mecanismo de compartilhamento de variáveis funciona.

Para compartilhar variáveis entre Ractors, ou seja, entre threads, você possui quatro opções:

  1. Você pode enviar um valor diretamente para outro Ractor usando o método .send, por exemplo, other_ractor.send. Na metáfora proposta pelo livro, isso é como enviar uma pessoa para a porta de outro Ractor. Essa mecânica é não bloqueante entre as threads.

  2. Você pode receber o output de outro Ractor usando o método other_ractor.take. Na metáfora, isso é como deixar uma pessoa na porta de saída do outro Ractor, com a tarefa de pegar o que sai da porta. No entanto, esse mecanismo é bloqueante, pois aguarda o resultado da outra thread.

  3. Dentro do código de um Ractor, você pode esperar receber um input externo para executar uma determinada ação. Metaforicamente, isso é como esperar alguém entrar pela porta de entrada. Isso pode ser feito usando o método Ractor.receive.

  4. Também dentro do código de um Ractor, você pode esperar que outro Ractor solicite um valor. Essa tarefa é bloqueante entre threads, pois o Ractor aguarda a solicitação de outro. É como esperar alguém bater na porta de saída para pedir algo, e então o Ractor reage, similar a um serviço de drive-thru.

Um conceito muito importante em Ractors é que todo novo Ractor criado é completamente isolado. Isso significa que o código dentro do Ractor só existe dentro dele e não pode acessar variáveis globais nem passar variáveis diretamente. A única forma de passar variáveis para um Ractor é por meio de chamadas ao método .send.

Existem duas formas de chamadas a um Ractor:

  • Chamadas externas: Mensagens enviadas para o Ractor a partir de outros lugares no código.

  • Chamadas internas: Mensagens enviadas de dentro de uma classe que conhece a implementação específica do Ractor.

Esses tipos de comunicação são de grande valia, porque permitem que você garanta o thread safety automaticamente, enviando valores entre Ractors por meio do método .send e recebendo valores usando os métodos .yield e .take.

Vamos ver um exemplo simples utilizando Ractors: um conversor paralelo de valores Celsius para Fahrenheit e Kelvin.



temperatures = [0, 10, 20, 30, 40, 50]
ractor_fahrenheit = Ractor.new(temperatures) do |celsius_list|
  celsius_list.map { |temp| (temp * 9.0 / 5) + 32 }
end

ractor_kelvin = Ractor.new(temperatures) do |celsius_list|
  celsius_list.map { |temp| temp + 273 }
end

list_fahrenheit = ractor_fahrenheit.take
list_kelvin = ractor_kelvin.take

puts "List temperatures in Celsius: #{temperatures}"
puts "Converted temperatures in Fahrenheit: #{list_fahrenheit.inspect}"
puts "Converted temperatures in Kevin: #{list_kelvin.inspect}"


Enter fullscreen mode Exit fullscreen mode

Nesse exemplo, o resultado no terminal seria:



List temperatures in Celsius: [0, 10, 20, 30, 40, 50]

Converted temperatures in Fahrenheit: [32.0, 50.0, 68.0, 86.0, 104.0, 122.0]

Converted temperatures in Kevin: [273, 283, 293, 303, 313, 323]


Enter fullscreen mode Exit fullscreen mode

É importante deixar claro que o módulo Ractor ainda é experimental na versão Ruby 3.3.1, que foi a versão que utilizei para testar os códigos enquanto escrevia este artigo. Portanto, o módulo pode conter erros que ainda não foram corrigidos. Se optar por usá-lo, faça-o por sua própria conta e risco.

Além disso, como Ractors realmente passam por cima do GIL e implementam multithreading real no Ruby, é crucial estar atento a problemas de sincronização de dados e deadlocks ao utilizar este módulo.

Conclusão e agradecimentos

Neste artigo, passamos pelos principais módulos de multithreading e corrotinas no Ruby: Threads, Fibers e Ractors. Enquanto estudava sobre esse tema, me surgiu várias ideias sobre como utilizá-los em diferentes cenários. Por isso, decidi escrever este artigo para compartilhar o conhecimento e ajudar outras pessoas a encontrarem outras formas de utilizar.

Não poderia deixar de agradecer a Cherry, o Henry e o Clinton por ter disponibilizado um tempinho para ler esse artigo e propor melhorias e dicas, muito obrigado de verdade gente ❤️. Agradeço também a toda comunidade da He4rt Developers que sempre me ajuda quando passo por perrengues técnicos.

Espero que o artigo tenha sido útil para entender os conceitos e as implementações desses módulos. Se você encontrar qualquer ponto negativo ou erro, estou totalmente à disposição para corrigir e aprender com você nos comentários!

Referências

Top comments (2)

Collapse
 
leandronsp profile image
Leandro Proença

muito bom o artigo, excelentes pontos!

uma coisa curiosa sobre o GIL é que mesmo ele garantindo a execução de uma thread por vez, se nao passarmos as variaveis pra thread pode acontecer data race, justamente pq quando uma thread faz alguma operação atômica, como entrada e saída de métodos, o GIL é liberado.

ou seja, mesmo com GIL dá pra ter data races em Ruby.

e outro ponto bacana sobre Fibers é que multitaskinh cooperativo abre portas para I/O assíncrono, bem bacana este tipo de utilização, pelo que o Ruby 3 trouxe uma interface que permite implementar um escalonador de Fibers.

mais uma vez, parabéns pelo excelente artigo

Collapse
 
giovannycordeiro profile image
Giovanny Cordeiro

Caraca, muito interessante o escalonador de Fibers e da condição das Threads que você mencionou, certamente vou estudar mais a respeito!