Skalowalny Ruby - wyjaśnienie współbieżności i równoległości
Lata temu Ruby rządził siecią. Bardzo łatwo było w nim budować nowe aplikacje, miał wiele gemów, które rozwiązywały powszechne problemy, co ułatwiało budowanie nowych funkcji.
Ale raptem wszystko się zmieniło. Twitter walczył o to, aby skalować Ruby i przełączył się na inne platformy, aby spełnić swoje wymagania wydajnościowe. Coraz więcej głosów zwracało uwagę, że Ruby był zbyt wolny i nie był w stanie sprostać wymaganiom sieci.
Dało to początek nowym technologiom, które skalowały się łatwiej i lepiej. Node szczególnie zaspokoił ten popyt i stworzył wokół siebie sporo hype'u. Pojawiły się również inne technologie, jak Golang, Elixir i Scala/Akka.
Uwaga: W trakcie pisania o Ruby, nie będę mówił o architekturze wieloprocesowej ani o tym, jak używać load balancerów. Celem tej pracy jest pokazanie, jak wykorzystać współbieżność w Ruby w ramach jednego procesu i pokazać różnicę między równoległością a współbieżnością.
Jak Node osiągnął taką skalowalność? Node działa na silniku V8 JavaScript i wykorzystuje bardzo zaawansowany i szybki event loop. Z jego pomocą oraz nieblokujących operacji IO, Node może obsłużyć ogromną ilość równolegle przychodzących żądań.
Czy to oznacza, że Node uruchamia wszystkie te żądania równolegle? Właściwie to nie. Event loop i nieblokujące operacje IO umożliwiają V8 uruchamianie żądań jednocześnie, ale nie równolegle.
Już słyszę jak mówisz "Czekaj, co? A co to wogóle za różnica i dlaczego miałbym się tym przejmować?"
Czym jest współbieżność i równoległość?
Współbieżność
Bardzo dokładna definicja współbieżności, którą znalazłem, jest taka:
"Koordynacja i zarządzanie niezależnymi ścieżkami wykonania. Wykonania te mogą być naprawdę równoległe lub po prostu zarządzane poprzez przeplatanie. Mogą komunikować się poprzez współdzieloną pamięć lub przekazywanie wiadomości".
Mówiąc o współbieżności, będę mówił tylko o zarządzanym przez przeplatanie typie współbieżności.
Wyjaśnijmy współbieżność z przykładem Node, ponieważ wiele osób zna Node i (prawie) każdy zna JavaScript. Przyjrzyjmy się więc przykładowi, aby zobaczyć, co w praktyce oznacza współbieżność.
Przypuśćmy, że mamy dwie żądania z klientów. Jeden z nich chce zaktualizować niektóre dane dotyczące pracownika, a drugi chce uzyskać dostęp do listy wszystkich działów w firmie. Jak silnik V8 radzi sobie w takiej sytuacji?
Najpierw zostanie obsłużone pierwsze żądanie. Dla przykładu: serwer sprawdza, czy użytkownik może zapisać dane klienta. W tym celu aplikacja robi żądanie do bazy danych. I oto dzieje się to o czym mówię! Żądanie do bazy danych nie jest blokowane, co oznacza, że aplikacja nie czeka na odpowiedź i kontynuuje. Ponieważ bieżące żądanie nie ma nic do roboty, dopóki baza danych nie zareaguje, powiadamia scheduler Node'a, że jest bezczynny. Teraz Node może obsłużyć drugie żądanie.
Przy okazji, jest to powód, dla którego programista JS musi używać async/await, promises czy callbacków. Wszystkie te techniki pozwalają silnikowi JS na kontynuację przepływu i obsługę odpowiedzi po przesłaniu danych przez bazę danych, podczas gdy programista ma kod w jednym miejscu.
Co to oznacza dla Ruby?
Ponieważ (prawie) wszystkie aplikacje internetowe mają do obsłużenia wiele zapytań sieciowych, główną rzeczą, która ogranicza wydajność w Ruby, jest żądanie i oczekiwanie na odpowiedź, gdyż domyślnie używa blokowania IO.
Nieco dalej opowiem co my, deweloperzy Ruby, możemy zrobić, aby współbieżność działała poprawnie.
Równoległość
Oto definicja:
Prawdziwie równoczesna realizacja lub wykonanie rzeczy.
Równoległość jest łatwiejsza do zrozumienia, ponieważ działa tak, jak sobie wyobrażasz. Dwa żądania przychodzą do serwera, dwa żądania są obsługiwane równolegle, w tym samym czasie, z poziomu samego serwera. Z punktu widzenia komputera oznacza to, że dwa rdzenie pracują jednocześnie.
Jak osiągnąć (nierównoległą) współbieżność w Ruby?
A teraz przejdźmy do celu tego artykułu: Jak możemy osiągnąć współbieżność w Ruby?
Jak wspomniałem wyżej, jest to możliwe, ale nie jest to takie proste. Ruby nie ma async/await, nie ma promises, a aby użyć nieblokującego IO, trzeba by napisać własny adapter ORM.
Pewnie powiesz "Ale mamy wątki!". Tak, masz rację. W przypadku wątków możemy użyć współbieżności. Jak to zrobić? Oto prosty przykład z ActiveRecord Rails:
threads << Thread.new do ActiveRecord::Base.connection_pool.with_connection do
# needed because otherwise the connection pool can run
# out of connections
t = Thread.current
t[:variable_name] = Model.find_by(column: data)
end
end
joined_threads = threads.map &:join
# do something with the thread local variable
Wady w porównaniu do współbieżności Node
Każdy wątek jest mapowany do jednego wątku systemu operacyjnego. Dlatego nie jest możliwe stworzenie setek, a nawet tysięcy wątków. Deweloper musi się znacznie bardziej napracować. Innymi słowy, musi robić wiele rzeczy dużo dokładniej.
Ponieważ używamy wątków i wątków Ruby, tłumaczymy je na wątki systemu operacyjnego....
Czy możemy również wykorzystać równoległość z Ruby?
Niestety, nie w ramach jednego procesu. Ruby posiada coś, co nazywa się GVL (skrót od Global Virtual Machine lock - czasami nazywany GIL - Global Interpreter Lock). Dzięki temu mechanizmowi Ruby upewnia się, że w jednym czasie działa tylko jeden wątek systemu operacyjnego. Jest to zasadniczo globalna flaga pokazująca, czy można uruchomić kod w bieżącym wątku.
Dla operacji IO, jak żądanie bazy danych w powyższym przykładzie, jest to w porządku, ponieważ wątek czeka na bazę danych, a Twój system operacyjny i tak go uśpi. Dlatego też drugi wątek może być przetwarzany - przynajmniej do momentu uśpienia również z powodu żądania bazy danych lub dlatego, że system operacyjny przełącza wątki.
Ale dla kodu Ruby wygląda to inaczej. Możesz używać wątków z Ruby bez operacji związanych z IO, ale wtedy nie zobaczysz żadnej zwiększonej wydajności. MRI Ruby sprawdzałby GVL i tylko wtedy, gdy ta flaga jest ustawiona na true, uruchamiałby Twój kod.
Dlaczego stworzono coś takiego jak GVL?
Powodem, dla którego Ruby używa GVL, jest jego filozofia: Uszczęśliwić programistów!
Bardzo trudno jest pisać programy wielowątkowe. GVL sprawia, że jest to łatwiejsze, ponieważ nie można (tak łatwo) doprowadzić do deadlocka lub innych przykrych rzeczy, które mogłyby łatwo wystąpić w programach wielowątkowych.
Na marginesie, Python również używa GIL więc, jest to popularny wybór wśród projektantów języków.
Co z różnymi implementacjami Ruby?
JRuby
JRuby może użyć realnej równoległości. Nie ma żadnego GVL, który zatrzymałby twój kod. Minusem jest oczywiście to, że teraz jesteś odpowiedzialny za upewnienie się, czy Twój kod jest bezpieczny, i że unikasz deadlocków i innych złych rzeczy.
TruffleRuby
TruffleRuby również nie używa GVL. Było nawet kilka dyskusji, aby pójść o krok dalej i umieścić wszystko pod Mutexem, jeśli - i tylko jeśli - Twój kod używa wątków. W ten sposób uzyskasz przewagę GVL oraz wzrost wydajności dzięki wielowątkowości. Ale w tej chwili, to nadal jest funkcja eksperymentalna. Tylko czas pokaże, czy stanie się czymś użytecznym.
Powrót do przyszłości - Ruby 3
Matz rozpoznał problemy związane z równoległością i współbieżnością. Z tego powodu ogłosił "Rok Współbieżności" (Ten film jest dostępny tylko w języku japońskim). Jak Ruby może osiągnąć podobną, a może nawet lepszą skalowalność do czegoś takiego jak Node? Oto trzy projekty, które chcą to umożliwić.
- Gildie
- Concurrent Fibers
- Auto::Thread (Ten projekt może być martwy)
Skalowalność - czy to naprawdę ma znaczenie?
W większości przypadków odpowiedź brzmi "nie". Kiedy uruchamiasz nowy projekt lub aplikację, Twoja aplikacja ma po prostu bardzo, bardzo niewielu użytkowników. Ruby (również: Rails) jest doskonale dostosowany do obsługi średniej liczby użytkowników bez większych problemów. Wiele firm to udowodniło. Ruby może nawet obsłużyć dużą ilość użytkowników, jak pokazał Basecamp, Shopify, GitHub i AirBnB.
Jeśli kiedykolwiek znajdziesz się w miejscu takim jak Twitter, gdzie potrzebujesz jeszcze większej wydajności, oznacza to również że masz więcej zasobów. Więcej pieniędzy, aby rozbić monolit, więcej wiedzy o domenie, a także więcej wiedzy technicznej z postaci pracowników, których prawdopodobnie zatrudniałbyś na tym etapie.
Przyszłość (MRI) Ruby wydaje się jeszcze bardziej oddalać tę barierę, więc możesz z radością kodować w języku, który kochasz i z którym jesteś najbardziej produktywny!
Oryginał tekstu w języku angielskim przeczytasz tutaj.