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)
- ma nowy identyfikator procesu (PID)
- ma oddzielną pamięć*
- 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.
Test obciążenia
Wykonuje obliczenia w pętli, aby wygenerować duże obciążenie procesora.
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.