Wydajność I/O po stronie serwera: Node vs. PHP vs. Java vs. Go

paź 22, 2021
admin

Zrozumienie modelu wejścia/wyjścia (I/O) Twojej aplikacji może oznaczać różnicę między aplikacją, która radzi sobie z obciążeniem, jakiemu jest poddawana, a taką, która gnie się w obliczu rzeczywistych przypadków użycia. Być może, gdy Twoja aplikacja jest mała i nie obsługuje dużych obciążeń, może to mieć znacznie mniejsze znaczenie. Ale gdy obciążenie ruchu aplikacji wzrasta, praca z niewłaściwym modelem I/O może doprowadzić cię do świata bólu.

I jak w każdej sytuacji, w której możliwe są różne podejścia, nie jest to tylko kwestia tego, które z nich jest lepsze, jest to kwestia zrozumienia kompromisów. W tym artykule porównamy Node, Java, Go i PHP z Apache, omówimy jak różne języki modelują swoje I/O, zalety i wady każdego z nich i zakończymy kilkoma podstawowymi benchmarkami. Jeśli martwisz się o wydajność I/O w swojej następnej aplikacji internetowej, ten artykuł jest dla Ciebie.

Podstawy I/O: A Quick Refresher

Aby zrozumieć czynniki związane z I/O, musimy najpierw zapoznać się z koncepcjami na poziomie systemu operacyjnego. Choć jest mało prawdopodobne, że będziemy mieli do czynienia z wieloma z tych pojęć bezpośrednio, to jednak cały czas mamy z nimi do czynienia pośrednio, poprzez środowisko uruchomieniowe naszej aplikacji. A szczegóły mają znaczenie.

Wywołania systemowe

Po pierwsze, mamy wywołania systemowe, które można opisać w następujący sposób:

  • Twój program (w „krainie użytkownika”, jak to się mówi) musi poprosić jądro systemu operacyjnego o wykonanie operacji wejścia/wyjścia w jego imieniu.
  • „Wywołanie systemowe” jest środkiem, za pomocą którego twój program prosi jądro o wykonanie czegoś. Specyfika tego, jak to jest zaimplementowane różni się między systemami operacyjnymi, ale podstawowa koncepcja jest taka sama. Będzie jakaś specyficzna instrukcja, która przekazuje kontrolę z twojego programu do jądra (jak wywołanie funkcji, ale z jakimś specjalnym sosem specjalnie do radzenia sobie z tą sytuacją). Ogólnie rzecz biorąc, wywołania syscall są blokowane, co oznacza, że twój program czeka na powrót jądra do twojego kodu.
  • Jądro wykonuje podstawową operację I/O na fizycznym urządzeniu, o którym mowa (dysk, karta sieciowa, itp.) i odpowiada na wywołanie syscall. W prawdziwym świecie, jądro może być zmuszone do zrobienia wielu rzeczy, aby spełnić twoje żądanie, w tym oczekiwanie, aż urządzenie będzie gotowe, uaktualnienie jego stanu wewnętrznego, itp. ale jako twórca aplikacji, nie dbasz o to. To jest zadanie jądra.

Blocking vs. Non-blocking Calls

Teraz, właśnie powiedziałem powyżej, że syscalls są blokujące, i to jest prawda w ogólnym sensie. Jednakże, niektóre wywołania są skategoryzowane jako „nieblokujące”, co oznacza, że jądro bierze twoje żądanie, umieszcza je gdzieś w kolejce lub buforze, a następnie natychmiast powraca bez czekania na rzeczywiste I/O do wystąpienia. Tak więc „blokuje” tylko przez bardzo krótki okres czasu, wystarczająco długi, aby odczekać twoje żądanie.

Kilka przykładów (z linuksowych syscalls) może pomóc w wyjaśnieniu:- read() jest wywołaniem blokującym – przekazujesz mu uchwyt mówiący, który plik i bufor gdzie dostarczyć dane, które czyta, a wywołanie wraca, gdy dane są tam. epoll_create(), epoll_ctl() i epoll_wait() są wywołaniami, które, odpowiednio, pozwalają na utworzenie grupy uchwytów do nasłuchiwania, dodawanie/usuwanie handlerów z tej grupy, a następnie blokowanie do czasu, aż pojawi się jakakolwiek aktywność. Pozwala to na efektywne kontrolowanie dużej liczby operacji wejścia/wyjścia za pomocą jednego wątku, ale już się rozpędziłem. Jest to świetne rozwiązanie, jeśli potrzebujesz tej funkcjonalności, ale jak widzisz, jest to z pewnością bardziej skomplikowane w użyciu.

Ważne jest, aby zrozumieć rząd wielkości różnicy w taktowaniu tutaj. Jeśli rdzeń procesora działa z częstotliwością 3GHz, bez wchodzenia w optymalizacje, które procesor może wykonać, wykonuje 3 miliardy cykli na sekundę (lub 3 cykle na nanosekundę). Nieblokujące wywołanie systemowe może zająć 10 cykli, aby zakończyć – lub „stosunkowo kilka nanosekund”. Wywołanie, które blokuje się na informacje otrzymywane przez sieć, może zająć znacznie więcej czasu – powiedzmy na przykład 200 milisekund (1/5 sekundy). I powiedzmy, że na przykład wywołanie nieblokujące trwało 20 nanosekund, a wywołanie blokujące trwał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 blokujących operacji wejścia/wyjścia („odczytaj z tego połączenia sieciowego i daj mi dane”), jak i nieblokujących operacji wejścia/wyjścia („powiedz mi, kiedy którekolwiek z tych połączeń sieciowych ma nowe dane”). A to, który mechanizm zostanie użyty, zablokuje proces wywołujący na dramatycznie różne długości czasu.

Scheduling

Trzecią rzeczą, która jest krytyczna do naśladowania, jest to, co się dzieje, gdy masz wiele wątków lub procesów, które zaczynają blokować.

Dla naszych celów nie ma wielkiej różnicy między wątkiem a procesem. W prawdziwym życiu, najbardziej zauważalną różnicą związaną z wydajnością jest to, że ponieważ wątki dzielą tę samą pamięć, a procesy mają swoją własną przestrzeń pamięci, tworzenie oddzielnych procesów ma tendencję do zajmowania dużo więcej pamięci. Ale kiedy mówimy o planowaniu, to co naprawdę sprowadza się do listy rzeczy (wątki i procesy podobnie), które każdy musi dostać kawałek czasu wykonania na dostępnych rdzeni procesora. Jeśli masz 300 uruchomionych wątków i 8 rdzeni do ich uruchomienia, musisz podzielić czas tak, aby każdy z nich dostał swoją część, z każdym rdzeniem działającym przez krótki okres czasu, a następnie przejście do następnego wątku. Odbywa się to poprzez „przełączanie kontekstu”, dzięki czemu procesor przełącza się z uruchamiania jednego wątku/procesu na następny.

Te przełączniki kontekstu mają koszt z nimi związany – zajmują trochę czasu. W niektórych szybkich przypadkach może to być mniej niż 100 nanosekund, ale nierzadko zajmuje to 1000 nanosekund lub dłużej, w zależności od szczegółów implementacji, prędkości/architektury procesora, pamięci podręcznej procesora itp.

A im więcej wątków (lub procesów), tym więcej przełączania kontekstu. Kiedy mówimy o tysiącach wątków i setkach nanosekund dla każdego z nich, rzeczy mogą stać się bardzo powolne.

Jednakże połączenia nieblokujące w istocie mówią jądru „tylko zadzwoń do mnie, gdy masz jakieś nowe dane lub zdarzenie na jednym z tych połączeń”. Te nieblokujące wywołania są zaprojektowane do efektywnej obsługi dużych obciążeń I/O i zmniejszenia przełączania kontekstu.

With me so far? Ponieważ teraz nadchodzi część zabawy: Spójrzmy na to, co niektóre popularne języki robią z tymi narzędziami i wyciągnijmy wnioski na temat kompromisów między łatwością użycia a wydajnością… i inne ciekawe ciekawostki.

Jako uwaga, podczas gdy przykłady pokazane w tym artykule są trywialne (i częściowe, z tylko odpowiednimi bitami pokazanymi); dostęp do bazy danych, zewnętrzne systemy buforowania (memcache, et. all) i wszystko, co wymaga I/O, skończy się wykonywaniem pewnego rodzaju wywołań I/O pod maską, które będą miały taki sam efekt, jak pokazane proste przykłady. Ponadto, w scenariuszach, w których I/O jest opisywane jako „blokujące” (PHP, Java), odczyty i zapisy żądań i odpowiedzi HTTP są same w sobie wywołaniami blokującymi: Ponownie, więcej I/O ukrytych w systemie z jego towarzyszącymi problemami wydajności do wzięcia pod uwagę.

Jest wiele czynników, które idą do wyboru języka programowania dla projektu. Istnieje nawet wiele czynników, gdy bierzesz pod uwagę tylko wydajność. Ale jeśli obawiasz się, że twój program będzie ograniczony głównie przez I/O, jeśli wydajność I/O jest kluczowa dla twojego projektu, to są rzeczy, które musisz wiedzieć.

Podejście „Keep It Simple”: PHP

W latach 90-tych wielu ludzi nosiło buty Converse i pisało skrypty CGI w Perlu. Potem pojawił się PHP i, mimo że niektórzy ludzie lubią na niego psioczyć, sprawił, że tworzenie dynamicznych stron internetowych stało się dużo prostsze.

Model, którego używa PHP, jest dość prosty. Istnieją pewne jego odmiany, ale przeciętny serwer PHP wygląda następująco:

Ządanie HTTP pochodzi z przeglądarki użytkownika i trafia na serwer Apache. Apache tworzy osobny proces dla każdego żądania, z pewnymi optymalizacjami, aby ponownie ich użyć w celu zminimalizowania liczby procesów, które musi wykonać (tworzenie procesów jest, relatywnie rzecz biorąc, powolne).Apache wywołuje PHP i mówi mu, aby uruchomił odpowiedni .php plik na dysku.Kod PHP wykonuje się i wykonuje blokujące wywołania I/O. Wywołujesz file_get_contents() w PHP, a pod maską PHP wykonuje read() wywołań systemowych i czeka na wyniki.

I oczywiście rzeczywisty kod jest po prostu wbudowany w twoją stronę, a operacje są blokowane:

<?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, to wygląda to tak:

Bardzo proste: jeden proces na żądanie. Wywołania I/O po prostu blokują się. Zaleta? Jest prosty i działa. Wada? Uderz w niego z 20 000 klientów jednocześnie, a twój serwer stanie w płomieniach. To podejście nie skaluje się dobrze, ponieważ narzędzia dostarczane przez jądro do radzenia sobie z dużym wolumenem I/O (epoll, itp.) nie są używane. A żeby dodać obelgę do obrażeń, uruchamianie osobnego procesu dla każdego żądania zużywa dużo zasobów systemowych, zwłaszcza pamięci, która jest często pierwszą rzeczą, której brakuje w takim scenariuszu.

Uwaga: Podejście stosowane w Ruby jest bardzo podobne do podejścia stosowanego w PHP, i w szerokim, ogólnym, ręcznym sensie mogą być uważane za takie same dla naszych celów.

Podejście wielowątkowe: Java

Więc pojawia się Java, dokładnie w czasie, gdy kupiłeś swoją pierwszą nazwę domeny i fajnie było po prostu losowo powiedzieć „dot com” po zdaniu. A Java ma wielowątkowość wbudowaną w język, co (zwłaszcza w czasach, gdy została stworzona) jest całkiem niesamowite.

Większość serwerów WWW Javy działa poprzez rozpoczęcie nowego wątku wykonania dla każdego żądania, które przychodzi, a następnie w tym wątku ostatecznie wywołując funkcję, którą ty, jako twórca aplikacji, napisałeś.

Wykonywanie operacji wejścia/wyjścia w serwlecie Java ma tendencję do wyglądania jak:

public void doGet(HttpServletRequest request,HttpServletResponse response) throws ServletException, IOException{// blocking file I/OInputStream fileIs = new FileInputStream("/path/to/file");// blocking network I/OURLConnection urlConnection = (new URL("http://example.com/example-microservice")).openConnection();InputStream netIs = urlConnection.getInputStream();// some more blocking network I/Oout.println("...");}

Ponieważ nasza doGet metoda powyżej odpowiada jednemu żądaniu i jest uruchamiana w swoim własnym wątku, zamiast oddzielnego procesu dla każdego żądania, który wymaga własnej pamięci, mamy oddzielny wątek. Ma to kilka fajnych zalet, jak możliwość współdzielenia stanu, buforowanych danych, itp. pomiędzy wątkami, ponieważ mogą one uzyskać dostęp do pamięci każdego z nich, ale wpływ na to, jak oddziałuje to z harmonogramem jest wciąż prawie identyczny do tego, co jest robione w przykładzie PHP. Każde żądanie dostaje nowy wątek i różne operacje wejścia/wyjścia blokują się wewnątrz tego wątku, aż żądanie zostanie w pełni obsłużone. Wątki są łączone w celu zminimalizowania kosztów ich tworzenia i niszczenia, ale nadal tysiące połączeń oznacza tysiące wątków, co jest złe dla harmonogramu.

Ważnym kamieniem milowym jest to, że w wersji 1.4 Java (i znaczące uaktualnienie ponownie w 1.7) uzyskała zdolność do wykonywania nieblokujących wywołań we/wy. Większość aplikacji, internetowych i innych, nie używa tego, ale przynajmniej jest to dostępne. Niektóre serwery WWW Javy próbują to wykorzystać na różne sposoby; jednak zdecydowana większość wdrożonych aplikacji Javy nadal działa tak, jak opisano powyżej.

Java przybliża nas i z pewnością ma kilka dobrych funkcji out-of-the-box dla I/O, ale nadal nie rozwiązuje problemu tego, co się dzieje, gdy masz silnie związaną z I/O aplikację, która jest wbijana w ziemię przez wiele tysięcy blokujących wątków.

Non-blocking I/O as a First Class Citizen: Node

Popularnym dzieckiem w bloku, jeśli chodzi o lepsze I/O, jest Node.js. Każdy, kto miał choćby krótkie wprowadzenie do Node’a, usłyszał, że jest on „nieblokujący” i że obsługuje I/O wydajnie. I to jest prawda w ogólnym sensie. Ale diabeł tkwi w szczegółach, a środki, za pomocą których to czary zostały osiągnięte, mają znaczenie, jeśli chodzi o wydajność.

Podstawowo zmiana paradygmatu, którą implementuje Node, polega na tym, że zamiast zasadniczo mówić „napisz swój kod tutaj, aby obsłużyć żądanie”, zamiast tego mówią „napisz kod tutaj, aby rozpocząć obsługę żądania”. Za każdym razem, gdy musisz zrobić coś, co obejmuje I / O, wykonujesz żądanie i podajesz funkcję wywołania zwrotnego, którą Node wywoła, gdy skończy.

Typowy kod Node do wykonywania operacji I / O w żądaniu idzie tak:

http.createServer(function(request, response) {fs.readFile('/path/to/file', 'utf8', function(err, data) {response.end(data);});});

Jak widać, istnieją dwie funkcje wywołania zwrotnego tutaj. Pierwsza jest wywoływana, gdy rozpoczyna się żądanie, a druga jest wywoływana, gdy dane pliku są dostępne.

To, co to robi, to w zasadzie danie Węzłowi możliwości efektywnej obsługi operacji wejścia/wyjścia pomiędzy tymi wywołaniami zwrotnymi. Scenariusz, w którym byłoby to jeszcze bardziej istotne, to sytuacja, w której 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: uruchamiasz wywołanie bazy danych i przekazujesz Node’owi funkcję wywołania zwrotnego, wykonuje ona operacje wejścia / wyjścia osobno za pomocą wywołań nieblokujących, a następnie wywołuje twoją funkcję wywołania zwrotnego, gdy dane, o które prosiłeś, są dostępne. Ten mechanizm kolejkowania wywołań I/O i pozwalania Węzłowi na ich obsługę, a następnie uzyskiwania wywołania zwrotnego nazywa się „Pętlą zdarzeń”. I działa on całkiem dobrze.

Jest jednak pewien haczyk w tym modelu. Pod maską, powód tego ma o wiele więcej wspólnego z tym, jak silnik V8 JavaScript (silnik JS Chrome’a, który jest używany przez Node) jest zaimplementowany 1 niż cokolwiek innego. Kod JS, który piszesz, wszystko działa w pojedynczym wątku. Pomyśl o tym przez chwilę. Oznacza to, że podczas gdy I/O jest wykonywane przy użyciu wydajnych technik nieblokujących, twoja puszka JS, która wykonuje operacje związane z procesorem, działa w pojedynczym wątku, a każdy fragment kodu blokuje następny. Powszechnym przykładem, gdzie może się to pojawić, jest zapętlanie 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})};

While Node obsługuje I/O wydajnie, ta for pętla w powyższym przykładzie używa cykli CPU wewnątrz twojego jedynego głównego wątku. Oznacza to, że jeśli masz 10 000 połączeń, ta pętla może spowodować, że cała aplikacja zacznie się czołgać, w zależności od tego, jak długo to trwa. Każde żądanie musi dzielić kawałek czasu, jeden na raz, w twoim głównym wątku.

Założeniem, na którym opiera się cała ta koncepcja, jest to, że operacje wejścia/wyjścia są najwolniejszą częścią, dlatego najważniejsze jest, aby obsługiwać je wydajnie, nawet jeśli oznacza to wykonywanie innego przetwarzania szeregowo. Jest to prawda w niektórych przypadkach, ale nie we wszystkich.

Innym punktem jest to, że, i chociaż jest to tylko opinia, może być dość męczące pisanie mnóstwa zagnieżdżonych wywołań zwrotnych, a niektórzy twierdzą, że czyni to kod znacznie trudniejszym do naśladowania. Nierzadko można zobaczyć wywołania zwrotne zagnieżdżone na czterech, pięciu, a nawet więcej poziomach głęboko wewnątrz kodu Node.

Znowu wracamy do kompromisów. Model Node działa dobrze, jeśli twoim głównym problemem wydajnościowym jest I/O. Jednak jego piętą achillesową jest to, że możesz wejść do funkcji, która obsługuje żądanie HTTP i umieścić w niej kod intensywnie korzystający z procesora i doprowadzić każde połączenie do pełzania, jeśli nie jesteś ostrożny.

Naturalnie nieblokujący: Go

Zanim przejdę do sekcji dla Go, właściwe jest dla mnie ujawnienie, że jestem fanboyem Go. Używałem go w wielu projektach i jestem otwartym zwolennikiem jego zalet wydajnościowych, i widzę je w mojej pracy, kiedy go używam.

To powiedziawszy, spójrzmy na to, jak radzi sobie z I/O. Jedną z kluczowych cech języka Go jest to, że zawiera on swój własny algorytm szeregowania. Zamiast każdego wątku wykonania odpowiadającego pojedynczemu wątkowi OS, działa on z koncepcją „goroutines”. I runtime Go może przypisać goroutine do wątku OS i kazać mu wykonywać, lub zawiesić go i kazać mu nie być związanym z wątkiem OS, w oparciu o to, co robi ten goroutine. Każde żądanie, które przychodzi z serwera HTTP Go jest obsługiwane w oddzielnej goroutine.

Schemat działania schedulera wygląda tak:

Pod maską, jest to realizowane przez różne punkty w runtime Go, które implementują wywołanie I/O przez wykonanie żądania zapisu/odczytu/połączenia/etc., usypiają bieżącą goroutine, z informacją, aby obudzić goroutine z powrotem, gdy można podjąć dalsze działania.

W efekcie, runtime Go robi coś nie strasznie niepodobnego do tego, co robi Node, z wyjątkiem tego, że mechanizm wywołania zwrotnego jest wbudowany w implementację wywołania I/O i współdziała z harmonogramem automatycznie. Nie cierpi również z powodu ograniczenia konieczności posiadania całego kodu obsługi uruchomionego w tym samym wątku, Go automatycznie mapuje twoje Goroutines do tylu wątków OS, ile uzna za stosowne w oparciu o logikę w swoim harmonogramie. Rezultatem jest kod taki jak ten:

func ServeHTTP(w http.ResponseWriter, r *http.Request) {// the underlying network call here is non-blockingrows, 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ście, a mimo to osiąga nieblokujące I/O pod maską.

W większości przypadków kończy się to „najlepszym z obu światów”. Non-blocking I / O jest używany do wszystkich ważnych rzeczy, ale twój kod wygląda tak, jakby był blokowany, a zatem ma tendencję do bycia prostszym do zrozumienia i utrzymania. Interakcja między harmonogramem Go a harmonogramem systemu operacyjnego obsługuje resztę. Nie jest to kompletna magia, a jeśli budujesz duży system, warto poświęcić czas, aby zrozumieć więcej szczegółów na temat tego, jak to działa; ale w tym samym czasie środowisko, które otrzymujesz „out-of-the-box” działa i skaluje się całkiem dobrze.

Go może mieć swoje wady, ale ogólnie rzecz biorąc, sposób, w jaki obsługuje I / O nie jest wśród nich.

Kłamstwa, przeklęte kłamstwa i benchmarki

Trudno jest podać dokładne czasy na przełączanie kontekstu związane z tymi różnymi modelami. Mógłbym również argumentować, że jest to mniej przydatne dla ciebie. Zamiast tego podam kilka podstawowych benchmarków, które porównują ogólną wydajność serwera HTTP w tych środowiskach serwerowych. Należy pamiętać, że w wydajność całej ścieżki żądania/odpowiedzi HTTP zaangażowanych jest wiele czynników, a liczby przedstawione tutaj to tylko kilka próbek, które zebrałem razem, aby dać podstawowe porównanie.

Dla każdego z tych środowisk napisałem odpowiedni kod, aby wczytać plik 64k z losowymi bajtami, wykonać na nim hash SHA-256 N razy (N jest określone w ciągu zapytania URL, np. .../test.php?n=100) i wydrukować wynikowy hash w formacie heksadecymalnym. Wybrałem to, ponieważ jest to bardzo prosty sposób na uruchomienie tych samych benchmarków z pewnym spójnym I/O i kontrolowanym sposobem na zwiększenie użycia CPU.

Zobacz notatki do benchmarków, aby uzyskać nieco więcej szczegółów na temat użytych środowisk.

Po pierwsze, spójrzmy na kilka przykładów o niskiej współbieżności. Wykonanie 2000 iteracji z 300 współbieżnymi żądaniami i tylko jednym haszem na żądanie (N=1) daje nam następujące wyniki:

Czasy są średnią liczbą milisekund potrzebnych do ukończenia żądania dla wszystkich współbieżnych żądań. Niżej jest lepiej.

Ciężko jest wyciągnąć wniosek z tego jednego wykresu, ale wydaje mi się, że przy tej ilości połączeń i obliczeń, widzimy czasy, które mają więcej wspólnego z ogólnym wykonaniem samych języków, o wiele bardziej niż I/O. Zauważ, że języki, które są uważane za „języki skryptowe” (luźne typowanie, dynamiczna interpretacja) wykonują się najwolniej.

Ale co się stanie, jeśli zwiększymy N do 1000, wciąż z 300 równoczesnymi żądaniami – to samo obciążenie, ale 100x więcej iteracji hash (znacznie większe obciążenie procesora):

Czasy są średnią liczbą milisekund do ukończenia żądania przez wszystkie równoczesne żądania. Niższy czas oznacza lepszy.

Nagle wydajność Node’a znacząco spada, ponieważ operacje wymagające CPU w każdym żądaniu blokują się wzajemnie. Co ciekawe, wydajność PHP jest znacznie lepsza (w stosunku do pozostałych) i bije na głowę Javę w tym teście. (Warto zauważyć, że w PHP implementacja SHA-256 jest napisana w C i ścieżka wykonania spędza dużo więcej czasu w tej pętli, ponieważ wykonujemy teraz 1000 iteracji hasha).

Teraz spróbujmy 5000 równoczesnych połączeń (z N=1) – lub tak blisko jak tylko mogłem. Niestety, dla większości z tych środowisk wskaźnik awaryjności nie był nieistotny. Dla tego wykresu, będziemy patrzeć na całkowitą liczbę żądań na sekundę. Im wyższy tym lepiej:

Całkowita liczba żądań na sekundę. Wyżej jest lepiej.

A obraz wygląda zupełnie inaczej. Jest to tylko przypuszczenie, ale wygląda na to, że przy dużej ilości połączeń, narzut na połączenie związany z tworzeniem nowych procesów i dodatkową pamięcią związaną z tym w PHP+Apache staje się czynnikiem dominującym i obniża wydajność PHP. Wyraźnie widać, że Go jest tutaj zwycięzcą, następnie Java, Node i w końcu PHP.

Pomimo, że czynników związanych z ogólną wydajnością jest wiele i różnią się one w zależności od aplikacji, im więcej rozumiesz o tym, co dzieje się pod maską i o kompromisach z tym związanych, tym lepiej będziesz się czuł.

Podsumowanie

W związku z powyższym, jest całkiem jasne, że wraz z ewolucją języków, rozwiązania do radzenia sobie z aplikacjami na dużą skalę, które wykonują dużo operacji we/wy, ewoluowały wraz z nimi.

By być uczciwym, zarówno PHP jak i Java, pomimo opisów w tym artykule, mają implementacje nieblokujących operacji we/wy dostępnych do użycia w aplikacjach internetowych. Nie są one jednak tak powszechne jak opisane powyżej, a koszty operacyjne związane z utrzymaniem serwerów wykorzystujących takie podejście muszą być 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 PHP lub Java zwykle nie będzie działać bez znaczących modyfikacji w takim środowisku.

Jako porównanie, jeśli weźmiemy pod uwagę kilka znaczących czynników, które wpływają na wydajność, jak również łatwość użycia, otrzymamy to:

.

Język Wątki vs. Processes Non-blokowanie I/O Łatwość użycia
PHP Procesy Nie
Java Wątki Dostępne Wymaga Callbacków
Node. No Callbacks Needed

Wątki generalnie będą znacznie bardziej wydajne pamięciowo niż procesy, ponieważ dzielą tę samą przestrzeń pamięci, podczas gdy procesy nie. Łącząc to z czynnikami związanymi z nieblokującym I/O, możemy zauważyć, że przynajmniej w przypadku czynników rozważanych powyżej, gdy przesuniemy się w dół listy, ogólna konfiguracja związana z I/O ulega poprawie. Tak więc, gdybym miał wybrać zwycięzcę w powyższym konkursie, z pewnością byłoby to Go.

Nawet w praktyce, wybór środowiska, w którym chcesz zbudować swoją aplikację, jest ściśle związany ze znajomością tego środowiska przez twój zespół i ogólną produktywnością, jaką możesz dzięki niemu osiągnąć. Dlatego nie dla każdego zespołu sensowne może być po prostu zanurzenie się i rozpoczęcie tworzenia aplikacji internetowych i usług w Node lub Go. Rzeczywiście, znalezienie programistów lub obycie wewnętrznego zespołu jest często podawane jako główny powód, aby nie używać innego języka i/lub środowiska. To powiedziawszy, czasy zmieniły się w ciągu ostatnich piętnastu lat lub tak, dużo.

Mam nadzieję, że powyższe pomaga namalować jaśniejszy obraz tego, co dzieje się pod maską i daje ci kilka pomysłów na to, jak radzić sobie ze skalowalnością w świecie rzeczywistym dla twojej aplikacji. Szczęśliwego wprowadzania i wyprowadzania danych!

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.