Wydajność we/wy po stronie serwera: węzeł vs. PHP vs. Java vs. Go
Opublikowany: 2022-03-11Zrozumienie modelu wejścia/wyjścia (I/O) aplikacji może oznaczać różnicę między aplikacją, która radzi sobie z obciążeniem, któremu jest poddawana, a aplikacją, która łamie się w obliczu rzeczywistych przypadków użycia. Być może, chociaż Twoja aplikacja jest mała i nie obsługuje dużych obciążeń, może to mieć znacznie mniejsze znaczenie. Jednak wraz ze wzrostem obciążenia ruchem aplikacji praca z niewłaściwym modelem we/wy może wprowadzić Cię w świat bólu.
I jak w większości sytuacji, w których możliwe jest wiele podejść, nie chodzi tylko o to, które z nich jest lepsze, ale o zrozumienie kompromisów. Przejdźmy się po krajobrazie I/O i zobaczmy, co możemy szpiegować.
W tym artykule będziemy porównywać Node, Javę, Go i PHP z Apache, omawiając, w jaki sposób różne języki modelują swoje I/O, zalety i wady każdego modelu, a zakończymy kilkoma podstawowymi testami porównawczymi. Jeśli martwisz się wydajnością we/wy następnej aplikacji sieci Web, ten artykuł jest dla Ciebie.
Podstawy we/wy: szybkie przypomnienie
Aby zrozumieć czynniki związane z we/wy, musimy najpierw przejrzeć te koncepcje na poziomie systemu operacyjnego. Chociaż jest mało prawdopodobne, że wiele z tych koncepcji będzie musiało zajmować się bezpośrednio, masz do czynienia z nimi pośrednio przez cały czas w środowisku wykonawczym aplikacji. A szczegóły mają znaczenie.
Wywołania systemowe
Po pierwsze, mamy wywołania systemowe, które można opisać następująco:
- Twój program (w „kraju użytkownika”, jak mówią) musi poprosić jądro systemu operacyjnego o wykonanie operacji we/wy w jego imieniu.
- „Wywołanie systemowe” to sposób, w jaki twój program prosi o coś jądro. Specyfika tego, jak to jest zaimplementowane, różni się w zależności od systemu operacyjnego, ale podstawowa koncepcja jest taka sama. Będzie jakaś specjalna instrukcja, która przeniesie kontrolę z twojego programu do jądra (jak wywołanie funkcji, ale ze specjalnym sosem specjalnie do radzenia sobie z tą sytuacją). Mówiąc ogólnie, wywołania systemowe blokują, co oznacza, że program czeka na powrót jądra do kodu.
- Jądro wykonuje podstawową operację we/wy na danym urządzeniu fizycznym (dysku, karcie sieciowej itp.) i odpowiada na wywołanie systemowe. W prawdziwym świecie jądro może być zmuszone wykonać szereg czynności, aby spełnić Twoje żądanie, w tym czekać na gotowość urządzenia, aktualizować jego stan wewnętrzny itp., ale jako programista aplikacji nie przejmujesz się tym. To zadanie jądra.
Połączenia blokujące a połączenia nieblokujące
Właśnie powiedziałem powyżej, że wywołania systemowe blokują i jest to prawda w ogólnym sensie. Jednak niektóre wywołania są klasyfikowane jako „nieblokujące”, co oznacza, że jądro pobiera twoje żądanie, umieszcza je w kolejce lub gdzieś buforuje, a następnie natychmiast wraca, nie czekając na faktyczne wystąpienie operacji we/wy. Tak więc „blokuje się” tylko na bardzo krótki czas, wystarczająco długi, aby umieścić twoją prośbę w kolejce.
Kilka przykładów (wywołań systemowych Linuksa) może pomóc wyjaśnić: - read()
jest wywołaniem blokującym - przekazujesz mu uchwyt mówiący, który plik i bufor, gdzie dostarczyć odczytane dane, a wywołanie powraca, gdy dane są tam. Zauważ, że ma to tę zaletę, że jest ładne i proste. - epoll_create()
, epoll_ctl()
i epoll_wait()
to wywołania, które, odpowiednio, pozwalają utworzyć grupę uchwytów do nasłuchiwania, dodawać/usuwać uchwyty z tej grupy, a następnie blokować do momentu pojawienia się jakiejkolwiek aktywności. Pozwala to skutecznie kontrolować dużą liczbę operacji we/wy za pomocą jednego wątku, ale wyprzedzam siebie. Jest to świetne, jeśli potrzebujesz funkcjonalności, ale jak widzisz, jest to z pewnością bardziej złożone w użyciu.
Ważne jest, aby zrozumieć tutaj rząd wielkości różnicy w czasie. Jeśli rdzeń procesora działa z częstotliwością 3 GHz, bez wchodzenia w optymalizacje, które może wykonać procesor, wykonuje 3 miliardy cykli na sekundę (lub 3 cykle na nanosekundę). Nieblokujące wywołanie systemowe może trwać do 10 sekund cykli – lub „stosunkowo kilka nanosekund”. Wywołanie blokujące informacje otrzymywane przez sieć może zająć znacznie więcej czasu - powiedzmy na przykład 200 milisekund (1/5 sekundy). Powiedzmy, na przykład, że połączenie nieblokujące zajęło 20 nanosekund, a połączenie blokujące zajęło 200 000 000 nanosekund. Twój proces właśnie czekał 10 milionów razy dłużej na połączenie blokujące.
Jądro zapewnia środki do wykonywania zarówno blokowania we/wy („odczytaj z tego połączenia sieciowego i podaj mi dane”), jak i nieblokującego we/wy („powiedz mi, kiedy którekolwiek z tych połączeń sieciowych ma nowe dane”). A który mechanizm zostanie użyty, zablokuje proces wywołujący na dramatycznie różne okresy czasu.
Planowanie
Trzecią ważną rzeczą do naśladowania jest to, co dzieje się, gdy masz wiele wątków lub procesów, które zaczynają się blokować.
Dla naszych celów nie ma dużej różnicy między wątkiem a procesem. W rzeczywistości najbardziej zauważalną różnicą związaną z wydajnością jest to, że ponieważ wątki współdzielą tę samą pamięć, a każdy z procesów ma własną przestrzeń pamięci, oddzielne procesy zwykle zajmują dużo więcej pamięci. Ale kiedy mówimy o harmonogramowaniu, tak naprawdę sprowadza się to do listy rzeczy (zarówno wątków, jak i procesów), z których każdy musi uzyskać kawałek czasu wykonania na dostępnych rdzeniach procesora. Jeśli masz 300 uruchomionych wątków i 8 rdzeni do ich uruchomienia, musisz podzielić czas w górę, aby każdy z nich otrzymał swój udział, przy czym każdy rdzeń działa przez krótki czas, a następnie przechodzi do następnego wątku. Odbywa się to za pomocą „przełącznika kontekstu”, dzięki czemu procesor przełącza się z uruchamiania jednego wątku/procesu na następny.
Te przełączniki kontekstowe wiążą się z pewnym kosztem - zabierają trochę czasu. W niektórych szybkich przypadkach może to być mniej niż 100 nanosekund, ale nierzadko trwa to 1000 nanosekund lub dłużej, w zależności od szczegółów implementacji, szybkości/architektury procesora, pamięci podręcznej procesora itp.
A im więcej wątków (lub procesów), tym więcej przełączania kontekstów. Kiedy mówimy o tysiącach wątków i setkach nanosekund na każdy, sprawy mogą stać się bardzo powolne.
Jednak połączenia nieblokujące w istocie mówią jądru „zadzwoń do mnie tylko wtedy, gdy masz jakieś nowe dane lub zdarzenie na jednym z tych połączeń”. Te nieblokujące wywołania mają na celu wydajną obsługę dużych obciążeń we/wy i ograniczenie przełączania kontekstu.
Ze mną do tej pory? Ponieważ teraz nadchodzi zabawna część: spójrzmy, co niektóre popularne języki robią z tymi narzędziami i wyciągnijmy wnioski na temat kompromisów między łatwością użytkowania a wydajnością… i innymi interesującymi ciekawostkami.
Uwaga, chociaż przykłady pokazane w tym artykule są trywialne (i częściowe, z pokazanymi tylko odpowiednimi bitami); dostęp do bazy danych, zewnętrzne systemy buforowania (memcache itp.) i wszystko, co wymaga I/O, zakończy się wykonaniem pewnego rodzaju wywołania I/O pod maską, co będzie miało taki sam efekt, jak pokazane na prostych przykładach. Ponadto, w scenariuszach, w których I/O jest opisane jako „blokujące” (PHP, Java), żądania HTTP i odpowiedzi odczyty i zapisy same blokują wywołania: Ponownie, więcej I/O ukrytych w systemie z towarzyszącymi problemami z wydajnością brać pod uwagę.
Istnieje wiele czynników, które wpływają na wybór języka programowania do projektu. Jeśli bierzesz pod uwagę tylko wydajność, jest nawet wiele czynników. Ale jeśli obawiasz się, że twój program będzie ograniczany głównie przez operacje wejścia/wyjścia, jeśli wydajność operacji wejścia/wyjścia jest słaba dla twojego projektu, to są to rzeczy, które musisz wiedzieć.
Podejście „Utrzymaj to w prostocie”: PHP
W latach 90-tych wiele osób nosiło buty Converse i pisało skrypty CGI w Perlu. Potem pojawiło się PHP i, jak niektórzy ludzie lubią się na nim szaleć, znacznie ułatwiło tworzenie dynamicznych stron internetowych.
Model używany przez PHP jest dość prosty. Jest kilka odmian, ale twój przeciętny serwer PHP wygląda tak:
Żądanie HTTP przychodzi z przeglądarki użytkownika i trafia na serwer WWW Apache. Apache tworzy osobny proces dla każdego żądania, z pewnymi optymalizacjami, aby ponownie je wykorzystać, aby zminimalizować liczbę zadań (tworzenie procesów jest stosunkowo powolne). Apache wywołuje PHP i każe mu uruchomić odpowiedni plik .php
na dysku. Kod PHP wykonuje i blokuje wywołania we/wy. file_get_contents()
w PHP i pod maską wykonuje wywołania systemowe read()
i czeka na wyniki.
I oczywiście sam kod jest po prostu osadzony bezpośrednio na Twojej stronie, a operacje blokują:
<?php // blocking file I/O $file_data = file_get_contents('/path/to/file.dat'); // blocking network I/O $curl = curl_init('http://example.com/example-microservice'); $result = curl_exec($curl); // some more blocking network I/O $result = $db->query('SELECT id, data FROM examples ORDER BY id DESC limit 100'); ?>
Jeśli chodzi o to, jak to integruje się z systemem, wygląda to tak:
Dość proste: jeden proces na żądanie. Wywołania we/wy po prostu blokują. Korzyść? To proste i działa. Niekorzyść? Uderz w to z 20 000 klientów jednocześnie, a Twój serwer stanie w płomieniach. To podejście nie jest dobrze skalowalne, ponieważ narzędzia dostarczane przez jądro do obsługi operacji we/wy o dużej objętości (epoll itp.) nie są używane. Aby dodać obrazę do kontuzji, uruchomienie oddzielnego procesu dla każdego żądania zwykle zużywa dużo zasobów systemowych, zwłaszcza pamięci, która często jest pierwszą rzeczą, której brakuje w takim scenariuszu.
Uwaga: Podejście zastosowane w przypadku Rubiego jest bardzo podobne do tego w PHP i ogólnie rzecz biorąc, można je uznać za takie same dla naszych celów.
Podejście wielowątkowe: Java
Tak więc pojawia się Java, mniej więcej w momencie, gdy kupiłeś swoją pierwszą nazwę domeny i fajnie było po prostu losowo powiedzieć „kropka com” po zdaniu. A Java ma wielowątkowość wbudowaną w język, co (zwłaszcza w momencie tworzenia) jest całkiem niesamowite.
Większość serwerów internetowych Java działa, uruchamiając nowy wątek wykonania dla każdego przychodzącego żądania, a następnie w tym wątku ostatecznie wywołując funkcję, którą napisałeś jako twórca aplikacji.
Wykonywanie operacji we/wy w serwlecie Javy wygląda mniej więcej tak:
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // blocking file I/O InputStream fileIs = new FileInputStream("/path/to/file"); // blocking network I/O URLConnection urlConnection = (new URL("http://example.com/example-microservice")).openConnection(); InputStream netIs = urlConnection.getInputStream(); // some more blocking network I/O out.println("..."); }
Ponieważ powyższa metoda doGet
odpowiada jednemu żądaniu i jest uruchamiana we własnym wątku, zamiast oddzielnego procesu dla każdego żądania, który wymaga własnej pamięci, mamy osobny wątek. Ma to kilka fajnych korzyści, takich jak możliwość dzielenia się stanem, buforowanymi danymi itp. między wątkami, ponieważ mają one dostęp do swojej pamięci, ale wpływ na sposób interakcji z harmonogramem jest nadal prawie identyczny z tym, co jest robione w PHP poprzedni przykład. Każde żądanie otrzymuje nowy wątek i różne bloki operacji we/wy wewnątrz tego wątku, dopóki żądanie nie zostanie w pełni obsłużone. Wątki są łączone, aby zminimalizować koszty ich tworzenia i niszczenia, ale mimo to tysiące połączeń to tysiące wątków, co jest szkodliwe dla harmonogramu.
Ważnym kamieniem milowym jest to, że w wersji 1.4 Java (i znacząca aktualizacja ponownie w 1.7) zyskała możliwość wykonywania nieblokujących wywołań I/O. Większość aplikacji, internetowych i innych, nie używa go, ale przynajmniej jest dostępny. Niektóre serwery internetowe Java próbują to wykorzystać na różne sposoby; jednak zdecydowana większość wdrożonych aplikacji Java nadal działa w sposób opisany powyżej.
Java zbliża nas do siebie i na pewno ma dobrą, gotową do użycia funkcjonalność we/wy, ale nadal nie rozwiązuje problemu tego, co się dzieje, gdy masz mocno powiązaną aplikację we/wy, która jest wbijana grunt z wieloma tysiącami wątków blokujących.
Nieblokujące we/wy jako obywatel pierwszej klasy: Węzeł
Popularnym dzieckiem w bloku, jeśli chodzi o lepsze I/O jest Node.js. Każdy, kto miał choćby najkrótsze wprowadzenie do Node, został poinformowany, że jest on „nieblokujący” i że efektywnie obsługuje I/O. I to jest prawda w sensie ogólnym. Ale diabeł tkwi w szczegółach, a środki, za pomocą których osiągnięto to czary, mają znaczenie, jeśli chodzi o wydajność.
Zasadniczo zmiana paradygmatu, którą implementuje Node, polega na tym, że zamiast mówić „napisz tutaj swój kod, aby obsłużyć żądanie”, zamiast tego mówią „napisz tutaj kod, aby rozpocząć obsługę żądania”. Za każdym razem, gdy musisz zrobić coś, co obejmuje I/O, wykonujesz żądanie i podajesz funkcję zwrotną, którą Node wywoła, gdy to się stanie.

Typowy kod węzła do wykonywania operacji we/wy w żądaniu wygląda następująco:
http.createServer(function(request, response) { fs.readFile('/path/to/file', 'utf8', function(err, data) { response.end(data); }); });
Jak widać, są tutaj dwie funkcje zwrotne. Pierwsza jest wywoływana, gdy rozpoczyna się żądanie, a druga jest wywoływana, gdy dane pliku są dostępne.
W zasadzie daje to Node'owi możliwość efektywnej obsługi I/O pomiędzy tymi wywołaniami zwrotnymi. Scenariusz, w którym byłoby to jeszcze bardziej istotne, to taki, w którym wykonujesz wywołanie bazy danych w Node, ale nie będę zawracał sobie głowy przykładem, ponieważ jest to dokładnie ta sama zasada: rozpoczynasz wywołanie bazy danych i nadajesz Node funkcję wywołania zwrotnego, to wykonuje operacje I/O oddzielnie, używając nieblokujących wywołań, a następnie wywołuje funkcję wywołania zwrotnego, gdy dane, o które prosiłeś, są dostępne. Ten mechanizm kolejkowania wywołań we/wy i umożliwienia Node obsługi tego, a następnie uzyskania wywołania zwrotnego, nazywa się „Pętlą zdarzeń”. I działa całkiem nieźle.
Jest jednak pewien haczyk w tym modelu. Pod maską powód tego ma znacznie więcej wspólnego z tym, jak zaimplementowany jest silnik JavaScript V8 (silnik Chrome JS używany przez Node ) niż cokolwiek innego. Cały napisany przez Ciebie kod JS działa w jednym wątku. Pomyśl o tym przez chwilę. Oznacza to, że podczas gdy operacje we/wy są wykonywane przy użyciu wydajnych technik nieblokujących, Twój JS może wykonywać operacje związane z procesorem w jednym wątku, a każdy fragment kodu blokuje następny. Typowym przykładem sytuacji, w której może się to pojawić, jest zapętlenie rekordów bazy danych w celu przetworzenia ich w jakiś sposób przed wysłaniem ich do klienta. Oto przykład, który pokazuje, jak to działa:
var handler = function(request, response) { connection.query('SELECT ...', function (err, rows) { if (err) { throw err }; for (var i = 0; i < rows.length; i++) { // do processing on each row } response.end(...); // write out the results }) };
Podczas gdy Node efektywnie obsługuje I/O, pętla for
w powyższym przykładzie używa cykli procesora w twoim jedynym głównym wątku. Oznacza to, że jeśli masz 10 000 połączeń, ta pętla może doprowadzić całą aplikację do indeksowania, w zależności od tego, jak długo to zajmie. Każde żądanie musi dzielić kawałek czasu, pojedynczo, w głównym wątku.
Założeniem, na którym opiera się cała koncepcja, jest to, że operacje we/wy są najwolniejszą częścią, dlatego najważniejsza jest ich wydajna obsługa, nawet jeśli oznacza to wykonywanie innych operacji szeregowo. To prawda w niektórych przypadkach, ale nie we wszystkich.
Inną kwestią jest to, że chociaż jest to tylko opinia, pisanie wielu zagnieżdżonych wywołań zwrotnych może być dość męczące, a niektórzy twierdzą, że znacznie utrudnia to śledzenie kodu. Często zdarza się, że wywołania zwrotne są zagnieżdżone na czterech, pięciu lub nawet więcej poziomach głęboko w kodzie węzła.
Wracamy do kompromisów. Model Node działa dobrze, jeśli głównym problemem z wydajnością jest I/O. Jednak jego piętą achillesową jest to, że możesz przejść do funkcji obsługującej żądanie HTTP i umieścić kod intensywnie korzystający z procesora i doprowadzić każde połączenie do indeksowania, jeśli nie będziesz ostrożny.
Naturalnie nieblokujący: Go
Zanim przejdę do sekcji Go, powinienem ujawnić, że jestem fanboyem Go. Używałem go do wielu projektów i otwarcie jestem zwolennikiem jego zalet wydajnościowych i widzę je w swojej pracy, kiedy z niego korzystam.
To powiedziawszy, spójrzmy, jak radzi sobie z I/O. Jedną z kluczowych cech języka Go jest to, że zawiera własny harmonogram. Zamiast każdego wątku wykonania odpowiadającego pojedynczemu wątkowi systemu operacyjnego, działa z koncepcją „gorutyn”. A środowisko uruchomieniowe Go może przypisać gorutynę do wątku systemu operacyjnego i uruchomić ją lub zawiesić i nie skojarzyć jej z wątkiem systemu operacyjnego, w zależności od tego, co robi ta gorutyna. Każde żądanie przychodzące z serwera HTTP Go jest obsługiwane w oddzielnym Goroutine.
Schemat działania harmonogramu wygląda tak:
Pod maską jest to realizowane przez różne punkty w środowisku wykonawczym Go, które implementują wywołanie we/wy, wysyłając żądanie zapisu/odczytu/połączenia/itd., uśpienia bieżącej gorutyny z informacją o ponownym obudzeniu gorutyny się, kiedy można podjąć dalsze działania.
W efekcie środowisko uruchomieniowe Go robi coś bardzo podobnego do tego, co robi Node, z wyjątkiem tego, że mechanizm wywołania zwrotnego jest wbudowany w implementację wywołania we/wy i automatycznie współdziała z harmonogramem. Nie cierpi również z powodu ograniczenia konieczności uruchamiania całego kodu obsługi w tym samym wątku. Rezultatem jest kod taki:
func ServeHTTP(w http.ResponseWriter, r *http.Request) { // the underlying network call here is non-blocking rows, err := db.Query("SELECT ...") for _, row := range rows { // do something with the rows, // each request in its own goroutine } w.Write(...) // write the response, also non-blocking }
Jak widać powyżej, podstawowa struktura kodu tego, co robimy, przypomina bardziej uproszczone podejścia, a jednocześnie zapewnia nieblokujące operacje we/wy pod maską.
W większości przypadków kończy się to „najlepszym z obu światów”. Nieblokujące operacje we/wy są używane do wszystkich ważnych rzeczy, ale Twój kod wygląda tak, jakby się blokował, a zatem jest łatwiejszy do zrozumienia i utrzymania. Resztą zajmuje się interakcja między harmonogramem Go a harmonogramem systemu operacyjnego. To nie jest pełna magia, a jeśli budujesz duży system, warto poświęcić czas, aby zrozumieć więcej szczegółów na temat jego działania; ale jednocześnie środowisko, które otrzymujesz „po wyjęciu z pudełka”, działa i całkiem dobrze się skaluje.
Go może mieć swoje wady, ale ogólnie rzecz biorąc, sposób, w jaki obsługuje I/O, nie należy do nich.
Kłamstwa, przeklęte kłamstwa i wzorce
Trudno jest podać dokładny czas przełączania kontekstu związanego z tymi różnymi modelami. Mógłbym też argumentować, że jest to dla ciebie mniej przydatne. Zamiast tego podam kilka podstawowych testów porównawczych, które porównują ogólną wydajność serwera HTTP w tych środowiskach serwerowych. Należy pamiętać, że na wydajność całej ścieżki żądania/odpowiedzi HTTP jest zaangażowanych wiele czynników, a przedstawione tutaj liczby to tylko niektóre próbki, które zestawiłem, aby dać podstawowe porównanie.
Dla każdego z tych środowisk napisałem odpowiedni kod do odczytania w pliku 64k z losowymi bajtami, wykonałem na nim hash SHA-256 N liczbę razy (N jest określone w ciągu zapytania adresu URL, np. .../test.php?n=100
) i wypisz wynikowy skrót w postaci szesnastkowej. Wybrałem to, ponieważ jest to bardzo prosty sposób na uruchomienie tych samych testów porównawczych z pewnymi spójnymi operacjami we/wy i kontrolowanym sposobem na zwiększenie wykorzystania procesora.
Zapoznaj się z tymi uwagami dotyczącymi testów porównawczych, aby uzyskać więcej szczegółów na temat używanych środowisk.
Najpierw spójrzmy na kilka przykładów o niskiej współbieżności. Uruchomienie 2000 iteracji z 300 równoczesnymi żądaniami i tylko jednym haszem na żądanie (N=1) daje nam to:
Trudno wyciągnąć wnioski z tylko tego jednego wykresu, ale wydaje mi się, że przy tej liczbie połączeń i obliczeń widzimy czasy, które mają więcej wspólnego z ogólnym wykonaniem samych języków, o wiele bardziej, że We/Wy. Zauważ, że języki uważane za „języki skryptowe” (luźne pisanie, dynamiczna interpretacja) działają najwolniej.
Ale co się stanie, jeśli zwiększymy N do 1000, wciąż przy 300 jednoczesnych żądaniach - to samo obciążenie, ale 100 razy więcej iteracji haszujących (znacznie większe obciążenie procesora):
Nagle wydajność węzła znacznie spada, ponieważ operacje intensywnie wykorzystujące procesor w każdym żądaniu blokują się nawzajem. Co ciekawe, wydajność PHP staje się znacznie lepsza (w stosunku do innych) i przewyższa w tym teście Javę. (Warto zauważyć, że w PHP implementacja SHA-256 jest napisana w C, a ścieżka wykonania spędza w tej pętli znacznie więcej czasu, ponieważ robimy teraz 1000 iteracji haszujących).
Teraz spróbujmy 5000 jednoczesnych połączeń (przy N=1) - lub tak blisko tego, jak tylko mogłem. Niestety, w przypadku większości tych środowisk wskaźnik awaryjności nie był bez znaczenia. Na tym wykresie przyjrzymy się całkowitej liczbie żądań na sekundę. Im wyższy tym lepiej :
A obraz wygląda zupełnie inaczej. To zgadywanie, ale wygląda na to, że przy dużej liczbie połączeń narzut przypadający na jedno połączenie związany z tworzeniem nowych procesów i związana z tym dodatkowa pamięć w PHP+Apache wydaje się być dominującym czynnikiem i obniża wydajność PHP. Najwyraźniej zwycięzcą jest tutaj Go, następnie Java, Node i wreszcie PHP.
Chociaż czynników związanych z ogólną przepustowością jest wiele, a także różnią się znacznie w zależności od aplikacji, im więcej rozumiesz wnętrzności tego, co dzieje się pod maską i związanych z tym kompromisów, tym lepiej dla Ciebie.
W podsumowaniu
Biorąc pod uwagę powyższe, jest całkiem jasne, że wraz z ewolucją języków ewoluowały wraz z nimi rozwiązania do obsługi aplikacji na dużą skalę, które wykonują wiele operacji we/wy.
Aby być uczciwym, zarówno PHP, jak i Java, pomimo opisów w tym artykule, mają implementacje nieblokującego I/O dostępne do użytku w aplikacjach internetowych. Jednak nie są one tak powszechne, jak podejścia opisane powyżej, a związane z tym koszty operacyjne związane z utrzymaniem serwerów przy użyciu takich podejść musiałyby zostać wzięte pod uwagę. Nie wspominając o tym, że twój kod musi być skonstruowany w sposób, który działa w takich środowiskach; Twoja „normalna” aplikacja internetowa PHP lub Java zwykle nie będzie działać bez znaczących modyfikacji w takim środowisku.
Dla porównania, jeśli weźmiemy pod uwagę kilka istotnych czynników, które wpływają na wydajność, a także łatwość użytkowania, otrzymujemy:
Język | Wątki a procesy | Nieblokujące we/wy | Łatwość użycia |
---|---|---|---|
PHP | Procesy | Nie | |
Jawa | Wątki | Do dyspozycji | Wymaga wywołań zwrotnych |
Node.js | Wątki | TAk | Wymaga wywołań zwrotnych |
Udać się | Wątki (gorutyny) | TAk | Brak potrzebnych połączeń zwrotnych |
Wątki będą na ogół znacznie bardziej wydajne pod względem pamięci niż procesy, ponieważ dzielą tę samą przestrzeń pamięci, podczas gdy procesy nie. Łącząc to z czynnikami związanymi z nieblokującymi we/wy, widzimy, że przynajmniej z czynnikami rozważanymi powyżej, gdy przesuwamy się w dół listy, poprawia się ogólna konfiguracja związana z we/wy. Jeśli więc miałbym wyłonić zwycięzcę w powyższym konkursie, z pewnością byłby to Go.
Mimo to w praktyce wybór środowiska, w którym ma zostać zbudowana aplikacja, jest ściśle związany ze znajomością tego środowiska przez zespół oraz ogólną produktywnością, jaką można dzięki niemu osiągnąć. Dlatego może nie mieć sensu, aby każdy zespół po prostu zanurkował i zaczął tworzyć aplikacje i usługi internetowe w Node lub Go. Rzeczywiście, znalezienie programistów lub znajomość wewnętrznego zespołu jest często wymieniana jako główny powód, aby nie używać innego języka i/lub środowiska. To powiedziawszy, czasy bardzo się zmieniły w ciągu ostatnich piętnastu lat.
Mamy nadzieję, że powyższe informacje pomogą nakreślić jaśniejszy obraz tego, co dzieje się pod maską, i podsunie kilka pomysłów, jak radzić sobie ze skalowalnością aplikacji w świecie rzeczywistym. Miłego wprowadzania i wyprowadzania!