Współbieżność i równoległość Ruby: praktyczny samouczek
Opublikowany: 2022-03-11Zacznijmy od wyjaśnienia zbyt częstego nieporozumienia wśród programistów Ruby; mianowicie: Współbieżność i równoległość to nie to samo (tj. współbieżność != równoległość).
W szczególności współbieżność Ruby ma miejsce, gdy dwa zadania mogą być uruchamiane, uruchamiane i kończone w nakładających się okresach czasu. Nie musi to jednak oznaczać, że zawsze będą działać w tym samym momencie (np. wiele wątków na maszynie jednordzeniowej). W przeciwieństwie do tego, równoległość ma miejsce, gdy dwa zadania dosłownie działają w tym samym czasie (np. wiele wątków na procesorze wielordzeniowym).
Kluczową kwestią jest tutaj to, że współbieżne wątki i/lub procesy niekoniecznie będą działały równolegle.
Ten samouczek zapewnia praktyczne (a nie teoretyczne) traktowanie różnych technik i podejść dostępnych dla współbieżności i równoległości w Ruby.
Aby uzyskać więcej przykładów Ruby ze świata rzeczywistego, zobacz nasz artykuł o interpreterach i środowiskach wykonawczych Ruby.
Nasz przypadek testowy
W przypadku prostego przypadku testowego stworzę klasę Mailer
i dodam funkcję Fibonacciego (zamiast metody sleep()
), aby każde żądanie bardziej obciążało procesor, w następujący sposób:
class Mailer def self.deliver(&block) mail = MailBuilder.new(&block).mail mail.send_mail end Mail = Struct.new(:from, :to, :subject, :body) do def send_mail fib(30) puts "Email from: #{from}" puts "Email to : #{to}" puts "Subject : #{subject}" puts "Body : #{body}" end def fib(n) n < 2 ? n : fib(n-1) + fib(n-2) end end class MailBuilder def initialize(&block) @mail = Mail.new instance_eval(&block) end attr_reader :mail %w(from to subject body).each do |m| define_method(m) do |val| @mail.send("#{m}=", val) end end end end
Następnie możemy wywołać tę klasę Mailer
w następujący sposób, aby wysłać pocztę:
Mailer.deliver do from "[email protected]" to "[email protected]" subject "Threading and Forking" body "Some content" end
(Uwaga: kod źródłowy tego przypadku testowego jest dostępny tutaj na github.)
Aby ustalić punkt odniesienia dla celów porównawczych, zacznijmy od prostego testu porównawczego, wywołując program pocztowy 100 razy:
puts Benchmark.measure{ 100.times do |i| Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end }
Dało to następujące wyniki na czterordzeniowym procesorze z MRI Ruby 2.0.0p353:
15.250000 0.020000 15.270000 ( 15.304447)
Wiele procesów a wielowątkowość
Nie ma jednej odpowiedzi dla wszystkich, jeśli chodzi o podejmowanie decyzji, czy używać wielu procesów, czy wielowątkowej aplikacji Ruby. Poniższa tabela podsumowuje niektóre kluczowe czynniki, które należy wziąć pod uwagę.
Procesy | Wątki |
---|---|
Używa więcej pamięci | Używa mniej pamięci |
Jeśli rodzic umrze, zanim dzieci wyjdą, dzieci mogą stać się procesami zombie | Wszystkie wątki umierają, gdy proces umiera (brak szans na zombie) |
Bardziej kosztowne dla rozwidlonych procesów, aby zmienić kontekst, ponieważ system operacyjny musi wszystko zapisywać i ponownie ładować | Wątki mają znacznie mniejsze obciążenie, ponieważ dzielą przestrzeń adresową i pamięć |
Rozwidlone procesy otrzymują nową przestrzeń pamięci wirtualnej (izolacja procesów) | Wątki współdzielą tę samą pamięć, więc trzeba kontrolować i radzić sobie z problemami z pamięcią współbieżną |
Wymaga komunikacji między procesami | Potrafi „komunikować się” za pośrednictwem kolejek i pamięci współdzielonej |
Wolniej tworzyć i niszczyć | Szybciej tworzyć i niszczyć |
Łatwiejsze kodowanie i debugowanie | Może być znacznie bardziej złożony w kodowaniu i debugowaniu |
Przykłady rozwiązań Ruby wykorzystujących wiele procesów:
- Resque: wspierana przez Redis biblioteka Ruby do tworzenia zadań w tle, umieszczania ich w wielu kolejkach i późniejszego przetwarzania.
- Unicorn: serwer HTTP dla aplikacji Rack zaprojektowany do obsługi szybkich klientów tylko na połączeniach o niskich opóźnieniach i wysokiej przepustowości oraz do korzystania z funkcji jąder Unix/Unix.
Przykłady rozwiązań Ruby wykorzystujących wielowątkowość:
- Sidekiq: w pełni funkcjonalny framework przetwarzania w tle dla Rubiego. Ma być prosty w integracji z dowolną nowoczesną aplikacją Rails i znacznie wyższą wydajnością niż inne istniejące rozwiązania.
- Puma: serwer sieciowy Ruby stworzony z myślą o współbieżności.
- Cienki: bardzo szybki i prosty serwer WWW Ruby.
Wiele procesów
Zanim przyjrzymy się opcjom wielowątkowości Rubiego, przyjrzyjmy się łatwiejszej ścieżce tworzenia wielu procesów.
W Rubim wywołanie systemowe fork()
służy do tworzenia „kopii” bieżącego procesu. Ten nowy proces jest zaplanowany na poziomie systemu operacyjnego, dzięki czemu może działać równolegle z pierwotnym procesem, tak jak każdy inny niezależny proces. ( Uwaga: fork()
jest wywołaniem systemowym POSIX i dlatego nie jest dostępna, jeśli używasz Rubiego na platformie Windows.)
OK, więc przeprowadźmy nasz przypadek testowy, ale tym razem używając fork()
do zastosowania wielu procesów:
puts Benchmark.measure{ 100.times do |i| fork do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end Process.waitall }
( Process.waitall
czeka na zakończenie wszystkich procesów podrzędnych i zwraca tablicę stanów procesów.)
Ten kod daje teraz następujące wyniki (ponownie, na czterordzeniowym procesorze z MRI Ruby 2.0.0p353):
0.000000 0.030000 27.000000 ( 3.788106)
Nieźle! Mailer przyspieszyliśmy ~5x, modyfikując tylko kilka linijek kodu (np. używając fork()
).
Nie bądź jednak przesadnie podekscytowany. Chociaż używanie rozwidlenia może być kuszące, ponieważ jest to łatwe rozwiązanie dla współbieżności Ruby, ma poważną wadę, jaką jest ilość pamięci, którą będzie zużywał. Rozwidlenie jest dość drogie, zwłaszcza jeśli interpreter Ruby, którego używasz, nie wykorzystuje funkcji kopiowania przy zapisie (CoW). Jeśli Twoja aplikacja używa na przykład 20 MB pamięci, rozwidlenie jej 100 razy może potencjalnie zająć nawet 2 GB pamięci!
Ponadto, chociaż wielowątkowość ma swoją własną złożoność, istnieje wiele złożoności, które należy wziąć pod uwagę podczas korzystania z fork()
, takie jak współdzielone deskryptory plików i semafory (pomiędzy nadrzędnymi i podrzędnymi procesami rozwidlonymi), potrzeba komunikacji za pomocą potoków , i tak dalej.
Rubinowy wielowątkowość
OK, więc spróbujmy teraz przyspieszyć ten sam program, używając zamiast tego technik wielowątkowych Rubiego.
Wiele wątków w ramach jednego procesu wiąże się ze znacznie mniejszym obciążeniem niż odpowiadająca mu liczba procesów, ponieważ współdzielą one przestrzeń adresową i pamięć.
Mając to na uwadze, wróćmy do naszego przypadku testowego, ale tym razem używając klasy Thread
Rubiego:
threads = [] puts Benchmark.measure{ 100.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }
Ten kod daje teraz następujące wyniki (ponownie, na czterordzeniowym procesorze z MRI Ruby 2.0.0p353):
13.710000 0.040000 13.750000 ( 13.740204)
Porażka. To na pewno nie jest imponujące! Więc co się dzieje? Dlaczego daje to prawie takie same wyniki, jakie otrzymaliśmy, gdy uruchomiliśmy kod synchronicznie?
Odpowiedzią, która jest zmorą istnienia wielu programistów Ruby, jest Global Interpreter Lock (GIL) . Dzięki GIL CRuby (implementacja MRI) tak naprawdę nie obsługuje wątków.
Global Interpreter Lock to mechanizm używany w interpreterach języka komputerowego do synchronizacji wykonywania wątków, dzięki czemu tylko jeden wątek może być wykonywany na raz. Interpreter korzystający z GIL zawsze pozwoli na wykonanie dokładnie jednego wątku i tylko jednego wątku na raz , nawet jeśli jest uruchamiany na procesorze wielordzeniowym. Ruby MRI i CPython to dwa najczęstsze przykłady popularnych tłumaczy, które mają GIL.
Wracając do naszego problemu, jak możemy wykorzystać wielowątkowość w Ruby, aby poprawić wydajność w świetle GIL?
Cóż, w MRI (CRuby) niefortunną odpowiedzią jest to, że w zasadzie utknąłeś i niewiele może dla ciebie zrobić wielowątkowość.

Współbieżność Ruby bez równoległości może być jednak nadal bardzo użyteczna w przypadku zadań, które są obciążone IO (np. zadania, które często muszą czekać w sieci). Tak więc wątki mogą nadal być przydatne w MRI, do zadań wymagających dużej liczby operacji we/wy. Jest powód, dla którego wątki zostały przecież wynalezione i użyte jeszcze zanim serwery wielordzeniowe stały się powszechne.
Ale to powiedziawszy, jeśli masz możliwość używania wersji innej niż CRuby, możesz użyć alternatywnej implementacji Ruby, takiej jak JRuby lub Rubinius, ponieważ nie mają one GIL i obsługują prawdziwe równoległe wątki Ruby.
Aby to udowodnić, oto wyniki, które otrzymujemy, gdy uruchamiamy dokładnie tę samą wersję kodu w wątkach, co poprzednio, ale tym razem uruchamiamy ją na JRuby (zamiast CRuby):
43.240000 0.140000 43.380000 ( 5.655000)
Teraz rozmawiamy!
Jednak…
Wątki nie są darmowe
Ulepszona wydajność z wieloma wątkami może prowadzić do przekonania, że możemy po prostu dodawać więcej wątków – w zasadzie nieskończenie wiele – aby nasz kod działał szybciej i szybciej. Byłoby to rzeczywiście miłe, gdyby to była prawda, ale rzeczywistość jest taka, że wątki nie są darmowe, więc prędzej czy później zabraknie Ci zasobów.
Załóżmy na przykład, że chcemy uruchomić nasz przykładowy program pocztowy nie 100 razy, ale 10 000 razy. Zobaczmy co się stanie:
threads = [] puts Benchmark.measure{ 10_000.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }
Bum! Wystąpił błąd z moim OS X 10.8 po odrodzeniu około 2000 wątków:
can't create Thread: Resource temporarily unavailable (ThreadError)
Zgodnie z oczekiwaniami, prędzej czy później zaczniemy się męczyć lub całkowicie wyczerpiemy zasoby. Skalowalność tego podejścia jest więc wyraźnie ograniczona.
Łączenie wątków
Na szczęście istnieje lepszy sposób; mianowicie łączenie wątków.
Pula wątków to grupa wstępnie utworzonych wątków wielokrotnego użytku, które są dostępne do wykonywania pracy w razie potrzeby. Pule wątków są szczególnie przydatne w przypadku dużej liczby krótkich zadań do wykonania, a nie niewielkiej liczby dłuższych zadań. Zapobiega to konieczności ponoszenia kosztów związanych z wielokrotnym tworzeniem wątku.
Kluczowym parametrem konfiguracyjnym puli wątków jest zazwyczaj liczba wątków w puli. Te wątki mogą być tworzone jednocześnie (tj. podczas tworzenia puli) lub leniwie (tj. w razie potrzeby do momentu utworzenia maksymalnej liczby wątków w puli).
Gdy pula otrzymuje zadanie do wykonania, przypisuje je do jednego z aktualnie nieaktywnych wątków. Jeśli żadne wątki nie są bezczynne (i została już utworzona maksymalna liczba wątków), czeka na zakończenie pracy wątku i stanie się bezczynnym, a następnie przypisuje zadanie do tego wątku.
Wracając więc do naszego przykładu, zaczniemy od użycia Queue
(ponieważ jest to typ danych bezpieczny wątkowo) i zastosujemy prostą implementację puli wątków:
wymagaj „./lib/mailer” wymagaj „wzorca” wymagaj „wątku”
POOL_SIZE = 10 jobs = Queue.new 10_0000.times{|i| jobs.push i} workers = (POOL_SIZE).times.map do Thread.new do begin while x = jobs.pop(true) Mailer.deliver do from "eki_#{x}@eqbalq.com" to "jill_#{x}@example.com" subject "Threading and Forking (#{x})" body "Some content" end end rescue ThreadError end end end workers.map(&:join)
W powyższym kodzie zaczęliśmy od utworzenia kolejki jobs
dla zadań, które należy wykonać. Użyliśmy do tego celu Queue
, ponieważ jest bezpieczny dla wątków (więc jeśli wiele wątków ma do niego dostęp w tym samym czasie, zachowa spójność), co pozwala uniknąć bardziej skomplikowanej implementacji wymagającej użycia muteksu.
Następnie przenieśliśmy identyfikatory nadawców do kolejki zadań i utworzyliśmy naszą pulę 10 wątków roboczych.
W każdym wątku roboczym usuwamy elementy z kolejki zadań.
Tak więc cykl życia wątku roboczego polega na ciągłym oczekiwaniu na umieszczenie zadań w kolejce zadań i ich wykonaniu.
Dobra wiadomość jest taka, że to działa i skaluje się bez żadnych problemów. Niestety, jest to dość skomplikowane nawet w przypadku naszego prostego samouczka.
Celuloid
Dzięki ekosystemowi Ruby Gem znaczna część złożoności wielowątkowości jest zgrabnie zamknięta w wielu łatwych w użyciu Ruby Gems.
Świetnym przykładem jest Celluloid, jeden z moich ulubionych klejnotów rubinowych. Framework Celluloid to prosty i czysty sposób na implementację systemów współbieżnych opartych na aktorach w Ruby. Celuloid umożliwia ludziom budowanie współbieżnych programów z współbieżnych obiektów tak samo łatwo, jak budowanie programów sekwencyjnych z obiektów sekwencyjnych.
W kontekście naszej dyskusji w tym poście skupiam się konkretnie na funkcji Pools, ale wyświadcz sobie przysługę i sprawdź ją bardziej szczegółowo. Używając Celluloid będziesz mógł budować wielowątkowe programy Ruby bez martwienia się o nieprzyjemne problemy, takie jak zakleszczenia, a użycie innych, bardziej wyrafinowanych funkcji, takich jak Futures i Promises, okaże się trywialne.
Oto jak prosta wielowątkowa wersja naszego programu pocztowego wykorzystuje Celluloid:
require "./lib/mailer" require "benchmark" require "celluloid" class MailWorker include Celluloid def send_email(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end mailer_pool = MailWorker.pool(size: 10) 10_000.times do |i| mailer_pool.async.send_email(i) end
Czysty, łatwy, skalowalny i niezawodny. Czego więcej można chcieć?
Praca w tle
Oczywiście inną potencjalnie realną alternatywą, w zależności od wymagań operacyjnych i ograniczeń, byłoby zatrudnienie w tle. Istnieje pewna liczba klejnotów Ruby, które obsługują przetwarzanie w tle (tj. zapisywanie zadań w kolejce i przetwarzanie ich później bez blokowania bieżącego wątku). Godne uwagi przykłady to Sidekiq, Resque, Delayed Job i Beanstalkd.
W tym poście użyję Sidekiq i Redis (pamięci podręcznej klucza i wartości typu open source).
Najpierw zainstalujmy Redis i uruchommy go lokalnie:
brew install redis redis-server /usr/local/etc/redis.conf
Po uruchomieniu naszej lokalnej instancji Redis, spójrzmy na wersję naszego przykładowego programu pocztowego ( mail_worker.rb
) przy użyciu Sidekiq:
require_relative "../lib/mailer" require "sidekiq" class MailWorker include Sidekiq::Worker def perform(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end
Możemy uruchomić Sidekiq za pomocą pliku mail_worker.rb
:
sidekiq -r ./mail_worker.rb
A potem z IRB:
⇒ irb >> require_relative "mail_worker" => true >> 100.times{|i| MailWorker.perform_async(i)} 2014-12-20T02:42:30Z 46549 TID-ouh10w8gw INFO: Sidekiq client with redis options {} => 100
Niesamowicie proste. I można go łatwo skalować, zmieniając liczbę pracowników.
Inną opcją jest użycie Sucker Punch, jednej z moich ulubionych asynchronicznych bibliotek przetwarzania RoR. Implementacja z wykorzystaniem Sucker Punch będzie bardzo podobna. Musimy tylko uwzględnić SuckerPunch::Job
zamiast Sidekiq::Worker
i MailWorker.new.async.perform()
zamiast MailWorker.perform_async()
.
Wniosek
Wysoka współbieżność jest nie tylko możliwa do osiągnięcia w Ruby, ale jest również prostsza niż mogłoby się wydawać.
Jednym z opłacalnych rozwiązań jest po prostu rozwidlenie działającego procesu w celu zwielokrotnienia jego mocy obliczeniowej. Inną techniką jest wykorzystanie wielowątkowości. Chociaż wątki są lżejsze niż procesy i wymagają mniejszych nakładów pracy, nadal możesz wyczerpać zasoby, jeśli uruchomisz zbyt wiele wątków jednocześnie. W pewnym momencie może okazać się konieczne użycie puli wątków. Na szczęście wiele złożoności wielowątkowości jest łatwiejszych dzięki wykorzystaniu dowolnej z wielu dostępnych perełek, takich jak Celluloid i jego model aktora.
Innym sposobem obsługi czasochłonnych procesów jest przetwarzanie w tle. Istnieje wiele bibliotek i usług, które umożliwiają implementację zadań w tle w Twoich aplikacjach. Niektóre popularne narzędzia obejmują struktury zadań oparte na bazach danych i kolejki komunikatów.
Rozwidlenie, wątki i przetwarzanie w tle to realne alternatywy. Decyzja, którego użyć, zależy od charakteru aplikacji, środowiska operacyjnego i wymagań. Mam nadzieję, że ten samouczek stanowi przydatne wprowadzenie do dostępnych opcji.