Jak zapewne wiesz, Ruby ma kilka implementacji, takich jak MRI, JRuby, Rubinius, Opal, RubyMotion itp. i każda z nich może wykorzystywać inny wzorzec wykonywania kodu. W tym artykule skupimy się na pierwszych trzech z nich i porównamy MRI
Jak zapewne wiesz, Ruby ma kilka implementacji, takich jak MRI, JRuby, Rubinius, Opal, RubyMotion itp. i każda z nich może używać innego wzorca kod wykonanie. W tym artykule skupimy się na pierwszych trzech z nich i porównamy MRI (obecnie najpopularniejszą implementację) z JRuby i Rubinius, uruchamiając kilka przykładowych skryptów, które mają ocenić przydatność forkowania i wątkowania w różnych sytuacjach, takich jak przetwarzanie algorytmów wymagających dużej mocy obliczeniowej procesora, kopiowanie plików itp.
Widelec
jest nowym procesem potomnym (kopią procesu nadrzędnego)
komunikuje się z innymi za pośrednictwem kanałów komunikacji międzyprocesowej (IPC), takich jak kolejki komunikatów, pliki, gniazda itp.
istnieje nawet po zakończeniu procesu nadrzędnego
jest wywołaniem POSIX - działa głównie na platformach uniksowych
Wątek
jest "tylko" kontekstem wykonania, działającym w ramach procesu
dzieli całą pamięć z innymi (domyślnie używa mniej pamięci niż fork)
komunikuje się z innymi za pomocą obiektów pamięci współdzielonej
umiera wraz z procesem
wprowadza typowe problemy związane z wielowątkowością, takie jak głód, zakleszczenia itp.
Istnieje wiele narzędzi wykorzystujących forki i wątki, które są używane na co dzień, np. Unicorn (forki) i Puma (wątki) na poziomie serwerów aplikacji, Resque (forki) i Sidekiq (wątki) na poziomie zadań w tle itp.
Poniższa tabela przedstawia wsparcie dla forków i wątków w głównych implementacjach Rubiego.
Implementacja Ruby
Rozwidlenie
Gwintowanie
REZONANS MAGNETYCZNY
Tak
Tak (ograniczone przez GIL**)
JRuby
–
Tak
Rubinius
Tak
Tak
Dwa kolejne magiczne słowa powracają jak bumerang w tym temacie - równoległość i współbieżność - musimy je nieco wyjaśnić. Po pierwsze, terminy te nie mogą być używane zamiennie. W skrócie - o równoległości możemy mówić, gdy dwa lub więcej zadań jest przetwarzanych dokładnie w tym samym czasie. Współbieżność ma miejsce, gdy dwa lub więcej zadań jest przetwarzanych w nakładających się okresach czasu (niekoniecznie w tym samym czasie). Tak, to szerokie wyjaśnienie, ale wystarczająco dobre, aby pomóc ci zauważyć różnicę i zrozumieć resztę tego artykułu.
Poniższa tabela przedstawia obsługę równoległości i współbieżności.
Implementacja Ruby
Równoległość (poprzez rozwidlenia)
Równoległość (poprzez wątki)
Współbieżność
REZONANS MAGNETYCZNY
Tak
Nie
Tak
JRuby
–
Tak
Tak
Rubinius
Tak
Tak (od wersji 2.X)
Tak
To koniec teorii - zobaczmy to w praktyce!
Posiadanie oddzielnej pamięci nie musi powodować zużywania takiej samej ilości pamięci jak proces nadrzędny. Istnieje kilka technik optymalizacji pamięci. Jedną z nich jest Copy on Write (CoW), która pozwala procesowi nadrzędnemu współdzielić przydzieloną pamięć z procesem podrzędnym bez jej kopiowania. Dzięki CoW dodatkowa pamięć jest potrzebna tylko w przypadku modyfikacji pamięci współdzielonej przez proces potomny. W kontekście Rubiego nie każda implementacja jest przyjazna dla CoW, np. MRI wspiera ją w pełni od wersji 2.X. Przed tą wersją każdy fork zużywał tyle samo pamięci co proces nadrzędny.
Jedną z największych zalet/wad MRI (niepotrzebne skreślić) jest wykorzystanie mechanizmu GIL (Global Interpreter Lock). W skrócie, mechanizm ten odpowiada za synchronizację wykonywania wątków, co oznacza, że w danym momencie może być wykonywany tylko jeden wątek. Ale zaraz... Czy to oznacza, że nie ma sensu w ogóle używać wątków w MRI? Odpowiedź przychodzi wraz ze zrozumieniem wewnętrznych elementów GIL... lub przynajmniej spojrzeniem na próbki kodu w tym artykule.
Przypadek testowy
Aby zaprezentować jak działa forking i wątkowanie w implementacjach Rubiego, stworzyłem prostą klasę o nazwie Test i kilka innych dziedziczących po niej. Każda klasa ma inne zadanie do przetworzenia. Domyślnie każde zadanie wykonywane jest cztery razy w pętli. Ponadto, każde zadanie działa w trzech typach wykonywania kodu: sekwencyjnym, z rozwidleniami i z wątkami. Dodatkowo, Benchmark.bmbm uruchamia blok kodu dwukrotnie - pierwszy raz w celu uruchomienia środowiska uruchomieniowego, drugi raz w celu dokonania pomiarów. Wszystkie wyniki przedstawione w tym artykule zostały uzyskane w drugim uruchomieniu. Oczywiście, nawet bmbm nie gwarantuje doskonałej izolacji, ale różnice między wieloma uruchomieniami kodu są nieznaczne.
wymagają "benchmarku"
klasa Test
KWOTA = 4
def run
Benchmark.bmbm do |b|
b.report("sequential") { sequential }
b.report("forking") { forking }
b.report("threading") { threading }
end
end
private
def sequential
AMOUNT.times { perform }
end
def forking
AMOUNT.times do
fork do
wykonać
end
end
Process.waitall
rescue NotImplementedError => e
Metoda fork # nie jest dostępna w JRuby
puts e
end
def threading
threads = []
AMOUNT.times do
threads << Thread.new do
wykonać
end
end
threads.map(&:join)
end
def perform
raise "nie zaimplementowano"
end
end
Test obciążenia
Wykonuje obliczenia w pętli, aby wygenerować duże obciążenie procesora.
class LoadTest < Test
def perform
1000.times { 1000.times { 2**3**4 } }
end
end
Uruchommy to...
LoadTest.new.run
...i sprawdzić wyniki
REZONANS MAGNETYCZNY
JRuby
Rubinius
sekwencyjny
1.862928
2.089000
1.918873
rozwidlenie
0.945018
–
1.178322
gwintowanie
1.913982
1.107000
1.213315
Jak widać, wyniki z sekwencyjnych uruchomień są podobne. Oczywiście istnieje niewielka różnica między rozwiązaniami, ale jest ona spowodowana implementacją wybranych metod w różnych interpreterach.
Forking, w tym przykładzie, ma znaczny wzrost wydajności (kod działa prawie dwa razy szybciej).
Wątkowanie daje podobne wyniki jak rozwidlanie, ale tylko dla JRuby i Rubinius. Uruchomienie próbki z wątkami na MRI zużywa nieco więcej czasu niż metoda sekwencyjna. Istnieją co najmniej dwa powody. Po pierwsze, GIL wymusza sekwencyjne wykonywanie wątków, więc w idealnym świecie czas wykonania powinien być taki sam jak w przypadku sekwencyjnego uruchamiania, ale występuje również strata czasu na operacje GIL (przełączanie między wątkami itp.). Po drugie, potrzebny jest również pewien narzut czasowy na tworzenie wątków.
Ten przykład nie daje nam odpowiedzi na pytanie o sens używania wątków w MRI. Zobaczmy inny.
Test drzemki
Uruchamia metodę uśpienia.
class SnoozeTest < Test
def perform
sleep 1
end
end
Oto wyniki
REZONANS MAGNETYCZNY
JRuby
Rubinius
sekwencyjny
4.004620
4.006000
4.003186
rozwidlenie
1.022066
–
1.028381
gwintowanie
1.001548
1.004000
1.003642
Jak widać, każda implementacja daje podobne wyniki nie tylko w uruchomieniach sekwencyjnych i forkowych, ale także w wątkowych. Dlaczego więc MRI ma taki sam wzrost wydajności jak JRuby i Rubinius? Odpowiedź tkwi w implementacji sen.
Rezonans magnetyczny sen jest zaimplementowana za pomocą rb_thread_wait_for C, która używa innej funkcji o nazwie native_sleep. Rzućmy okiem na jego implementację (kod został uproszczony, oryginalną implementację można znaleźć pod adresem tutaj):
static void
native_sleep(rb_thread_t *th, struct timeval *timeout_tv)
{
...
GVL_UNLOCK_BEGIN();
{
// zrób kilka rzeczy tutaj
}
GVL_UNLOCK_END();
thread_debug("native_sleep donen");
}
Powodem, dla którego ta funkcja jest ważna, jest to, że oprócz używania ścisłego kontekstu Ruby, przełącza się ona również do kontekstu systemowego, aby wykonać tam pewne operacje. W takich sytuacjach proces Ruby nie ma nic do roboty... Świetny przykład marnowania czasu? Nie do końca, ponieważ istnieje GIL mówiący: "Nic do roboty w tym wątku? Przełączmy się na inny i wróćmy tu po chwili". Można to zrobić, odblokowując i blokując GIL za pomocą GVL_UNLOCK_BEGIN() i GVL_UNLOCK_END() funkcje.
Sytuacja staje się jasna, ale sen jest rzadko przydatna. Potrzebujemy więcej przykładów z życia wziętych.
Test pobierania plików
Uruchamia proces, który pobiera i zapisuje plik.
wymagają "net/http"
class DownloadFileTest < Test
def perform
Net::HTTP.get("upload.wikimedia.org", "/wikipedia/commons/thumb/7/73/Ruby_logo.svg/2000px-Ruby_logo.svg.png")
end
end
Nie ma potrzeby komentowania poniższych wyników. Są one dość podobne do tych z powyższego przykładu.
1.003642
JRuby
Rubinius
sekwencyjny
0.327980
0.334000
0.329353
rozwidlenie
0.104766
–
0.121054
gwintowanie
0.085789
0.094000
0.088490
Innym dobrym przykładem może być proces kopiowania plików lub jakakolwiek inna operacja wejścia/wyjścia.
Wnioski
Rubinius w pełni obsługuje zarówno rozwidlanie, jak i wątkowanie (od wersji 2.X, kiedy usunięto GIL). Twój kod może być współbieżny i działać równolegle.
JRuby dobrze radzi sobie z wątkami, ale w ogóle nie obsługuje forkowania. Równoległość i współbieżność można osiągnąć za pomocą wątków.
REZONANS MAGNETYCZNY obsługuje rozwidlanie, ale wątkowanie jest ograniczone przez obecność GIL. Współbieżność można osiągnąć za pomocą wątków, ale tylko wtedy, gdy uruchomiony kod wykracza poza kontekst interpretera Ruby (np. operacje wejścia-wyjścia, funkcje jądra). Nie ma sposobu na osiągnięcie równoległości.