¿Por qué necesitamos fibras?

100

Para las fibras tenemos el ejemplo clásico: generación 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 qué necesitamos fibras aquí? Puedo reescribir esto con el mismo Proc (cierre, en realidad)

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

Entonces

10.times { puts fib.resume }

y

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

devolverá el mismo resultado.

Entonces, ¿cuáles son las ventajas de las fibras? ¿Qué tipo de cosas puedo escribir con Fibers que no puedo hacer con lambdas y otras características geniales de Ruby?

fl00r
fuente
4
El viejo ejemplo de fibonacci es el peor motivador posible ;-) Incluso hay una fórmula que puede usar para calcular cualquier número de fibonacci en O (1).
usr
17
El problema no es sobre el algoritmo, sino sobre la comprensión de las fibras :)
fl00r

Respuestas:

229

Las fibras son algo que probablemente nunca usará directamente en el código de nivel de aplicación. Son una primitiva de control de flujo que puede usar para construir otras abstracciones, que luego usa en código de nivel superior.

Probablemente el uso # 1 de fibras en Ruby es implementar Enumerators, que son una clase central de Ruby en Ruby 1.9. Son increíblemente útiles.

En Ruby 1.9, si llama a casi cualquier método iterador en las clases principales, sin pasar un bloque, devolverá un 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)>

Estos Enumerators son objetos Enumerables, y sus eachmétodos producen los elementos que habrían sido generados por el método iterador original, si se hubiera llamado con un bloque. En el ejemplo que acabo de dar, el Enumerador devuelto por reverse_eachtiene un eachmétodo que produce 3,2,1. El enumerador devuelto por charsproduce "c", "b", "a" (y así sucesivamente). PERO, a diferencia del método iterador original, el Enumerador también puede devolver los elementos uno por uno si llamanext 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"

Es posible que haya oído hablar de los "iteradores internos" y los "iteradores externos" (en el libro Patrones de diseño "Gang of Four" se ofrece una buena descripción de ambos). El ejemplo anterior muestra que los enumeradores se pueden usar para convertir un iterador interno en uno externo.

Esta es una forma de crear sus propios 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 a intentarlo:

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

Espera un minuto ... ¿Hay algo extraño ahí? Escribió las yielddeclaraciones an_iteratorcomo código de línea recta, pero el enumerador puede ejecutarlas una a la vez . Entre llamadas a next, la ejecución de an_iteratorse "congela". Cada vez que llama next, continúa funcionando hasta el siguienteyield declaración y luego se "congela" nuevamente.

¿Puedes adivinar cómo se implementa esto? El enumerador envuelve la llamada an_iteratoren una fibra y pasa un bloque que suspende la fibra . Entonces, cada vez que an_iteratorcede el paso al bloque, la fibra en la que se está ejecutando se suspende y la ejecución continúa en el hilo principal. La próxima vez que llame next, pasa el control a la fibra, el bloque regresa yan_iterator continúa donde lo dejó.

Sería instructivo pensar en lo que se necesitaría para hacer esto sin fibras. CADA clase que quisiera proporcionar iteradores internos y externos tendría que contener código explícito para realizar un seguimiento del estado entre llamadas a next. Cada llamada a next tendría que verificar ese estado y actualizarlo antes de devolver un valor. Con las fibras, podemos convertir automáticamente cualquier iterador interno en uno externo.

Esto no tiene que ver con las fibras persay, pero déjame mencionar una cosa más que puedes hacer con los enumeradores: te permiten aplicar métodos Enumerables de orden superior a otros iteradores distintos de each. Piense en esto: normalmente todos los métodos enumerables, incluyendo map, select, include?, inject, y así sucesivamente, todos los trabajos en los elementos producidos por each. Pero, ¿y si un objeto tiene otros iteradores además deeach ?

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

Llamar al iterador sin bloque devuelve un Enumerador, y luego puede llamar a otros métodos Enumerables sobre eso.

Volviendo a las fibras, ¿ha utilizado el takemétodo de Enumerable?

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

Si algo llama a ese eachmétodo, parece que nunca debería regresar, ¿verdad? Mira esto:

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

No sé si esto usa fibras debajo del capó, pero podría. Las fibras se pueden utilizar para implementar listas infinitas y evaluación perezosa de una serie. Para ver un ejemplo de algunos métodos perezosos definidos con enumeradores, he definido algunos aquí: https://github.com/alexdowad/showcase/blob/master/ruby-core/collections.rb

También puede construir una instalación de corrutina de uso general utilizando fibras. Nunca he usado corrutinas en ninguno de mis programas, pero es un buen concepto para conocer.

Espero que esto les dé una idea de las posibilidades. Como dije al principio, las fibras son una primitiva de control de flujo de bajo nivel. Hacen posible mantener múltiples "posiciones" de flujo de control dentro de su programa (como diferentes "marcadores" en las páginas de un libro) y cambiar entre ellos como desee. Dado que el código arbitrario puede ejecutarse en una fibra, puede llamar a código de terceros en una fibra y luego "congelarlo" y continuar haciendo otra cosa cuando vuelva a llamar al código que controla.

Imagínese algo como esto: está escribiendo un programa de servidor que dará servicio a muchos clientes. Una interacción completa con un cliente implica seguir una serie de pasos, pero cada conexión es transitoria y debe recordar el estado de cada cliente entre las conexiones. (¿Suena como programación web?)

En lugar de almacenar explícitamente ese estado y verificarlo cada vez que un cliente se conecta (para ver cuál es el siguiente "paso" que tienen que hacer), podría mantener una fibra para cada cliente. Después de identificar al cliente, recuperaría su fibra y la reiniciaría. Luego, al final de cada conexión, suspendería la fibra y la almacenaría nuevamente. De esta manera, podría escribir código de línea recta para implementar toda la lógica para una interacción completa, incluidos todos los pasos (tal como lo haría naturalmente si su programa se ejecutara localmente).

Estoy seguro de que hay muchas razones por las que tal cosa puede no ser práctica (al menos por ahora), pero nuevamente estoy tratando de mostrarles algunas de las posibilidades. Quién sabe; Una vez que obtenga el concepto, ¡puede que se le ocurra una aplicación totalmente nueva en la que nadie más ha pensado todavía!

Alex D
fuente
¡Gracias por tu respuesta! Entonces, ¿por qué no implementan charsu otros enumeradores con solo cierres?
fl00r
@ fl00r, estoy pensando en agregar aún más información, pero no sé si esta respuesta ya es demasiado larga ... ¿quieres más?
Alex D
13
Esta respuesta es tan buena que creo que debería escribirse como una publicación de blog en algún lugar.
Jason Voegele
1
ACTUALIZACIÓN: Parece Enumerableque incluirá algunos métodos "perezosos" en Ruby 2.0.
Alex D
2
takeno requiere fibra. En cambio, takesimplemente se rompe durante el n-ésimo rendimiento. Cuando se usa dentro de un bloque, breakdevuelve el control al marco que define el bloque. a = [] ; InfiniteSeries.new.each { |x| a << x ; break if a.length == 10 } ; a
Mateo
22

A diferencia de los cierres, que tienen un punto de entrada y salida definido, las fibras pueden conservar su estado y retorno (rendimiento) muchas veces:

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 esto:

some code
return
received param: param
etc

La implementación de esta lógica con otras características de ruby ​​será menos legible.

Con esta característica, un buen uso de las fibras consiste en realizar una programación cooperativa manual (como reemplazo de hilos). Ilya Grigorik tiene un buen ejemplo de cómo convertir una biblioteca asincrónica ( eventmachineen este caso) en lo que parece una API sincrónica sin perder las ventajas de la programación IO de la ejecución asincrónica. Aquí está el enlace .

Aliaksei Kliuchnikau
fuente
¡Gracias! Leo documentos, así que entiendo toda esta magia con muchas entradas y salidas dentro de la fibra. Pero no estoy seguro de que esto facilite la vida. No creo que sea una buena idea intentar seguir todos estos currículums y rendimientos. Parece un ovillo difícil de desenredar. Entonces quiero entender si hay casos en los que este puñado de fibras sea una buena solución. Eventmachine es genial, pero no es el mejor lugar para entender las fibras, porque primero debes entender todas estas cosas del patrón del reactor. Entonces creo que puedo entender las fibras physical meaningen un ejemplo más simple
fl00r