Serveroldali I/O teljesítmény: Node vs. PHP vs. Java vs. Go

okt 22, 2021
admin

Az alkalmazás bemeneti/kimeneti (I/O) modelljének megismerése jelentheti a különbséget egy olyan alkalmazás között, amely megbirkózik a rá nehezedő terheléssel, és egy olyan között, amely összeroppan a valós felhasználási esetek előtt. Lehet, hogy amíg az Ön alkalmazása kicsi, és nem szolgál ki nagy terhelést, ez sokkal kevésbé számít. De ahogy az alkalmazás forgalmi terhelése növekszik, a rossz I/O-modellel való munka nagy bajba kerülhet.

És mint a legtöbb olyan helyzetben, ahol többféle megközelítés lehetséges, itt sem csak az a kérdés, hogy melyik a jobb, hanem az is, hogy megértsük a kompromisszumokat. Tegyünk egy sétát az I/O tájon, és nézzük meg, mit kémlelhetünk ki.

Ebben a cikkben összehasonlítjuk a Node-ot, a Java-t, a Go-t és a PHP-t az Apache-csal, megvitatjuk, hogyan modellezik a különböző nyelvek az I/O-t, az egyes modellek előnyeit és hátrányait, és néhány kezdetleges benchmarkkal zárjuk. Ha aggódsz a következő webes alkalmazásod I/O teljesítménye miatt, ez a cikk neked szól.

I/O alapok: A Quick Refresher

Az I/O-val kapcsolatos tényezők megértéséhez először az operációs rendszer szintjén kell áttekinteni a fogalmakat. Bár nem valószínű, hogy ezek közül a fogalmak közül többel közvetlenül kell foglalkoznunk, közvetve, az alkalmazásunk futási környezetén keresztül állandóan foglalkozunk velük. És a részletek számítanak.

Rendszerhívások

Először is, itt vannak a rendszerhívások, amelyek a következőképpen írhatók le:

  • A programunknak (a “felhasználó földjén”, ahogy mondani szokták) meg kell kérnie az operációs rendszer kernelét, hogy hajtson végre egy I/O műveletet a nevében.
  • A “syscall” az az eszköz, amellyel a programunk megkéri a kernelt, hogy csináljon valamit. Ennek megvalósításának sajátosságai operációs rendszerenként eltérőek, de az alapkoncepció ugyanaz. Lesz valamilyen speciális utasítás, ami átadja a vezérlést a programodtól a kernelnek (mint egy függvényhívás, de némi speciális mártással, kifejezetten erre a helyzetre). Általában a syscallok blokkoló jellegűek, ami azt jelenti, hogy a programunk megvárja, amíg a kernel visszatér a kódunkhoz.
  • A kernel elvégzi az alapul szolgáló I/O műveletet a kérdéses fizikai eszközön (lemez, hálózati kártya stb.), és válaszol a syscallra. A való világban a kernelnek számos dolgot meg kell tennie a kérésed teljesítéséhez, beleértve a várakozást, hogy az eszköz készen álljon, a belső állapotának frissítését stb. de alkalmazásfejlesztőként ez téged nem érdekel. Ez a kernel feladata.

Blokkoló vs. nem blokkoló hívások

Most, fentebb azt mondtam, hogy a syscallok blokkolóak, és ez általános értelemben igaz is. Néhány hívás azonban a “nem blokkoló” kategóriába tartozik, ami azt jelenti, hogy a kernel átveszi a kérésedet, elhelyezi valahol a sorba vagy a pufferbe, majd azonnal visszatér, anélkül, hogy megvárná a tényleges I/O végrehajtását. Tehát csak egy nagyon rövid ideig “blokkol”, épp elég ideig ahhoz, hogy a kérésedet sorba állítsa.

Néhány példa (Linux syscallokra) segíthet a tisztánlátásban:- A read() egy blokkoló hívás – átadsz neki egy fogantyút, ami megmondja, hogy melyik fájlt és egy puffert, ahová az olvasott adatokat szállítsa, és a hívás akkor tér vissza, amikor az adatok ott vannak. Megjegyzendő, hogy ennek az az előnye, hogy szép és egyszerű.- A epoll_create(), epoll_ctl() és epoll_wait() olyan hívások, amelyekkel létrehozhatsz egy csoport handle-t, amelyre figyelni fogsz, hozzáadhatsz/eltávolíthatsz kezelőket a csoportból, majd blokkolhatsz, amíg nincs aktivitás. Ez lehetővé teszi, hogy nagyszámú I/O műveletet hatékonyan irányítsunk egyetlen szál segítségével, de most előreszaladtam. Ez nagyszerű, ha szükséged van erre a funkcionalitásra, de mint láthatod, biztosan bonyolultabb a használata.

Nagyon fontos megérteni, hogy az időzítésben mekkora nagyságrendi különbség van itt. Ha egy CPU mag 3GHz-en fut, anélkül, hogy belemennénk az optimalizációkba, amire a CPU képes, akkor 3 milliárd ciklust végez másodpercenként (vagy 3 ciklust nanoszekundumonként). Egy nem blokkoló rendszerhívás befejezése 10-es nagyságrendű ciklusokat vehet igénybe – vagy “viszonylag néhány nanoszekundumot”. Egy olyan hívás, amely blokkolja a hálózaton keresztül érkező információkat, sokkal hosszabb időt vehet igénybe – mondjuk például 200 milliszekundumot (1/5 másodperc). És tegyük fel, hogy például a nem blokkoló hívás 20 nanoszekundumot vett igénybe, a blokkoló hívás pedig 200 000 000 nanoszekundumot. A folyamatod éppen 10 milliószor tovább várt a blokkoló hívásra.

A kernel biztosítja mind a blokkoló I/O (“olvass be erről a hálózati kapcsolatról, és add meg nekem az adatokat”), mind a nem blokkoló I/O (“mondd meg, ha bármelyik hálózati kapcsolaton új adatok vannak”) végrehajtását. És hogy melyik mechanizmust használjuk, az drámaian különböző ideig fogja blokkolni a hívó folyamatot.

Tervezés

A harmadik dolog, amit kritikusan kell követnünk, az az, hogy mi történik, ha sok szál vagy folyamat kezd el blokkolni.

A mi céljaink szempontjából nincs nagy különbség a szál és a folyamat között. A valós életben a teljesítmény szempontjából a leginkább észrevehető különbség az, hogy mivel a szálak ugyanazt a memóriát használják, a folyamatok pedig mindegyike saját memóriaterülettel rendelkezik, a különálló folyamatok létrehozása általában sokkal több memóriát foglal el. De amikor az ütemezésről beszélünk, akkor ez valójában egy olyan lista, amiben olyan dolgok (szálak és folyamatok egyaránt) szerepelnek, amelyek mindegyikének szüksége van egy-egy szeletnyi végrehajtási időre a rendelkezésre álló CPU-magokon. Ha 300 szál fut, és 8 magon futtatjuk őket, akkor úgy kell elosztani az időt, hogy mindegyik megkapja a maga részét, és minden mag rövid ideig fut, majd áttér a következő szálra. Ez “kontextusváltással” történik, azaz a CPU átvált az egyik szál/folyamat futtatásáról a másikra.

Ezeknek a kontextusváltásoknak ára van – időbe telik. Néhány gyors esetben ez kevesebb, mint 100 nanoszekundum lehet, de nem ritka, hogy 1000 nanoszekundumot vagy annál hosszabb időt vesz igénybe, a megvalósítás részleteitől, a processzor sebességétől/architektúrájától, a CPU gyorsítótárától stb. függően.

És minél több szál (vagy folyamat), annál több a kontextusváltás. Amikor több ezer szálról beszélünk, és több száz nanoszekundumról mindegyikhez, a dolgok nagyon lassúvá válhatnak.

A nem blokkoló hívások azonban lényegében azt mondják a kernelnek, hogy “csak akkor hívj, ha van valami új adat vagy esemény ezen kapcsolatok valamelyikén”. Ezeket a nem blokkoló hívásokat úgy tervezték, hogy hatékonyan kezeljék a nagy I/O terhelést és csökkentsék a kontextusváltást.

Mivel eddig? Mert most jön a szórakoztató rész: Nézzük meg, mit csinál néhány népszerű nyelv ezekkel az eszközökkel, és vonjunk le néhány következtetést a könnyű használhatóság és a teljesítmény közötti kompromisszumokról… és egyéb érdekességekről.

Megjegyzem, hogy bár a cikkben bemutatott példák triviálisak (és részlegesek, csak a lényeges részeket mutatják); az adatbázishoz való hozzáférés, a külső gyorsítótárazási rendszerek (memcache, stb.) és bármi, ami I/O-t igényel, a végén valamilyen I/O hívást fog végrehajtani a motorháztető alatt, aminek ugyanaz lesz a hatása, mint a bemutatott egyszerű példáknak. Azokban az esetekben, ahol az I/O “blokkoló” (PHP, Java), a HTTP-kérés és -válasz olvasása és írása maga is blokkoló hívás: Ismét több, a rendszerben rejtett I/O-t kell figyelembe venni, az ezzel járó teljesítményproblémákkal együtt.

Egy projekt programozási nyelvének kiválasztásakor sok tényezőt kell figyelembe venni. Még akkor is sok tényező van, ha csak a teljesítményt vesszük figyelembe. De ha aggódik amiatt, hogy a programját elsősorban az I/O fogja korlátozni, ha az I/O teljesítménye döntő fontosságú a projektje számára, akkor ezeket a dolgokat tudnia kell.

A “Keep It Simple” megközelítés: PHP

A 90-es években sokan Converse cipőt hordtak és CGI szkripteket írtak Perl nyelven. Aztán jött a PHP, és bármennyire is szeretik egyesek szidni, sokkal egyszerűbbé tette a dinamikus weboldalak készítését.

A PHP által használt modell meglehetősen egyszerű. Van néhány variációja, de egy átlagos PHP-kiszolgáló így néz ki:

Egy HTTP-kérés érkezik a felhasználó böngészőjéből, és eléri az Apache webszerverét. Az Apache minden egyes kéréshez külön folyamatot hoz létre, némi optimalizálással, hogy újra felhasználja őket annak érdekében, hogy minimalizálja, hogy hányat kell csinálnia (a folyamatok létrehozása viszonylag lassú).Az Apache meghívja a PHP-t, és megmondja neki, hogy futtassa a megfelelő .php fájlt a lemezen.A PHP kód végrehajtódik, és blokkoló I/O hívásokat hajt végre. Meghívod a file_get_contents() PHP-t, és a motorháztető alatt read() syscallokat hajt végre, és várja az eredményeket.

És persze a tényleges kód egyszerűen be van ágyazva közvetlenül az oldaladba, és a műveletek blokkolóak:

<?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');?>

A rendszerbe való integráció szempontjából ez így néz ki:

Egyszerű: kérésenként egy folyamat. Az I/O hívások csak blokkolnak. Előny? Egyszerű és működik. Hátránya? Üsd meg 20 000 klienssel egyidejűleg, és a szervered lángba fog borulni. Ez a megközelítés nem skálázható jól, mert a kernel által a nagy mennyiségű I/O kezelésére biztosított eszközöket (epoll stb.) nem használják. És hogy tetézzük a bajt azzal, hogy minden egyes kérésre külön folyamatot futtatunk, ami hajlamos sok rendszererőforrást használni, különösen a memóriát, ami gyakran az első dolog, ami egy ilyen forgatókönyvben elfogy.

Megjegyzés: A Ruby esetében alkalmazott megközelítés nagyon hasonlít a PHP megközelítéséhez, és tág, általános, kézzelfogható módon a mi céljaink szempontjából azonosnak tekinthetők.

A többszálú megközelítés: Java

Szóval jön a Java, nagyjából akkor, amikor megvetted az első domain nevedet, és menő volt csak úgy véletlenszerűen “dot com”-ot mondani egy mondat után. A Java pedig többszálúságot épített be a nyelvbe, ami (különösen ahhoz képest, hogy mikor jött létre) elég félelmetes.

A legtöbb Java webszerver úgy működik, hogy minden egyes beérkező kérésre új végrehajtási szálat indít, majd ebben a szálban végül meghívja azt a függvényt, amit te, mint az alkalmazás fejlesztője írtál.

Az I/O végrehajtása egy Java Servletben általában valahogy így néz ki:

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("...");}

Mivel a fenti doGet módszerünk egy kérésnek felel meg, és a saját szálában fut, ahelyett, hogy minden egyes kérésnek külön folyamat lenne, amely saját memóriát igényel, külön szálunk van. Ennek van néhány szép előnye, például hogy megoszthatjuk az állapotot, a gyorsítótárazott adatokat stb. a szálak között, mivel hozzáférhetnek egymás memóriájához, de az ütemezéssel való interakcióra gyakorolt hatása még mindig szinte azonos azzal, amit a korábbi PHP-példában csináltunk. Minden egyes kérés egy új szálat kap, és a különböző I/O műveletek ezen a szálon belül blokkolódnak, amíg a kérés teljes kezelése meg nem történik. A szálak összevonásra kerülnek, hogy minimalizálják a létrehozásuk és megsemmisítésük költségeit, de még így is több ezer kapcsolat több ezer szálat jelent, ami rossz az ütemező számára.

Egy fontos mérföldkő, hogy az 1.4-es verzióban a Java (és egy jelentős frissítéssel ismét az 1.7-ben) megkapta a nem blokkoló I/O-hívások képességét. A legtöbb alkalmazás, webes és egyéb, nem használja, de legalább elérhető. Néhány Java webkiszolgáló különböző módokon próbálja ezt kihasználni; a telepített Java-alkalmazások túlnyomó többsége azonban továbbra is a fent leírtak szerint működik.

A Java közelebb visz minket, és minden bizonnyal van néhány jó out-of-the-box funkciója az I/O-hoz, de még mindig nem igazán oldja meg azt a problémát, hogy mi történik, ha egy erősen I/O-köteles alkalmazásunk van, amelyet sok ezer blokkoló szál ver a földbe.

A nem blokkoló I/O mint első osztályú polgár:

A népszerű gyerek a blokkban, ha a jobb I/O-ról van szó, a Node.js. Bárki, aki akár csak röviden is megismerkedett a Node-dal, azt hallotta, hogy az “nem blokkoló”, és hogy hatékonyan kezeli az I/O-t. És ez általános értelemben igaz is. De az ördög a részletekben rejlik, és az eszközök, amelyekkel ezt a boszorkányságot megvalósították, számítanak, amikor a teljesítményről van szó.

A Node által megvalósított paradigmaváltás lényegében az, hogy ahelyett, hogy lényegében azt mondanák, hogy “írd ide a kódodat, hogy kezeld a kérést”, inkább azt mondják, hogy “írj ide kódot, hogy kezdd el kezelni a kérést”. Minden alkalommal, amikor valami olyat kell tenned, ami I/O-t tartalmaz, elkészíted a kérést, és megadsz egy visszahívási függvényt, amelyet a Node meghív, amikor végzett.

A tipikus Node kód egy I/O művelet elvégzésére egy kérésben így néz ki:

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

Amint láthatod, itt két visszahívási függvény van. Az első akkor hívódik meg, amikor a kérés elindul, a második pedig akkor, amikor a fájl adatai elérhetővé válnak.

Az, amit ez tesz, alapvetően az, hogy lehetőséget ad a Node-nak arra, hogy hatékonyan kezelje az I/O-t ezen visszahívások között. Egy forgatókönyv, ahol ez még relevánsabb lenne, az az, amikor egy adatbázis-hívást végzel a Node-ban, de nem fogok a példával foglalkozni, mert ez pontosan ugyanaz az elv: elindítod az adatbázis-hívást, és adsz a Node-nak egy callback függvényt, az külön-külön hajtja végre az I/O műveleteket nem blokkoló hívásokkal, majd meghívja a callback függvényedet, amikor a kért adat elérhetővé válik. Ezt a mechanizmust, hogy sorba állítjuk az I/O-hívásokat, és hagyjuk, hogy a Node kezelje, majd megkapjuk a visszahívást, “eseményhuroknak” nevezzük. És elég jól működik.

A modellnek azonban van egy buktatója. A motorháztető alatt ennek oka sokkal inkább azzal függ össze, hogy a V8 JavaScript motor (a Chrome JS motorja, amelyet a Node használ) hogyan van implementálva 1, mint bármi mással. A JS kód, amit írsz, mind egyetlen szálban fut. Gondoljon erre egy pillanatra. Ez azt jelenti, hogy miközben az I/O hatékony, nem blokkoló technikákkal történik, a CPU-kötött műveleteket végző JS-kódod egyetlen szálban fut, és minden kódrészlet blokkolja a következőt. Egy gyakori példa, ahol ez felmerülhet, az adatbázis rekordok átfutása, hogy valamilyen módon feldolgozza őket, mielőtt kiadná őket az ügyfélnek. Íme egy példa, amely megmutatja, hogyan működik ez:

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})};

Míg a Node hatékonyan kezeli az I/O-t, a fenti példában szereplő for ciklus CPU-ciklusokat használ az egyetlen fő szálon belül. Ez azt jelenti, hogy ha 10 000 kapcsolatod van, akkor ez a ciklus az egész alkalmazásodat leállíthatja, attól függően, hogy mennyi ideig tart. Minden egyes kérésnek egy-egy szelet időn kell osztoznia a főszáladban.

Az egész koncepció alapja az, hogy az I/O műveletek a leglassabbak, ezért a legfontosabb, hogy ezeket hatékonyan kezeljük, még akkor is, ha ez azt jelenti, hogy a többi feldolgozást sorozatosan kell elvégezni. Ez bizonyos esetekben igaz, de nem minden esetben.

A másik pont az, hogy – és bár ez csak egy vélemény – elég fárasztó lehet egy csomó egymásba ágyazott callback-et írni, és egyesek szerint ez jelentősen megnehezíti a kód követhetőségét. Nem ritka, hogy a Node kódban négy, öt vagy még több szint mélyen egymásba ágyazott visszahívásokat látunk.

Ismét visszatértünk a kompromisszumokhoz. A Node modell jól működik, ha a fő teljesítményprobléma az I/O. Azonban az achilles sarka az, hogy ha nem vigyázol, bemehetsz egy HTTP-kérdést kezelő függvénybe, és CPU-intenzív kódot rakhatsz bele, és minden kapcsolatot leállíthatsz.

Naturally Non-blocking: Go

Mielőtt rátérnék a Go-ra vonatkozó részre, illik elárulnom, hogy Go fanboy vagyok. Sok projektben használtam már, és nyíltan kiállok a termelékenységi előnyei mellett, és látom is őket a munkámban, amikor használom.

Ezzel együtt nézzük meg, hogyan kezeli az I/O-t. A Go nyelv egyik legfontosabb jellemzője, hogy saját ütemezőt tartalmaz. Ahelyett, hogy minden egyes végrehajtási szál egyetlen OS szálnak felelne meg, a “goroutine-ok” koncepciójával dolgozik. A Go futási ideje pedig képes egy goroutine-t egy OS szálhoz rendelni, és végrehajtani, vagy felfüggeszteni, és nem társítani egy OS szálhoz, attól függően, hogy az adott goroutine mit csinál. Minden egyes kérést, amely a Go HTTP-kiszolgálójától érkezik, egy külön goroutine kezel.

Az ütemező működésének diagramja így néz ki:

A motorháztető alatt ezt a Go futásidő különböző pontjai valósítják meg, amelyek az I/O-hívást valósítják meg az írás/olvasás/csatlakozás/stb. kérésekkel, az aktuális goroutine-t alvó állapotba helyezik, azzal az információval, hogy a goroutine-t újra felébresztik, amikor további műveletet lehet végrehajtani.

A Go futásidő valójában valami olyasmit csinál, ami nem nagyon különbözik attól, amit a Node csinál, kivéve, hogy a callback mechanizmus az I/O hívás implementációjába van beépítve, és automatikusan interakcióba lép az ütemezővel. Nem szenved attól a korlátozástól sem, hogy az összes kezelő kódodat ugyanabban a szálban kell futtatnod, a Go automatikusan annyi OS szálhoz rendeli hozzá a Goroutine-okat, amennyit az ütemező logikája alapján megfelelőnek ítél. Az eredmény egy ilyen kód lesz:

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}

Amint fentebb láthatjuk, az alapvető kódszerkezet, amit csinálunk, hasonlít az egyszerűbb megközelítésekhez, és mégis blokkolásmentes I/O-t ér el a motorháztető alatt.

A legtöbb esetben ez végül “a két világ legjobbja”. A nem blokkoló I/O-t minden fontos dologra használjuk, de a kódunk úgy néz ki, mintha blokkoló lenne, és így általában egyszerűbb megérteni és karbantartani. A Go ütemező és az operációs rendszer ütemezője közötti interakció a többit megoldja. Ez nem teljes varázslat, és ha egy nagy rendszert építesz, érdemes időt szánnod arra, hogy részletesebben megértsd, hogyan működik; ugyanakkor a környezet, amit “out-of-the-box” kapsz, működik és elég jól skálázódik.

A Go-nak lehetnek hibái, de általában véve az I/O kezelésének módja nem tartozik ezek közé.

Lies, Damned Lies and Benchmarks

Nehéz pontos időbeosztást adni a különböző modellekkel járó kontextusváltásra. Azzal is érvelhetnék, hogy ez kevésbé hasznos számodra. Ezért ehelyett adok néhány alapvető benchmarkot, amelyek összehasonlítják ezeknek a kiszolgálói környezeteknek az általános HTTP-kiszolgálói teljesítményét. Ne feledje, hogy a teljes végponttól-végpontig tartó HTTP-kérés-válasz útvonal teljesítményében számos tényező játszik szerepet, és az itt bemutatott számok csak néhány minta, amelyeket az alapvető összehasonlítás érdekében állítottam össze.

Mindegyik környezethez megírtam a megfelelő kódot, amely beolvas egy 64k méretű, véletlenszerű bájtokat tartalmazó fájlt, N alkalommal lefuttat rajta egy SHA-256 hash-t (N az URL lekérdezési karakterláncában van megadva, pl. .../test.php?n=100), és a kapott hash-t hexában kiírja. Azért választottam ezt, mert ez egy nagyon egyszerű módja annak, hogy ugyanazokat a benchmarkokat futtassuk némi konzisztens I/O-val és egy ellenőrzött módon növeljük a CPU-használatot.

A használt környezetekről részletesebben lásd ezeket a benchmark jegyzeteket.

Először nézzünk meg néhány alacsony párhuzamosságú példát. Ha 2000 iterációt futtatunk 300 egyidejű kéréssel és kérésenként csak egy hash-sel (N=1), akkor ezt kapjuk:

Az idők az összes egyidejű kérés teljesítéséhez szükséges milliszekundumok átlagos száma. Az alacsonyabb jobb.

Nehéz ebből az egy grafikonból következtetést levonni, de számomra úgy tűnik, hogy ilyen mennyiségű kapcsolat és számítás esetén olyan időket látunk, amelyek inkább maguknak a nyelveknek az általános végrehajtásához kapcsolódnak, sokkal inkább, mint az I/O-hoz. Vegyük észre, hogy a “szkriptnyelveknek” minősülő nyelvek (laza tipizálás, dinamikus értelmezés) teljesítenek a leglassabban.

De mi történik, ha N-t 1000-re növeljük, még mindig 300 egyidejű kéréssel – ugyanaz a terhelés, de 100-szor több hash-iteráció (jelentősen nagyobb CPU-terhelés):

Az idők az összes egyidejű kérés teljesítéséhez szükséges átlagos ezredmásodpercek száma. Az alacsonyabb jobb.

A Node teljesítménye hirtelen jelentősen csökken, mivel az egyes kérésekben szereplő CPU-intenzív műveletek blokkolják egymást. És érdekes módon a PHP teljesítménye sokkal jobb lesz (a többiekhez képest), és ebben a tesztben megveri a Javát. (Érdemes megjegyezni, hogy a PHP-ben az SHA-256 implementáció C-ben van megírva, és a végrehajtási útvonal sokkal több időt tölt abban a ciklusban, mivel most 1000 hash iterációt végzünk).

Most próbáljuk ki az 5000 egyidejű kapcsolatot (N=1-el) – vagy amennyire csak tudtam. Sajnos a legtöbb ilyen környezetben a hibaarány nem volt elhanyagolható. Ehhez a diagramhoz a másodpercenkénti összes kérésszámot nézzük. Minél magasabb, annál jobb:

A másodpercenkénti kérések teljes száma. A magasabb a jobb.

És a kép egészen másképp néz ki. Ez csak találgatás, de úgy tűnik, hogy nagy kapcsolatszám esetén a PHP+Apache esetében az új folyamatok létrehozásával járó kapcsolatonkénti többletköltség és az ehhez kapcsolódó többletmemória domináns tényezővé válik, és megnehezíti a PHP teljesítményét. Itt egyértelműen a Go a győztes, majd a Java, a Node és végül a PHP következik.

Míg az általános teljesítményt befolyásoló tényezők sokfélék, és alkalmazásonként is nagyon eltérőek, minél többet tudsz a motorháztető alatt zajló folyamatokról és a kompromisszumokról, annál jobban jársz.

Összefoglalva

A fentiek alapján elég egyértelmű, hogy a nyelvek fejlődésével együtt fejlődtek a sok I/O-t végző nagyméretű alkalmazások kezelésére szolgáló megoldások is.

Az igazsághoz tartozik, hogy a PHP és a Java a cikkben leírtak ellenére is rendelkezik a webes alkalmazásokban használható, nem blokkoló I/O implementációkkal. Ezek azonban nem olyan elterjedtek, mint a fent leírt megközelítések, és figyelembe kellene venni az ilyen megközelítéseket használó kiszolgálók fenntartásával járó működési többletköltséget. Arról nem is beszélve, hogy a kódot úgy kell strukturálni, hogy az ilyen környezetekkel működjön; a “normál” PHP vagy Java webalkalmazás általában nem fog jelentős módosítások nélkül futni egy ilyen környezetben.

Hasonlításképpen, ha figyelembe veszünk néhány jelentős tényezőt, amelyek befolyásolják a teljesítményt, valamint a könnyű használatot, akkor ezt kapjuk:

Nyelv Futamok vs. Folyamatok Nem-blokkoló I/O Egyszerű használat
PHP Folyamatok Nem
Java Threads Available Requires Callbacks
Node.js Threads Yes Requires Callbacks
Go Threads (Goroutines) Yes No Callbacks Needed

A futamok általában sokkal memóriahatékonyabbak lesznek, mint a folyamatok, mivel ugyanazt a memóriaterületet osztják meg, míg a folyamatok nem. Ezt kombinálva a nem blokkoló I/O-val kapcsolatos tényezőkkel, láthatjuk, hogy legalábbis a fent figyelembe vett tényezőkkel, ahogy haladunk lefelé a listán, úgy javul az általános beállítás az I/O-val kapcsolatban. Ha tehát győztest kellene választanom a fenti versenyben, az minden bizonnyal a Go lenne.

A gyakorlatban azonban a környezet kiválasztása, amelyben az alkalmazást építjük, szorosan összefügg azzal, hogy a csapat mennyire ismeri az adott környezetet, és milyen általános termelékenységet tudunk elérni vele. Tehát nem biztos, hogy minden csapat számára van értelme csak úgy belevetni magát, és elkezdeni webes alkalmazások és szolgáltatások fejlesztését Node-ban vagy Go-ban. Sőt, gyakran a fejlesztők megtalálása vagy a házon belüli csapat ismertsége a fő ok, amiért nem használnak más nyelvet és/vagy környezetet. Ennek ellenére az elmúlt körülbelül tizenöt évben sokat változtak az idők.”

Remélhetőleg a fentiek segítenek tisztább képet festeni arról, hogy mi történik a motorháztető alatt, és adnak néhány ötletet arra vonatkozóan, hogyan kezelje a valós skálázhatóságot az alkalmazása számára. Boldog be- és kiadást!

Vélemény, hozzászólás?

Az e-mail-címet nem tesszük közzé.