Back-End

11 jul, 2016

[Manga-Downloadr] Portando de Crystal para Ruby (e um pouco de JRuby)

Publicidade

Eu tenho esse meu velho projeto de estimação chamado de Manga Downloadr que publiquei em 2014. Era uma versão muito rústica. Eu estava experimentando com solicitações assíncronas em Typhoeus e, no final, o código acabou se tornando super confuso.

A natureza original do Manga Downloader não é diferente de um rastreador web, ele busca páginas HTML, analisa-as a fim de encontrar coleções de links e continua a busca até que um conjunto de imagens é transferido. Então, eu as organizo em volumes, otimizo-as para se ajustarem à resolução da tela do Kindle, e as compilo em arquivos PDF. Isso faz com que esse projeto seja um exercício interessante na tentativa de fazer solicitações HTTP simultâneas e processar os resultados.

Um ano mais tarde, eu estava aprendendo Elixir. A Manga Downloadr era um bom candidato para eu descobrir como implementar a mesma coisa em uma linguagem diferente.

Finalmente, eu fui aprendendo mais sobre Crystal, uma plataforma inspirada em Ruby que pode compilar o código-fonte Ruby-like em binários nativos LLVM-otimizados. E como um bônus, ele apresenta um Go-like CSP channels e fibras para permitir código concorrente.

Então eu adaptei a minha versão Elixir para Crystal, e o resultado é este código que você pode encontrar no GitHub como AkitaOnRails/cr_manga_downloadr.

Ele funciona muito bem e executa muito rápido, limitado principalmente por quantos pedidos MangaReader podem ser respondidos ao mesmo tempo e a velocidade/confiabilidade da conexão à Internet. Então, como a minha versão original de Ruby era um código terrível, era um bom momento para reescrevê-lo. E como Crystal é surpreendentemente parecido com Ruby, eu decidi portá-lo.

O port foi quase demasiadamente trivial.

Tudo o que fiz foi principalmente copiar e colar o código Crystal sem as Type Annotations. E eu tive que substituir as lightweight Fibers e as implementações de canal por concorrência sobre as tradicionasi Threads do Ruby.

A nova versão 2.0 para a versão do Ruby da ferramenta pode ser encontrada neste repositório do GitHub: akitaonrails/manga-downloadr.

Ruby, Ruby em todos os lugares!

Mover entre Ruby e Crystal não é tão difícil. A equipe do Crystal fez um trabalho fantástico de implementação de uma biblioteca padrão muito sólida (stdlib), que muito se assemelha ao MRI Ruby.

Por exemplo, vamos comparar um trecho da minha versão de Crystal em primeiro lugar:

def fetch(page_link : String)
  get page_link do |html|
    images = html.xpath("//img[contains(@id, 'img')]").as(XML::NodeSet)
    image_alt = images[0]["alt"]
    image_src = images[0]["src"]
    if image_alt && image_src
      extension      = image_src.split(".").last
      list           = image_alt.split(" ").reverse
      title_name     = list[4..-1].join(" ")
      chapter_number = list[3].rjust(5, '0')
      page_number    = list[0].rjust(5, '0')
      uri = URI.parse(image_src)
      CrMangaDownloadr::Image.new(uri.host as String, uri.path as String, "#{title_name}-Chap-#{chapter_number}-Pg-#{page_number}.#{extension}")
    else
      raise Exception.new("Couldn't find proper metadata alt in the image tag")
    end
  end
end

Agora vamos verificar a versão portada do Ruby:

def fetch(page_link)
  get page_link do |html|
    images = html.css('#img')

    image_alt = images[0]["alt"]
    image_src = images[0]["src"]

    if image_alt && image_src
      extension      = image_src.split(".").last
      list           = image_alt.split(" ").reverse
      title_name     = list[4..-1].join(" ")
      chapter_number = list[3].rjust(5, '0')
      page_number    = list[0].rjust(5, '0')

      uri = URI.parse(image_src)
      Image.new(uri.host, uri.path, "#{title_name}-Chap-#{chapter_number}-Pg-#{page_number}.#{extension}")
    else
      raise Exception.new("Couldn't find proper metadata alt in the image tag")
    end
  end
end

É estranho como eles são semelhantes, até chamadas stdlib tais como métodos URI.parse ou Array, tais como split.

Depois de remover as Type Annotations da versão do Crystal, tudo fica 99% Ruby.

O Ruby não liga se você está tentando chamar um método em um objeto Nil – no código-fonte em tempo de compilação. O Crystal faz verificações em tempo de compilação e, se sentir que a chamada de método será sobre Nil, não vai nem mesmo compilar. Portanto, isso é uma grande coisa para evitar bugs sutis.

No Rails, estamos acostumados a utilizar método #try ousado. O Ruby 2.3 introduziu o operador de navegação segura.

Assim, no Ruby 2.3 com Rails, as seguintes linhas são válidas:

obj.try(:something).try(:something2)
obj&.something&.something2

Em Crystal podemos fazer o seguinte:

obj.try(&.something).try(&.something2)

Então, é parecido. Use com cuidado.

Como mencionei antes, Crystal é parecido com Ruby, mas não é para ser compatível, por isso não podemos apenas carregar Rubygems sem portar. Neste exemplo, eu não tenho Nokogiri para fazer o parse do HTML. Mas é aí que stdlib brilha: Crystal vem com parsers XML/HTML e JSON suficientemente bons. Assim, podemos fazer o parse do HTML como XML e usar XPath simples no lugar.

Em vez de Net::HTTP do Ruby, temos HTTP::Client (mas seus métodos e semântica são surpreendentemente similares).

Existem outras diferenças, por exemplo, este é o arquivo principal, que exige todos os outros em Ruby:

...
$LOAD_PATH.unshift File.join(File.dirname(__FILE__), "lib")

require "manga-downloadr/records.rb"
require "manga-downloadr/downloadr_client.rb"
require "manga-downloadr/concurrency.rb"
require "manga-downloadr/chapters.rb"
require "manga-downloadr/pages.rb"
require "manga-downloadr/page_image.rb"
require "manga-downloadr/image_downloader.rb"
require "manga-downloadr/workflow.rb"

E esta é a versão do Crystal, do mesmo manifesto:

require "./cr_manga_downloadr/*"
...

Por outro lado, precisamos ser um pouco mais explícitos em cada arquivo de código-fonte do Crystal, e declarar as dependências específicas quando necessário. Por exemplo, no arquivo pages.cr, começa assim:

require "./downloadr_client"
require "xml"

module CrMangaDownloadr
  class Pages < DownloadrClient(Array(String))
  ...

Crystal tem menos espaço para “mágica”, mas é capaz de manter um alto nível de abstração de qualquer maneira.

Uma palavra sobre Types

Nós podemos passar a próxima década masturbando sobre tudo o que há sobre Types, e isso será extremamente chato.

A única coisa que você deve entender: o compilador deve saber a assinatura do método de classes antes que você possa usá-las. Não há nenhum componente em tempo de execução que pode fazer a introspecção de objetos em tempo real, como em Ruby e outras linguagens dinâmicas (mesmo Objective-C/Swift pode fazer coisas mais dinâmicas do que Crystal).

Na maioria das vezes, o compilador do Crystal vai ser inteligente o suficiente para inferir os Types para você, para que você não precise ser absolutamente explícito. Você deve seguir o exemplo do compilador para saber quando usar as Type Annotations.

O que pode assustar você em primeiro lugar é a necessidade de Type Annotations, entender Generics e por aí vai. O compilador irá imprimir todo erro assustador do dump, e você vai precisar se acostumar com isso. A maioria dos erros assustadores geralmente será um Type Annotation ausente ou você tentando chamar um método sobre um possível objeto Nil.

Por exemplo, se eu mudar a seguinte linha no arquivo de teste page_image_spec.cr:

# line 8:
image = CrMangaDownloadr::PageImage.new("www.mangareader.net").fetch("/naruto/662/2")

# line 10:
# image.try(&.host).should eq("i8.mangareader.net")
image.host.should eq("i8.mangareader.net")

A linha comentada reconhece que a instância image pode vir como Nil, então adicionamos uma chamada #try explícita no spec.

Se tentarmos compilar sem esse reconhecimento, este será o erro o compilador irá jogar sobre você:

$ crystal spec                                                        [
Error in ./spec/cr_manga_downloadr/page_image_spec.cr:10: undefined method 'host' for Nil (compile-time type is CrMangaDownloadr::Image?)

    image.host.should eq("i8.mangareader.net")
          ^~~~

=============================================================================

Nil trace:

  ./spec/cr_manga_downloadr/page_image_spec.cr:8

        image = CrMangaDownloadr::PageImage.new("www.mangareader.net").fetch("/naruto/662/2")
...

Há um grande despejo de stacktrace depois do trecho acima, mas você só precisa prestar atenção nas primeiras linhas que já dizem o que está errado: “método indefinido ‘host’ para Nil (em tempo de compilação, type é CrMangaDownloadr::Imagem?)”. Se você sabe como ler, não deve ter nenhum problema na maioria das vezes.

Agora, Chapters, Pages e PageImage (todas as subclasses de DownloadrClient) são basicamente a mesma coisa: eles fazem solicitações HTTP::Client.

Esta é a forma como a classe Pages é implementada:

...
module CrMangaDownloadr
  class Pages < DownloadrClient(Array(String))
    def fetch(chapter_link : String)
      get chapter_link do |html|
        nodes = html.xpath_nodes("//div[@id='selectpage']//select[@id='pageMenu']//option")
        nodes.map { |node| [chapter_link, node.text as String].join("/") }
      end
    end
  end
end

#get é um método da superclasse DownloadrClient que recebe uma string chapter_link e um bloco. O bloco recebe uma coleção nodes html analisados e podemos jogar com eles, retornando uma Array de Strings.

É por isso que temos a (Array(String)) quando herdamos de DownloadrClient. Vamos ver como a superclasse DownloadrClient é implementada.

module CrMangaDownloadr
  class DownloadrClient(T)
    ...
    def get(uri : String, &block : XML::Node -> T)
      response = @http_client.get(uri)
      case response.status_code
      when 301
        get response.headers["Location"], &block
      when 200
        parsed = XML.parse_html(response.body)
        block.call(parsed)
      end
      ...
    end
  end
end

Você pode ver que essa classe recebe um Generic Type e o usa como o tipo de retorno para o bloco que produziu o método #get. O XML::Node -> T é a declaração da assinatura para o bloco, enviando XML::Node e recebendo qualquer que seja o tipo T. Em tempo de compilação, imagine esse T sendo substituído por Array(String). É assim que você pode criar classes que podem lidar com qualquer número de diferentes Types sem ter a sobrecarga do polimorfismo.

Se você vem de Java, C#, Go ou qualquer outra linguagem moderna de tipagem estática, provavelmente já sabe o que é um Generic.

Você pode ir muito longe com Generics, veja como o nosso Concurrency.cr começa:

class Concurrency(A, B, C)
  ...
  def fetch(collection : Array(A)?, &block : A, C? -> Array(B)?) : Array(B)?
    results = [] of B
    collection.try &.each_slice(@config.download_batch_size) do |batch|
      engine  = if @turn_on_engine
                  C.new(@config.domain)
                end
      channel = Channel(Array(B)?).new
      batch.each do |item|
      ...

E é assim que nós usamos no workflow.cr:

private def fetch_pages(chapters : Array(String)?)
  puts "Fetching pages from all chapters ..."
  reactor = Concurrency(String, String, Pages).new(@config)
  reactor.fetch(chapters) do |link, engine|
    engine.try( &.fetch(link) )
  end
end

Nesse exemplo, imagine A sendo substituído por String, B também está sendo substituído por String e C sendo substituído por páginas na classe Concurrency.

Esse é a “primeira-versão-que-funcionou”, por isso provavelmente não é muito idiomática. Ou isso poderia ser resolvido com menos Generics exercizing ou talvez eu poderia simplificar com o uso de Macros. Mas ele está funcionando até que bem como está.

A versão Ruby pura acaba deste jeito:

class Concurrency
  def initialize(engine_klass = nil, config = Config.new, turn_on_engine = true)
    ...
  end
  def fetch(collection, &block)
    results = []
    collection&.each_slice(@config.download_batch_size) do |batch|
      mutex   = Mutex.new
      threads = batch.map do |item|
      ...

Essa versão é muito mais “simples” em termos de densidade de código-fonte. Mas, por outro lado, teríamos que testar essa versão do Ruby muito mais, porque ela tem muitas permutações diferentes (até mesmo injetamos classes por meio do engine_klass), e teremos que nos certificar de que está respondendo corretamente. Na prática, devemos adicionar testes para todas as combinações de argumentos do inicializador.

Na versão Crystal, porque todos os types foram verificados em tempo de compilação, isso foi mais exigente em termos de annotations; mas podemos ter certeza de que, se ele compila, será executado conforme o esperado.

Eu não estou dizendo que Crystal não precisa qualquer tipo de specs.

Compiladores só podem ir tão longe. Mas quanto é “tão longe”? Sempre que você é “forçado” a adicionar Type Annotations, vou afirmar que essas partes são tentando ser espertas demais ou são intrinsecamente complexas. Essas são as peças que exigem níveis extra de testes em Ruby e, se nós adicionarmos annotations corretamente, podemos ter menos testes (não precisamos cobrir a maioria das permutações) na versão Crystal (complexidade exponencial de permutações poderia diminuir para uma complexidade linear, acho eu).

Uma palavra sobre Ruby Threads

Os principais conceitos que você deve entender sobre concorrência em Ruby são os seguintes:

  • MRI Ruby tem uma GIL, um Global Interpreter Lock, que proíbe a execução de código simultaneamente.
  • MRI Ruby tem acesso e expõe Threads nativas desde a versão 1.9. Mas mesmo se você iniciar múltiplas Threads, elas serão executadas sequencialmente, porque apenas uma thread pode estar no bloqueio de cada vez.
  • Operações I/O são a exceção: elas liberaram o bloqueio de outras threads para executar enquanto a operação está esperando para ser concluída. O OS irá sinalizar o programa através do OS level poll.

Isso significa que se o seu aplicativo é I/O intensivo (solicitações HTTP, leituras ou escritas de arquivos, operações de socket etc.), você vai ter alguma concorrência. Um servidor web, como o Puma, pode levar alguma vantagem de Threads porque a grande parte das operações envolve receber e enviar solicitações HTTP sobre o wire, o que tornaria o processo Ruby ocioso enquanto espera.

Se seu aplicativo usa CPU intensivamente (algoritmos pesados, manipulação de dados, coisas que realmente fazem a CPU esquentar), então você não pode tirar vantagem de Threads nativas, pois apenas uma será executado por vez. Se você tem múltiplos núcleos em sua CPU, você pode fazer um fork do seu processo para o número de núcleos disponíveis.

Você deve verificar grosser/parallel para tornar isso mais fácil.

É por isso que o Puma também tem um modo de “worker”. “Worker” é o nome que costumamos dar aos processos filhos de fork.

No caso desse processo downloader, ele irá executar milhares de solicitações HTTP para extrair os metadados necessários a partir das páginas MangaReader. Por isso há definitivamente muito mais I/O intensivo do que CPU intensivo (as peças de uso intensivo da CPU são o parse de HTML e mais tarde o redimensionamento da imagem e a compilação do PDF).

Uma versão seqüencial do que tem de ser feito, em Ruby, se parece com isto:

def fetch_sequential(collection, &block)
  results = []
  engine  = @turn_on_engine ? @engine_klass.new(@config.domain) : nil
  collection&.each_slice(@config.download_batch_size) do |batch|
    batch.each do |item|
      batch_results = block.call(item, engine)&.flatten
      results += ( batch_results || [])
    end
  end
  results
end

Se tivermos 10.000 links na collection, primeiro cortamos para @config.download_batch_size e iteramos sobre essas fatias menores, chamando algum bloco e acumulando os resultados. Esse algoritmo é ingênuo, como você vai descobrir na próxima seção, mas fique comigo.

No Elixir, você pode disparar micro-processos para fazer as solicitações HTTP em paralelo. Em Crystal, você pode disparar até Fibers e esperar que as solicitações HTTP sejam concluídas e sinalizar os resultados por meio de Channels.

Ambas são maneiras leves, e você pode ter centenas ou mesmo milhares rodando em paralelo. O Manga Reader provavelmente vai reclamar se você tentar tantos de uma só vez, de modo que o limite não está no código, mas no serviço externo.

Para transformar a versão sequencial em uma concorrente, isto é o que podemos fazer em Crystal:

def fetch(collection : Array(A)?, &block : A, C? -> Array(B)?) : Array(B)?
  results = [] of B
  collection.try &.each_slice(@config.download_batch_size) do |batch|
    channel = Channel(Array(B)?).new
    batch.each do |item|
      spawn {
        engine  = if @turn_on_engine
                    C.new(@config.domain)
                  end
        reply = block.call(item, engine)
        channel.send(reply)
        engine.try &.close
      }
    end
    batch.size.times do
      reply = channel.receive
      if reply
        results.concat(reply.flatten)
      end
    end
    channel.close
    puts "Processed so far: #{results.try &.size}"
  end
  results
end

Buscar uma enorme coleção e cortar em “lotes” menores é fácil. Agora temos uma coleção de lote bem menor. Para cada item (geralmente uma URI) fazemos spawn em uma Fiber e chamamos um bloco que vai solicitar e processar os resultados. Assim que terminar o processamento, os resultados são enviados ao longo de um channel.

Assim que terminamos de iterar sobre o lote e desovar quantas Fibers, podemos “esperar” por eles, fazendo channel.receive, que irá começar a receber resultados assim que as Fibers finalizarem cada solicitação/processamento de cada URI.

Nós acumulamos os resultados e seguimos sobre o próximo lote da coleção até terminar. A quantidade de concorrência é determinada pelo tamanho do lote (é como o que eu fiz com o ‘poolboy’ sobre Elixir, onde começamos com um número fixo de processos para executar em paralelo e evitar fazer uma negação de serviço ao Manga Reader).

Por sinal, esta implementação de Crystal é semelhante ao que você faria se estivesse em Go, usando Channels.

Na versão Ruby, você pode disparar Threads nativas – que têm muita sobrecarga para desovar! – e assumir as solicitações HTTP que serão executadas quase todas em paralelo. Porque é I/O intensivo, você pode ter todos em paralelo. É assim que se parece:

def fetch(collection, &block)
  results = []
  collection&.each_slice(@config.download_batch_size) do |batch|
    mutex   = Mutex.new
    threads = batch.map do |item|
      Thread.new {
        engine  = @turn_on_engine ? @engine_klass.new(@config.domain) : nil
        Thread.current["results"] = block.call(item, engine)&.flatten
        mutex.synchronize do
          results += ( Thread.current["results"] || [] )
        end
      }
    end
    threads.each(&:join)
    puts "Processed so far: #{results&.size}"
  end
  results
end

Threads são todas inicializadas em um estado de “pausa”. Uma vez que instanciamos esses muitas Threads, podemos fazer um #join em cada uma delas e aguardar para todas terminarem.

Uma vez que cada Thread termina o mesmo URI request/process, os resultados têm que ser acumulados num armazenamento global, nesse caso, um array simples chamado results. Mas porque nós podemos ter a chance de duas ou mais Threads tentarem atualizar o mesmo array, poderíamos muito bem sincronizar o acesso (não tenho certeza se o acesso de array do Ruby já está sincronizado, mas acho que não). Para sincronizar o acesso, usamos um Mutex, que é um bloqueio de granulação fina, para ter certeza de que apenas uma thread pode modificar o array global ao mesmo tempo.

Para provar que Ruby pode suportar as operações de I/O simultâneas, eu adicionei dois métodos à classe Concurrent: o primeiro é apenas #fetch e é a implementação da Thread acima; o segundo é chamada #fetch_sequential e é a versão sequencial também mostrada no início desta seção. E acrescentei a seguinte spec:

it "should check that the fetch implementation runs in less time than the sequential version" do
  reactor = MangaDownloadr::Concurrency.new(MangaDownloadr::Pages, config, true)
  collection = ["/onepunch-man/96"] * 10
  WebMock.allow_net_connect!
  begin
    concurrent_measurement = Benchmark.measure {
      results = reactor.fetch(collection) { |link, engine| engine&.fetch(link) }
    }
    sequential_measurement = Benchmark.measure {
      results = reactor.send(:fetch_sequential, collection) { |link, engine| engine&.fetch(link) }
    }
    /\((.*?)\)$/.match(concurrent_measurement.to_s) do |cm|
      /\((.*?)\)/.match(sequential_measurement.to_s) do |sm|
        # expected for the concurrent version to be close to 10 times faster than sequential
        expect(sm[1].to_f).to be > ( cm[1].to_f * 9 )
      end
    end
  ensure
    WebMock.disable_net_connect!
  end
end

Porque ela usa WebMock, primeiro eu a desativei durante essa spec. Eu criei uma coleção falsa de 10 links reais para o MangaReader. E então eu fiz o benchmark da versão concorrente baseada em Thread e a versão sequencial simples. Como nós temos 10 links e todos eles são os mesmos, podemos supor que a versão sequencial será quase 10 vezes mais lenta do que a versão baseada em Thread. E isso é exatamente o que essa spec compara e prova (a spec falha se a versão concorrente não é, pelo menos, 9x mais rápida).

Para comparar todas as versões dos Manga Downloadrs, eu permiti o download e a compilação de uma coleção inteira de manga, nesse caso, o “One-Man Punch”, que tem quase 1.900 páginas/imagens. Eu apenas estou medindo os processos de busca e quebra e ignorando o download das imagens, redimensionamento e geração do PDF, já que eles tomam a maior parte do tempo e o redimensionamento e a parte do PDF são executados pelo mogrify do ImageMagick e ferramentas de conversão.

Este é o tempo que essa nova versão do Ruby leva para buscar e quebrar quase 1.900 páginas (usando MRI Ruby 2.3.1):

12,42s user 1,33s system 23% cpu 57,675 total

Este é o tempo que a versão em Crystal leva:

4,03s user 0,40s system 7% cpu 59,207 total

Só por diversão, eu tentei executar a versão em Ruby sob JRuby 9.1.1.0. Para executar com JRuby, basta adicionar a seguinte linha no Gemfile:

ruby "2.3.0", :engine => 'jruby', :engine_version => '9.1.1.0'

Bundle install, executou normalmente, e este é o resultado:

47,80s user 1,99s system 108% cpu 45,967 total

E este é o tempo que a versão Elixir leva:

11,38s user 1,04s system 85% cpu 14,590 total

Reality Check!

Se você só olhar para os tempos acima, pode chegar a conclusões erradas.

Antes de mais nada, é uma comparação injusta. A versão Elixir usa um algoritmo muito diferente das versões Ruby e Crystal.

No Elixir, eu inicio um pool de processos, cerca de 50 deles. Então eu começo 50 solicitações HTTP de uma só vez. Uma vez que cada processo termina, ele se libera novamente para o pool e posso iniciar outra solicitação HTTP a partir da fila de links. Portanto, é um fluxo constante de, no máximo, 50 solicitações HTTP, constantemente.

As versões Crystal e Ruby/JRuby cortam os 1.900 links em lotes de 40 links, e então eu inicio 40 pedidos de uma só vez. Essa implementação aguarda todos os 40 terminarem e então inicia os próximos 40. Então, ele nunca é um fluxo constante, são bursts de 40 pedidos. Assim, cada lote é retardado pelo pedido mais lento no lote, não dando chance para outras solicitações iniciarem.

É uma diferença na arquitetura. O Elixir torna muito mais fácil fazer streams, e o Crystal, com o seu estilo CSP, faz com que seja mais fácil fazer bursts. Uma abordagem melhor seria fazer fila com os 1.900 links e usar algo como Sidekiq.cr para passar um link de cada vez (desovando 40 fibers para o servidor, como um “pool”, por exemplo).

A versão Elixir tem uma arquitetura mais eficiente, por isso que não leva mais de 15 segundos para buscar todos os links de imagem, enquanto a versão Crystal leva quase um minuto inteiro para terminar (a acumulação dos pedidos mais lentos em cada lote).

Agora, você vai se surpreender ao saber que a versão Crystal é, na verdade, um pouco mais lenta do que a versão Ruby! E você não vai se surpreender ao ver JRuby ser mais rápido em 45 segundos!

Essa é outra prova de que você não deve descartar Ruby (e que você deve tentar JRuby mais frequentemente). Como expliquei antes, ele suporta concurrência nas operações de I/O, e os aplicativos que testei são todos usuários pesados em I/O.

A diferença é, provavelmente, a maturidade da biblioteca Net::HTTP do Ruby contra a HTTP::Client do Crystal. Eu tentei muitos ajustes na versão Crystal, mas não consegui obter nada muito mais rápido. Eu tentei fazer lotes maiores, mas por alguma razão as aplicações simplesmente travam, fazem uma pausa, e nunca liberam. Eu tinha que usar o Ctrl-C e repetir até que finalmente prosseguisse. Se alguém sabe o que estou fazendo de errado, por favor, não se esqueça de escrever na seção de comentários abaixo.

Parte disso é provavelmente devido aos servidores não confiáveis do MangaReader, eles provavelmente têm algum tipo de proteção DoS, estrangulando conexões ou algo assim.

De qualquer forma, quando eles passam, porque os algoritmos do Crystal e do Ruby são essencialmente os mesmos, eles levam praticamente o mesmo tempo para completar. Então o que falta para mim é evoluir esse algoritmo para usar algo como Sidekiq ou implementar uma queue/pool interna de workers scheme.

Conclusão

O objetivo deste experimento foi aprender mais sobre a capacidade do Crystal e quão fácil seria ir e voltar com o Ruby.

Como você pode ver, há muitas diferenças, mas não é tão difícil. Eu posso estar deixando algo passar, mas eu esbarrei em algumas dificuldades quando forcei muito no HTTP::Client + Fibers, como expliquei acima. Se você sabe o que estou fazendo errado, por favor, me avise.

A diferença entre Elixir e algoritmos Ruby/Crystal não mostra apenas as diferenças de desempenho de linguagem, mas também a importância da arquitetura e dos algoritmos no desempenho global. Este teste não foi conclusivo, apenas uma dica de que a minha aplicação ingênua de Fibers precisa de mais trabalho, e que o tratamento natural do Elixir a processos paralelos o torna mais fácil de alcançar maiores níveis de paralelismo.

Isso serve como uma amostra do que Crystal pode fazer, e também que o Ruby ainda está no jogo. Mas também que o Elixir é certamente algo para se olhar bem de perto.

***

Artigo traduzido com autorização do autor. Publicado originalmente em  http://www.akitaonrails.com/2016/06/06/manga-downloadr-porting-from-crystal-to-ruby-and-a-bit-of-jruby