Dlaczego potrzebujemy włókien

101

Dla Fibers mamy klasyczny przykład: generowanie liczb Fibonacciego

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

Dlaczego potrzebujemy tutaj włókien? Mogę to przepisać z tym samym procesem (właściwie zamknięciem)

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

Więc

10.times { puts fib.resume }

i

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

zwróci ten sam wynik.

Więc jakie są zalety włókien. Jakie rzeczy mogę napisać za pomocą Fibers, czego nie mogę zrobić z lambdami i innymi fajnymi funkcjami Rubiego?

fl00r
źródło
4
Stary przykład Fibonacciego jest po prostu najgorszym możliwym czynnikiem motywującym ;-) Jest nawet wzór, którego można użyć do obliczenia dowolnej liczby Fibonacciego w O (1).
usr
17
Problem nie polega na algorytmie, ale na zrozumieniu włókien :)
fl00r

Odpowiedzi:

230

Włókna to coś, czego prawdopodobnie nigdy nie użyjesz bezpośrednio w kodzie na poziomie aplikacji. Są to prymityw do sterowania przepływem, którego można użyć do tworzenia innych abstrakcji, których można następnie używać w kodzie wyższego poziomu.

Prawdopodobnie pierwszym zastosowaniem włókien w Rubim jest implementacja Enumerators, które są podstawową klasą Rubiego w Rubim 1.9. Są niezwykle przydatne.

W Rubim 1.9, jeśli wywołasz prawie każdą metodę iteratora na klasach rdzenia, bez przekazywania bloku, zwróci to plik 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)>

Enumeratorto obiekty Enumerable, a ich eachmetody dają elementy, które zostałyby uzyskane przez oryginalną metodę iteratora, gdyby została wywołana z blokiem. W podanym przeze mnie przykładzie Enumerator zwrócony przez reverse_eachma eachmetodę, która zwraca 3,2,1. Enumerator zwrócony przez charszwraca „c”, „b”, „a” (i tak dalej). ALE, w przeciwieństwie do oryginalnej metody iteratora, Enumerator może również zwracać elementy jeden po drugim, jeśli wywołujesz nextgo wielokrotnie:

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"

Być może słyszałeś o „wewnętrznych iteratorach” i „zewnętrznych iteratorach” (dobry opis obu znajduje się w książce „Gang of Four” Design Patterns). Powyższy przykład pokazuje, że Enumerators mogą służyć do przekształcania wewnętrznego iteratora w zewnętrzny.

Oto jeden ze sposobów tworzenia własnych modułów wyliczających:

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

Spróbujmy:

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

Chwileczkę ... czy coś tam wydaje się dziwne? Napisałeś yieldinstrukcje an_iteratorjako kod liniowy, ale moduł wyliczający może uruchamiać je pojedynczo . W międzyczasie nextwykonanie polecenia an_iteratorjest „zawieszane”. Za każdym razem, gdy dzwonisz next, przechodzi do następnej yieldinstrukcji, a następnie ponownie „zawiesza się”.

Czy możesz zgadnąć, jak to jest realizowane? Enumerator zawija wywołanie do an_iteratorświatłowodu i przekazuje blok, który zawiesza światłowód . Tak więc za każdym razem, gdy an_iteratorustępuje blokowi, światłowód, na którym działa, jest zawieszany, a wykonywanie jest kontynuowane w głównym wątku. Następnym razem, gdy dzwonisz next, przekazuje sterowanie do światłowodu, blok wraca i an_iteratorkontynuuje od miejsca, w którym został przerwany.

Pouczające byłoby zastanowienie się, co byłoby potrzebne do zrobienia tego bez włókien. KAŻDA klasa, która chciała udostępniać zarówno wewnętrzne, jak i zewnętrzne iteratory, musiałaby zawierać jawny kod, aby śledzić stan między wywołaniami next. Każde wywołanie next musiałoby sprawdzić ten stan i zaktualizować go przed zwróceniem wartości. Dzięki światłowodom możemy automatycznie przekształcić dowolny wewnętrzny iterator na zewnętrzny.

Nie ma to nic wspólnego z włóknami, ale wspomnę jeszcze o jednej rzeczy, którą możesz zrobić z Enumeratorami: pozwalają one na zastosowanie metod Enumerable wyższego rzędu do innych iteratorów innych niż each. Pomyśl o tym: normalnie wszystkie przeliczalne metody, w tym map, select, include?, inject, i tak dalej, wszystkie prace na elementach uzyskano przez each. Ale co, jeśli obiekt ma inne iteratory inne niż 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]

Wywołanie iteratora bez bloku zwraca Enumerator, a następnie możesz wywołać inne metody Enumerable.

Wracając do włókien, czy użyłeś takemetody z Enumerable?

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

Jeśli cokolwiek wywołuje tę eachmetodę, wygląda na to, że nigdy nie powinna powrócić, prawda? Sprawdź to:

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

Nie wiem, czy to wykorzystuje włókna pod maską, ale mogłoby. Włókna mogą służyć do implementacji nieskończonych list i leniwej oceny serii. Na przykład niektóre leniwe metody zdefiniowane w Enumerators, zdefiniowałem tutaj: https://github.com/alexdowad/showcase/blob/master/ruby-core/collections.rb

Możesz również zbudować ośrodek ogólnego przeznaczenia z użyciem włókien. Nigdy jeszcze nie używałem programów w żadnym z moich programów, ale warto wiedzieć.

Mam nadzieję, że to daje ci wyobrażenie o możliwościach. Jak powiedziałem na początku, włókna są prymitywem kontroli przepływu niskiego poziomu. Umożliwiają one utrzymywanie wielu „pozycji” przepływu sterowania w programie (jak różne „zakładki” na stronach książki) i przełączanie się między nimi w razie potrzeby. Ponieważ dowolny kod może działać w światłowodzie, możesz wywołać kod strony trzeciej na światłowodzie, a następnie „zamrozić” go i kontynuować wykonywanie innych czynności, gdy wywoła kod, który kontrolujesz.

Wyobraź sobie coś takiego: piszesz program serwera, który będzie obsługiwał wielu klientów. Pełna interakcja z klientem wymaga wykonania szeregu kroków, ale każde połączenie jest przejściowe i należy pamiętać stan każdego klienta między połączeniami. (Brzmi jak programowanie internetowe?)

Zamiast jawnie zapisywać ten stan i sprawdzać go za każdym razem, gdy klient się łączy (aby zobaczyć, jaki będzie następny „krok”, jaki musi wykonać), można zachować światłowód dla każdego klienta. Po zidentyfikowaniu klienta należy odzyskać jego włókno i ponownie go uruchomić. Następnie na końcu każdego połączenia zawieszasz światłowód i przechowujesz go ponownie. W ten sposób możesz napisać kod w linii prostej, aby zaimplementować całą logikę dla pełnej interakcji, w tym wszystkie kroki (tak jak naturalnie byś zrobił, gdyby twój program był uruchamiany lokalnie).

Jestem pewien, że istnieje wiele powodów, dla których taka rzecz może nie być praktyczna (przynajmniej na razie), ale znowu próbuję tylko pokazać niektóre możliwości. Kto wie; kiedy już zdobędziesz koncepcję, możesz wymyślić zupełnie nową aplikację, o której nikt inny jeszcze nie pomyślał!

Alex D.
źródło
Dziękuję za odpowiedź! Dlaczego więc nie implementują charsani innych modułów wyliczających z samymi zamknięciami?
fl00r
@ fl00r, myślę o dodaniu jeszcze więcej informacji, ale nie wiem, czy ta odpowiedź jest już za długa ... chcesz więcej?
Alex D
13
Ta odpowiedź jest tak dobra, że ​​wydaje mi się, że powinna zostać napisana gdzieś na blogu.
Jason Voegele
1
AKTUALIZACJA: Wygląda na to, że EnumerableRuby 2.0 będzie zawierał pewne "leniwe" metody.
Alex D
2
takenie wymaga błonnika. Zamiast tego takepo prostu pęka podczas n-tego plonu. Gdy jest używany wewnątrz bloku, breakzwraca sterowanie do ramki definiującej blok. a = [] ; InfiniteSeries.new.each { |x| a << x ; break if a.length == 10 } ; a
Matthew
22

W przeciwieństwie do zamknięć, które mają określony punkt wejścia i wyjścia, włókna mogą wielokrotnie zachowywać swój stan i zwrot (plastyczność):

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

drukuje to:

some code
return
received param: param
etc

Implementacja tej logiki z innymi funkcjami ruby ​​będzie mniej czytelna.

Dzięki tej funkcji dobrym wykorzystaniem włókien jest ręczne planowanie współpracy (jako wymiana wątków). Ilya Grigorik ma dobry przykład tego, jak zamienić bibliotekę asynchroniczną ( eventmachinew tym przypadku) w coś, co wygląda jak synchroniczny interfejs API, bez utraty zalet planowania we / wy wykonywania asynchronicznego. Oto link .

Aliaksei Kliuchnikau
źródło
Dziękuję Ci! Czytam dokumenty, więc rozumiem całą tę magię z wieloma wejściami i wyjściami wewnątrz włókna. Ale nie jestem pewien, czy te rzeczy ułatwiają życie. Nie sądzę, żeby to był dobry pomysł, próbując podążać za tymi wszystkimi życiorysami i ustępstwami. Wygląda jak róg szotowy, który jest trudny do rozplątania. Chcę więc zrozumieć, czy są przypadki, w których ten rączek włókien jest dobrym rozwiązaniem. Eventmachine jest fajne, ale nie jest najlepszym miejscem do zrozumienia włókien, ponieważ najpierw powinieneś zrozumieć wszystkie te rzeczy związane z reaktorem. Wierzę więc, że rozumiem włókna physical meaningw prostszym przykładzie
fl00r