Por que precisamos de fibras

100

Para Fibras, temos um exemplo clássico: geração de números de Fibonacci

fib = Fiber.new do  
  x, y = 0, 1 
  loop do  
    Fiber.yield y 
    x,y = y,x+y 
  end 
end

Por que precisamos de fibras aqui? Posso reescrever isso apenas com o mesmo Proc (encerramento, na verdade)

def clsr
  x, y = 0, 1
  Proc.new do
    x, y = y, x + y
    x
  end
end

assim

10.times { puts fib.resume }

e

prc = clsr 
10.times { puts prc.call }

retornará apenas o mesmo resultado.

Então, quais são as vantagens das fibras. Que tipo de coisa eu posso escrever com Fibers que não consigo fazer com lambdas e outros recursos interessantes do Ruby?

fl00r
fonte
4
O antigo exemplo de fibonacci é apenas o pior motivador possível ;-) Há até uma fórmula que você pode usar para calcular qualquer número de fibonacci em O (1).
usr
17
O problema não é sobre algoritmo, mas sobre a compreensão das fibras :)
fl00r

Respostas:

229

Fibras são algo que você provavelmente nunca usará diretamente no código de nível de aplicativo. Eles são um primitivo de controle de fluxo que você pode usar para construir outras abstrações, que você usa em código de nível superior.

Provavelmente, o uso nº 1 de fibras em Ruby é implementar Enumerators, que são uma classe central de Ruby no Ruby 1.9. Eles são incrivelmente úteis.

No Ruby 1.9, se você chamar quase qualquer método iterador nas classes principais, sem passar um bloco, ele retornará um Enumerator.

irb(main):001:0> [1,2,3].reverse_each
=> #<Enumerator: [1, 2, 3]:reverse_each>
irb(main):002:0> "abc".chars
=> #<Enumerator: "abc":chars>
irb(main):003:0> 1.upto(10)
=> #<Enumerator: 1:upto(10)>

Esses Enumerators são objetos Enumerable, e seus eachmétodos geram os elementos que teriam sido produzidos pelo método iterador original, se tivesse sido chamado com um bloco. No exemplo que acabei de dar, o Enumerador retornado por reverse_eachtem um eachmétodo que retorna 3,2,1. O Enumerador retornado por charsretorna "c", "b", "a" (e assim por diante). MAS, ao contrário do método iterador original, o Enumerator também pode retornar os elementos um por um se você chamá next-lo repetidamente:

irb(main):001:0> e = "abc".chars
=> #<Enumerator: "abc":chars>
irb(main):002:0> e.next
=> "a"
irb(main):003:0> e.next
=> "b"
irb(main):004:0> e.next
=> "c"

Você pode ter ouvido falar de "iteradores internos" e "iteradores externos" (uma boa descrição de ambos é fornecida no livro "Gang of Four" Design Patterns). O exemplo acima mostra que Enumeradores podem ser usados ​​para transformar um iterador interno em um externo.

Esta é uma maneira de fazer seus próprios enumeradores:

class SomeClass
  def an_iterator
    # note the 'return enum_for...' pattern; it's very useful
    # enum_for is an Object method
    # so even for iterators which don't return an Enumerator when called
    #   with no block, you can easily get one by calling 'enum_for'
    return enum_for(:an_iterator) if not block_given?
    yield 1
    yield 2
    yield 3
  end
end

Vamos tentar:

e = SomeClass.new.an_iterator
e.next  # => 1
e.next  # => 2
e.next  # => 3

Espere um minuto ... alguma coisa parece estranha aí? Você escreveu as yieldinstruções an_iteratorcomo código de linha reta, mas o Enumerador pode executá-las uma de cada vez . Entre as chamadas para next, a execução de an_iteratoré "congelada". Cada vez que você chama next, ele continua executando a seguinte yieldinstrução e, em seguida, "congela" novamente.

Você consegue adivinhar como isso é implementado? O Enumerador envolve a chamada para an_iteratorem uma fibra e passa um bloco que suspende a fibra . Portanto, toda vez que an_iteratorcede ao bloco, a fibra na qual ele está sendo executado é suspensa e a execução continua no thread principal. Na próxima vez que você ligar next, ele passa o controle para a fibra, o bloco retorna e an_iteratorcontinua de onde parou.

Seria instrutivo pensar no que seria necessário para fazer isso sem fibras. CADA classe que quisesse fornecer iteradores internos e externos teria que conter código explícito para controlar o estado entre as chamadas para next. Cada chamada para next teria que verificar esse estado e atualizá-lo antes de retornar um valor. Com fibras, podemos converter automaticamente qualquer iterador interno em externo.

Isso não tem a ver com fibras persay, mas deixe-me mencionar mais uma coisa que você pode fazer com Enumerators: eles permitem que você aplique métodos Enumerable de ordem superior a outros iteradores diferentes de each. Pense nisso: normalmente todos os métodos Enumerable, incluindo map, select, include?, inject, e assim por diante, todos os trabalhos sobre os elementos gerados pelo each. Mas e se um objeto tiver outros iteradores diferentes de each?

irb(main):001:0> "Hello".chars.select { |c| c =~ /[A-Z]/ }
=> ["H"]
irb(main):002:0> "Hello".bytes.sort
=> [72, 101, 108, 108, 111]

Chamar o iterador sem bloco retorna um Enumerator, e então você pode chamar outros métodos Enumerable nele.

Voltando às fibras, você usou o takemétodo de Enumerable?

class InfiniteSeries
  include Enumerable
  def each
    i = 0
    loop { yield(i += 1) }
  end
end

Se alguma coisa chamar esse eachmétodo, parece que nunca deve retornar, certo? Veja isso:

InfiniteSeries.new.take(10) # => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Não sei se isso usa fibras sob o capô, mas poderia. As fibras podem ser usadas para implementar listas infinitas e avaliação preguiçosa de uma série. Para obter um exemplo de alguns métodos preguiçosos definidos com Enumeradores, defini alguns aqui: https://github.com/alexdowad/showcase/blob/master/ruby-core/collections.rb

Você também pode construir uma instalação de co-rotina de uso geral usando fibras. Eu nunca usei corrotinas em nenhum dos meus programas ainda, mas é um bom conceito de saber.

Espero que isso lhe dê uma ideia das possibilidades. Como eu disse no início, as fibras são um primitivo de controle de fluxo de baixo nível. Eles tornam possível manter várias "posições" de fluxo de controle dentro de seu programa (como diferentes "marcadores" nas páginas de um livro) e alternar entre eles conforme desejado. Como o código arbitrário pode ser executado em uma fibra, você pode chamar o código de terceiros em uma fibra e, em seguida, "congelá-lo" e continuar fazendo outra coisa quando ele retornar ao código que você controla.

Imagine algo assim: você está escrevendo um programa de servidor que atenderá a muitos clientes. Uma interação completa com um cliente envolve passar por uma série de etapas, mas cada conexão é transitória e você deve lembrar o estado de cada cliente entre as conexões. (Parece programação da web?)

Em vez de armazenar explicitamente esse estado e verificá-lo cada vez que um cliente se conecta (para ver qual é a próxima "etapa" que eles precisam fazer), você pode manter uma fibra para cada cliente. Depois de identificar o cliente, você deve recuperar sua fibra e reiniciá-lo. Então, ao final de cada conexão, você suspenderia a fibra e a armazenaria novamente. Dessa forma, você poderia escrever código em linha reta para implementar toda a lógica para uma interação completa, incluindo todas as etapas (exatamente como você faria naturalmente se seu programa fosse feito para ser executado localmente).

Tenho certeza de que há muitos motivos pelos quais tal coisa pode não ser prática (pelo menos por agora), mas, novamente, estou apenas tentando mostrar a você algumas das possibilidades. Quem sabe; depois de entender o conceito, você pode criar um aplicativo totalmente novo no qual ninguém mais pensou ainda!

Alex D
fonte
Obrigado pela sua resposta! Então, por que eles não implementam charsou outros enumeradores com apenas encerramentos?
fl00r de
@ fl00r, estou pensando em acrescentar ainda mais informações, mas não sei se essa resposta já é muito longa ... quer mais?
Alex D
13
Essa resposta é tão boa que deveria ser escrita como uma postagem de blog em algum lugar, eu acho.
Jason Voegele
1
ATUALIZAÇÃO: Parece que Enumerableirá incluir alguns métodos "preguiçosos" no Ruby 2.0.
Alex D
2
takenão requer fibra. Em vez disso, takesimplesmente interrompe durante o n-ésimo rendimento. Quando usado dentro de um bloco, breakretorna o controle para o quadro que define o bloco. a = [] ; InfiniteSeries.new.each { |x| a << x ; break if a.length == 10 } ; a
Matthew
22

Ao contrário dos fechamentos, que têm um ponto de entrada e saída definido, as fibras podem preservar seu estado e retornar (escoamento) muitas vezes:

f = Fiber.new do
  puts 'some code'
  param = Fiber.yield 'return' # sent parameter, received parameter
  puts "received param: #{param}"
  Fiber.yield #nothing sent, nothing received 
  puts 'etc'
end

puts f.resume
f.resume 'param'
f.resume

imprime isto:

some code
return
received param: param
etc

A implementação desta lógica com outros recursos do ruby ​​será menos legível.

Com esse recurso, um bom uso de fibras é fazer o agendamento cooperativo manual (como substituição de Threads). Ilya Grigorik tem um bom exemplo de como transformar uma biblioteca assíncrona ( eventmachineneste caso) no que parece ser uma API síncrona sem perder as vantagens do agendamento IO da execução assíncrona. Aqui está o link .

Aliaksei Kliuchnikau
fonte
Obrigado! Eu leio docs, então entendo toda essa magia com muitas entradas e saídas dentro da fibra. Mas não tenho certeza se isso torna a vida mais fácil. Não acho que seja uma boa ideia tentar acompanhar todos esses currículos e rendimentos. Parece um punho difícil de desemaranhar. Então, eu quero entender se há casos em que esse feixe de fibras é uma boa solução. Eventmachine é legal, mas não é o melhor lugar para entender as fibras, porque primeiro você deve entender todas essas coisas de padrão de reator. Então, eu acredito que posso entender as fibras physical meaningem um exemplo mais simples
fl00r