Server-side I/O Performance: Node vs. PHP vs. Java vs. Go Server-side I/O Performance: Node vs. PHP vs. Java vs. Go

loka 22, 2021
admin

Sovelluksesi Input/Output (I/O) -mallin ymmärtäminen voi merkitä eroa sovelluksen välillä, joka selviytyy sille aiheutuvasta kuormituksesta, ja sovelluksen välillä, joka murtuu todellisten käyttötapausten edessä. Ehkä kun sovelluksesi on pieni eikä palvele suurta kuormitusta, sillä voi olla paljon vähemmän merkitystä. Mutta sovelluksen liikennekuormituksen kasvaessa väärän I/O-mallin käyttäminen voi aiheuttaa pahaa jälkeä.

Ja kuten melkein missä tahansa tilanteessa, jossa useita lähestymistapoja on mahdollista käyttää, kyse ei ole vain siitä, kumpi on parempi, vaan myös kompromissien ymmärtämisestä. Lähdetäänpä kävelylle I/O-maiseman halki ja katsotaan, mitä voimme vakoilla.

Tässä artikkelissa vertailemme Nodea, Javaa, Go:ta ja PHP:tä Apachen kanssa, keskustelemme siitä, miten eri kielet mallintavat I/O:nsa, kunkin mallin eduista ja haitoista, ja päätämme artikkelin muutamiin alkeellisiin vertailuarvoihin. Jos olet huolissasi seuraavan verkkosovelluksesi I/O-suorituskyvystä, tämä artikkeli on sinulle.

I/O-perusteet: A Quick Refresher

Ymmärtääksemme I/O:han liittyviä tekijöitä meidän on ensin tarkasteltava käsitteitä käyttöjärjestelmätasolla. Vaikka on epätodennäköistä, että joudut käsittelemään monia näistä käsitteistä suoraan, olet niiden kanssa tekemisissä epäsuorasti sovelluksesi ajoympäristön kautta koko ajan. Ja yksityiskohdilla on merkitystä.

Järjestelmäkutsut

Ensiksi meillä on järjestelmäkutsut, jotka voidaan kuvata seuraavasti:

  • Ohjelmasi (niin sanotussa ”käyttäjämaassa”) joutuu pyytämään käyttöjärjestelmän ydintä suorittamaan jonkin I/O-operaation puolestaan.
  • ”Syscall” on keino, jonka avulla ohjelmasi pyytää ydintä tekemään jotain. Tämän toteuttamisen yksityiskohdat vaihtelevat käyttöjärjestelmittäin, mutta peruskonsepti on sama. On olemassa jokin erityinen käsky, joka siirtää kontrollin ohjelmastasi ytimelle (kuten funktiokutsu, mutta jossa on jotain erityiskastiketta nimenomaan tätä tilannetta varten). Yleisesti ottaen syscallit ovat estäviä, mikä tarkoittaa, että ohjelmasi odottaa, että ydin palaa takaisin koodillesi.
  • Ydin suorittaa taustalla olevan I/O-operaation kyseiselle fyysiselle laitteelle (levylle, verkkokortille jne.) ja vastaa syscall-kutsuun. Todellisessa maailmassa ytimen täytyy ehkä tehdä useita asioita täyttääkseen pyyntösi, kuten odottaa, että laite on valmis, päivittää sisäinen tilansa jne. mutta sovelluskehittäjänä et välitä siitä. Se on ytimen tehtävä.

Blokkaavat vs. ei-blokkaavat kutsut

Nyt, sanoin juuri edellä, että syscallit ovat blokkaavia, ja se on totta yleisessä mielessä. Jotkin kutsut luokitellaan kuitenkin ”ei-blokkaaviksi”, mikä tarkoittaa, että ydin ottaa pyyntösi vastaan, laittaa sen jonoon tai puskuriin jonnekin ja palaa sitten heti takaisin odottamatta varsinaista I/O:ta. Joten se ”blokkaa” vain hyvin lyhyen aikaa, juuri niin kauan, että pyyntösi asetetaan jonoon.

Joitakin esimerkkejä (Linuxin syscall-puheluista) saattaa auttaa selventämään asiaa:- read() on blokkaava kutsu – annat sille kahvan, joka kertoo, mitä tiedostoa ja puskuria se lukemansa datan toimittamiseksi sinne, ja kutsu palaa, kun data on siellä. Huomaa, että tämän etuna on se, että se on mukavan yksinkertaista.- epoll_create(), epoll_ctl() ja epoll_wait() ovat kutsuja, joiden avulla voit luoda ryhmän kahvoja, joita voit kuunnella, lisätä/poistaa käsittelijöitä tästä ryhmästä ja sitten blokata, kunnes on toimintaa. Näin voit hallita tehokkaasti suurta määrää I/O-operaatioita yhdellä säikeellä, mutta menen asioiden edelle. Tämä on hienoa, jos tarvitset toiminnallisuutta, mutta kuten huomaat, sen käyttö on varmasti monimutkaisempaa.

On tärkeää ymmärtää ajoituksen suuruusluokkaero tässä. Jos CPU-ydin toimii 3 GHz:n taajuudella, ilman että puhutaan optimoinneista, joita CPU voi tehdä, se suorittaa 3 miljardia sykliä sekunnissa (tai 3 sykliä nanosekunnissa). Lukkiutumattoman järjestelmäkutsun suorittaminen saattaa kestää kymmeniä syklejä – tai ”suhteellisen muutaman nanosekunnin”. Kutsu, joka lukkiutuu verkon kautta vastaanotettavien tietojen vuoksi, saattaa kestää paljon kauemmin – sanotaan esimerkiksi 200 millisekuntia (1/5 sekunnista). Ja sanotaan esimerkiksi, että lukkiutumaton kutsu kesti 20 nanosekuntia ja lukkiutuva kutsu kesti 200 000 000 nanosekuntia. Prosessisi odotti juuri 10 miljoonaa kertaa pidempään estävää kutsua.

Ydin tarjoaa välineet sekä estävän I/O:n (”lue tästä verkkoyhteydestä ja anna tiedot minulle”) että ei-estävän I/O:n (”kerro minulle, kun jossakin näistä verkkoyhteyksistä on uusia tietoja”) suorittamiseen. Ja se, kumpaa mekanismia käytetään, blokkaa kutsuvaa prosessia dramaattisesti eri pituisia aikoja.

Scheduling

Kolmas kriittisesti seurattava asia on se, mitä tapahtuu, kun on paljon säikeitä tai prosesseja, jotka alkavat blokkaantua.

Meidän tarkoituksemme kannalta säikeen ja prosessin välillä ei ole suurta eroa. Todellisessa elämässä huomattavin suorituskykyyn liittyvä ero on se, että koska säikeet jakavat saman muistin ja prosesseilla on kullakin oma muistialueensa, erillisten prosessien tekeminen vie yleensä paljon enemmän muistia. Mutta kun puhumme aikatauluttamisesta, kyse on lähinnä luettelosta asioista (säikeistä ja prosesseista), joiden jokaisen on saatava siivu suoritusaikaa käytettävissä olevilla suorittimen ytimillä. Jos käynnissä on 300 säiettä ja 8 ydintä, joilla niitä voi ajaa, aika on jaettava niin, että kukin säie saa oman osuutensa siten, että kukin ydin toimii lyhyen aikaa ja siirtyy sitten seuraavalle säikeelle. Tämä tapahtuu ”kontekstinvaihdon” avulla, jolloin suoritin siirtyy yhden säikeen/prosessin suorittamisesta seuraavaan.

Näillä kontekstinvaihdoilla on hintansa – ne vievät jonkin verran aikaa. Joissakin nopeissa tapauksissa se voi olla alle 100 nanosekuntia, mutta ei ole harvinaista, että se kestää 1000 nanosekuntia tai kauemmin riippuen toteutuksen yksityiskohdista, prosessorin nopeudesta/arkkitehtuurista, suorittimen välimuistista jne.

Ja mitä enemmän säikeitä (tai prosesseja), sitä enemmän kontekstinvaihtoja. Kun puhutaan tuhansista säikeistä ja sadoista nanosekunneista kullekin, asiat voivat muuttua hyvin hitaiksi.

Mutta lukkiutumattomat kutsut kertovat ytimelle pohjimmiltaan: ”Kutsu minua vain silloin, kun sinulla on jotain uutta dataa tai tapahtumaa jossakin näistä yhteyksistä”. Nämä lukkiutumattomat kutsut on suunniteltu käsittelemään tehokkaasti suuria I/O-kuormia ja vähentämään kontekstinvaihtoa.

Mitä tähän asti? Koska nyt tulee hauska osuus:

Huomautuksena mainittakoon, että vaikka tässä artikkelissa esitetyt esimerkit ovat triviaaleja (ja osittaisia, vain olennaiset osat on näytetty), tietokantojen käyttö, ulkoiset välimuistijärjestelmät (memcache ym.) ja kaikki, jotka vaativat I/O:ta, päätyvät suorittamaan jonkinlaisen I/O-kutsun konepellin alla, millä on sama vaikutus kuin esitetyillä yksinkertaisilla esimerkeillä. Niissä skenaarioissa, joissa I/O on kuvattu ”estäväksi” (PHP, Java), HTTP-pyyntöjen ja -vastausten lukeminen ja kirjoittaminen ovat itse estäviä kutsuja: Jälleen kerran järjestelmään on piilotettu lisää I/O:ta, ja siihen liittyvät suorituskykyongelmat on otettava huomioon.

Projektin ohjelmointikielen valintaan vaikuttavat monet tekijät. On jopa paljon tekijöitä, kun otetaan huomioon vain suorituskyky. Mutta jos olet huolissasi siitä, että ohjelmaasi rajoittaa ensisijaisesti I/O, jos I/O-suorituskyky on ratkaiseva tekijä projektissasi, nämä ovat asioita, jotka sinun on tiedettävä.

The ”Keep It Simple” Approach: PHP

Takaisin 90-luvulla monet ihmiset käyttivät Converse-kenkiä ja kirjoittivat CGI-skriptejä Perlillä. Sitten tuli PHP, ja vaikka jotkut tykkäävätkin haukkua sitä, se teki dynaamisten verkkosivujen tekemisestä paljon helpompaa.

PHP:n käyttämä malli on melko yksinkertainen. Siitä on joitakin variaatioita, mutta tavallinen PHP-palvelin näyttää seuraavalta:

HTP-pyyntö tulee käyttäjän selaimesta ja osuu Apache-verkkopalvelimelle. Apache luo jokaista pyyntöä varten erillisen prosessin, jossa on joitain optimointeja niiden uudelleenkäyttöä varten, jotta niiden määrä olisi mahdollisimman pieni (prosessien luominen on suhteellisesti ottaen hidasta).Apache kutsuu PHP:tä ja käskee sitä suorittamaan sopivan .php tiedoston levyllä.PHP-koodi suoritetaan ja se tekee estäviä I/O-kutsuja. Kutsut file_get_contents() PHP:ssä ja konepellin alla se tekee read() syscall-pyyntöjä ja odottaa tuloksia.

Ja tietysti varsinainen koodi on yksinkertaisesti upotettu suoraan sivuun, ja operaatiot ovat estäviä:

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

Kuten tämä integroituu systeemiin, se on seuraavanlainen:

Aika simppeliä: yksi prosessi jokaista pyyntöä kohti. I/O-kutsut vain blokkaavat. Etu? Se on yksinkertainen ja se toimii. Haitta? Jos siihen kohdistuu 20 000 yhtäaikaista asiakasta, palvelimesi roihahtaa liekkeihin. Tämä lähestymistapa ei skaalaudu hyvin, koska ytimen tarjoamia työkaluja suurten I/O-määrien käsittelyyn (epoll jne.) ei käytetä. Kaiken kukkuraksi erillisen prosessin käyttäminen jokaista pyyntöä varten kuluttaa paljon järjestelmäresursseja, erityisesti muistia, joka on usein ensimmäinen asia, joka loppuu tällaisessa skenaariossa.

Huomautus: Rubyssä käytetty lähestymistapa on hyvin samankaltainen kuin PHP:ssä, ja laajasti, yleisesti ja käsin kosketeltavalla tavalla niitä voidaan pitää samoina tarkoituksiamme varten.

Monisäikeinen lähestymistapa: Java

Jaava tulee siis juuri silloin, kun ostit ensimmäisen verkkotunnuksesi ja oli siistiä sanoa satunnaisesti ”dot com” lauseen perään. Ja Javassa on monisäikeistäminen sisäänrakennettuna kieleen, mikä (varsinkin silloin kun se luotiin) on aika mahtavaa.

Useimmat Java-verkkopalvelimet toimivat siten, että ne aloittavat uuden suoritussäikeen jokaista saapuvaa pyyntöä varten ja sitten tässä säikeessä lopulta kutsutaan funktiota, jonka sinä sovelluskehittäjänä kirjoitit.

I/O:n tekeminen Java Servletissä näyttää yleensä jotakuinkin seuraavalta:

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

Koska yllä oleva doGet-metodimme vastaa yhtä pyyntöä ja sitä ajetaan omassa säikeessään, meillä on erillinen säie sen sijaan, että jokaista pyyntöä varten olisi erillinen prosessi, joka vaatii oman muistinsa. Tällä on joitakin mukavia etuja, kuten mahdollisuus jakaa tilaa, välimuistiin tallennettuja tietoja jne. säikeiden välillä, koska ne voivat käyttää toistensa muistia, mutta vaikutus siihen, miten se on vuorovaikutuksessa aikataulun kanssa, on edelleen lähes identtinen sen kanssa, mitä aiemmin PHP-esimerkissä tehtiin. Jokainen pyyntö saa uuden säikeen, ja erilaiset I/O-operaatiot lukkiutuvat kyseisen säikeen sisällä, kunnes pyyntö on kokonaan käsitelty. Säikeet kootaan yhteen niiden luomisen ja tuhoamisen kustannusten minimoimiseksi, mutta silti tuhannet yhteydet tarkoittavat tuhansia säikeitä, mikä on huono asia aikatauluttajalle.

Tärkeä virstanpylväs on se, että Java sai versiossa 1.4 (ja merkittävässä päivityksessä taas 1.7) kyvyn tehdä lukkiutumattomia I/O-kutsuja. Useimmat sovellukset, web ja muut, eivät käytä sitä, mutta ainakin se on saatavilla. Jotkin Java-verkkopalvelimet yrittävät hyödyntää tätä eri tavoin; suurin osa käyttöönotetuista Java-sovelluksista toimii kuitenkin edelleen edellä kuvatulla tavalla.

Javalla päästään lähemmäs, ja siinä on varmasti joitakin hyviä valmiita I/O-toimintoja, mutta se ei vieläkään ratkaise ongelmaa siitä, mitä tapahtuu, kun sinulla on voimakkaasti I/O:ta sitova sovellus, jota murskataan monilla tuhansilla blokkaavilla säikeillä.Sulkeutumattomat I/O:t ykkösluokan kansalaisina: Node

Suosittu lapsi korttelissa, kun puhutaan paremmasta I/O:sta, on Node.js. Kaikille, jotka ovat tutustuneet Nodeen edes lyhyesti, on kerrottu, että se on ”non-blocking” ja että se käsittelee I/O:n tehokkaasti. Ja tämä on totta yleisessä mielessä. Mutta paholainen piilee yksityiskohdissa, ja keinoilla, joilla tämä noituus saavutettiin, on väliä, kun kyse on suorituskyvystä.

Välttämättä paradigman muutos, jonka Node toteuttaa, on se, että sen sijaan, että periaatteessa sanottaisiin ”kirjoita koodisi tänne käsittelemään pyyntöä”, sanotaan sen sijaan ”kirjoita koodi tänne, jotta voit alkaa käsitellä pyyntöä”. Joka kerta, kun sinun täytyy tehdä jotain, joka sisältää I/O:ta, teet pyynnön ja annat callback-funktion, jota Node kutsuu, kun se on valmis.

Tyypillinen Node-koodi I/O-operaation tekemiseen pyynnössä menee näin:

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

Kuten näet, tässä on kaksi callback-funktiota. Ensimmäistä kutsutaan, kun pyyntö käynnistyy, ja toista kutsutaan, kun tiedoston tiedot ovat käytettävissä.

Tämä periaatteessa antaa Nodelle mahdollisuuden käsitellä tehokkaasti I/O:ta näiden callbackien välissä. Skenaario, jossa se olisi vielä merkityksellisempi, on se, että teet tietokantakutsun Nodessa, mutta en vaivaudu esimerkin kanssa, koska se on täsmälleen sama periaate: Käynnistät tietokantakutsun ja annat Nodelle takaisinkutsufunktion, se suorittaa I/O-operaatiot erikseen käyttämällä lukkiutumattomia kutsuja ja kutsuu sitten takaisinkutsufunktioasi, kun pyytämäsi data on saatavilla. Tätä mekanismia, jossa I/O-pyynnöt asetetaan jonoon ja annetaan Noden hoitaa ne, minkä jälkeen saat takaisinsoiton, kutsutaan ”tapahtumasilmukaksi”. Ja se toimii melko hyvin.

Tässä mallissa on kuitenkin juju. Konepellin alla syy siihen liittyy paljon enemmän siihen, miten V8 JavaScript-moottori (Chromen JS-moottori, jota Node käyttää) on toteutettu 1 kuin mihinkään muuhun. Kaikki kirjoittamasi JS-koodi suoritetaan yhdessä säikeessä. Mieti tätä hetki. Se tarkoittaa, että vaikka I/O suoritetaan tehokkailla ei-blokkaavilla tekniikoilla, suorittimeen sidottuja operaatioita tekevä JS-koodisi suoritetaan yhdessä säikeessä, jolloin jokainen koodikappale estää seuraavan. Yleinen esimerkki tilanteesta, jossa tämä voi esiintyä, on tietokantatietueiden läpikäynti niiden käsittelemiseksi jollakin tavalla ennen niiden lähettämistä asiakkaalle. Tässä on esimerkki, joka osoittaa, miten tämä toimii:

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

Vaikka Node käsittelee I/O:n tehokkaasti, tuo yllä olevan esimerkin for-silmukka käyttää CPU-sykliä yhden ja ainoan pääsäikeesi sisällä. Tämä tarkoittaa, että jos sinulla on 10 000 yhteyttä, tuo silmukka voi hidastaa koko sovelluksesi toimintaa, riippuen siitä, kuinka kauan se kestää. Jokaisen pyynnön on jaettava siivu ajasta, yksi kerrallaan, pääsäikeessäsi.

Tämän koko konseptin lähtökohta on, että I/O-operaatiot ovat hitain osa, joten on tärkeintä käsitellä ne tehokkaasti, vaikka se tarkoittaisikin muun käsittelyn suorittamista sarjamaisesti. Tämä on totta joissakin tapauksissa, mutta ei kaikissa.

Toinen seikka on se, että, ja vaikka tämä on vain mielipide, voi olla melko rasittavaa kirjoittaa kasa sisäkkäisiä callbackeja ja jotkut väittävät, että se tekee koodista huomattavasti vaikeammin seurattavaa. Ei ole harvinaista, että Node-koodin sisällä on neljä, viisi tai jopa useampia tasoja syvällä olevia takaisinsoittoja.

Olemme taas takaisin kompromissien äärellä. Node-malli toimii hyvin, jos tärkein suorituskykyongelmasi on I/O. Sen akilleenkantapää on kuitenkin se, että voit mennä funktioon, joka käsittelee HTTP-pyyntöä, ja laittaa CPU-intensiivistä koodia ja saada jokaisen yhteyden pysähtymään, jos et ole varovainen.

Naturally Non-blocking: Go

Ennen kuin siirryn Go:ta käsittelevään osioon, minun on syytä paljastaa, että olen Go-fanipoika. Olen käyttänyt sitä monissa projekteissa ja olen avoimesti sen tuottavuushyötyjen kannattaja, ja näen ne työssäni, kun käytän sitä.

Sen sanottuani, katsotaanpa, miten se käsittelee I/O:ta. Yksi Go-kielen keskeinen ominaisuus on se, että se sisältää oman ajastimensa. Sen sijaan, että jokainen suoritussäie vastaisi yhtä käyttöjärjestelmän säiettä, se toimii käsitteellä ”goroutines”. Go:n suoritusohjelma voi liittää goroutiinin käyttöjärjestelmän säikeeseen ja antaa sen suoritettavaksi tai keskeyttää sen, jolloin sitä ei liitetä käyttöjärjestelmän säikeeseen, sen perusteella, mitä kyseinen goroutiini tekee. Jokainen Go:n HTTP-palvelimelta tuleva pyyntö käsitellään erillisessä goroutinessa.

Kaavio siitä, miten ajoitusohjelma toimii, näyttää tältä:

Konepellin alla tämä on toteutettu Go:n runtime:n eri pisteillä, jotka toteuttavat I/O-kutsun tekemällä kirjoitus/luku-/liitäntä-/yhteyspyynnön jne, laittavat nykyisen goroutinen lepotilaan, jolloin tieto herättää goroutinen takaisin, kun jatkotoimet voidaan tehdä.

Tosiasiassa Go-runtime tekee jotakin, joka ei ole kauhean erilainen kuin mitä Node tekee, paitsi että takaisinkutsumekanismi on sisäänrakennettu I/O-kutsun toteutukseen ja se on vuorovaikutuksessa aikatauluttajan kanssa automaattisesti. Se ei myöskään kärsi siitä rajoituksesta, että kaikki käsittelijäkoodisi on suoritettava samassa säikeessä, vaan Go liittää Goroutines-ohjelmasi automaattisesti niin moneen käyttöjärjestelmän säikeeseen kuin se katsoo sopivaksi aikatauluttajan logiikan perusteella. Tuloksena on tämän kaltaista koodia:

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}

Kuten yllä näet, tekemämme koodin perusrakenne muistuttaa yksinkertaisempien lähestymistapojen rakennetta, ja silti saavutamme lukkiutumattoman I/O:n konepellin alla.

Useimmissa tapauksissa tämä on lopulta ”molempien maailmojen parasta”. Ei-blokkaavaa I/O:ta käytetään kaikkiin tärkeisiin asioihin, mutta koodisi näyttää siltä kuin se olisi blokkaavaa ja on siten yleensä yksinkertaisempaa ymmärtää ja ylläpitää. Go:n ajastimen ja käyttöjärjestelmän ajastimen välinen vuorovaikutus hoitaa loput. Se ei ole täyttä taikuutta, ja jos rakennat suuren järjestelmän, kannattaa käyttää aikaa ymmärtääkseen yksityiskohtaisemmin, miten se toimii; mutta samaan aikaan ympäristö, jonka saat ”out-of-the-box”, toimii ja skaalautuu melko hyvin.

Go:lla voi olla vikansa, mutta yleisesti ottaen tapa, jolla se käsittelee I/O:ta, ei ole niiden joukossa.

Valheita, hitonmoista valhetta ja vertailuanalyysejä

Tarkkoja ajoituksia kontekstin vaihtoon, joka liittyy näissä erilaisissa toimintamalleissa, on hankalaa antaa. Voisin myös väittää, että siitä on vähemmän hyötyä. Annan sen sijaan joitakin perusvertailukohtia, joissa verrataan näiden palvelinympäristöjen yleistä HTTP-palvelimen suorituskykyä. Kannattaa muistaa, että koko HTTP-pyynnön ja -vastauksen loppupään suorituskykyyn vaikuttavat monet tekijät, ja tässä esitetyt luvut ovat vain joitakin näytteitä, jotka kokosin perusvertailua varten.

Kirjoitin kullekin näistä ympäristöistä sopivan koodin, jolla luetaan 64 kilotavun tiedosto, jossa on satunnaisia tavuja, suoritetaan SHA-256-hajautuslaskenta N kertaa (N määritetään URL-osoitteen merkkijonossa, esimerkiksi .../test.php?n=100) ja tulostetaan tuloksena saatava hajautuslaskenta heksadesimaalina. Valitsin tämän, koska se on hyvin yksinkertainen tapa ajaa samoja vertailuarvoja johdonmukaisella I/O:lla ja hallittu tapa lisätä suorittimen käyttöä.

Katso näistä vertailuarvojen muistiinpanoista hieman tarkemmin käytetyistä ympäristöistä.

Katsotaan ensin muutamia esimerkkejä, joissa on vähän rinnakkaisuutta. Kun ajetaan 2000 iteraatiota 300 samanaikaisella pyynnöllä ja vain yksi hash per pyyntö (N=1), saadaan tämä tulos:

Ajat ovat pyyntöjen loppuunsaattamiseen kuluvien millisekuntien keskiarvo kaikissa samanaikaisissa pyynnöissä. Pienempi on parempi.

Vaikea vetää johtopäätöstä vain tästä yhdestä kuvaajasta, mutta minusta näyttää siltä, että tällä yhteys- ja laskentamäärällä näemme aikoja, jotka liittyvät enemmän itse kielten yleiseen suoritukseen kuin I/O:han. Huomaa, että kielet, joita pidetään ”skriptikielinä” (löyhä tyypitys, dynaaminen tulkinta), suoriutuvat hitaimmin.

Mutta mitä tapahtuu, jos kasvatamme N:n 1000:een, jolloin samanaikaisia pyyntöjä on edelleen 300 – sama kuormitus, mutta 100 kertaa enemmän hash-iteraatioita (huomattavasti enemmän suorittimen kuormitusta):

Ajat ovat pyyntöjen suorittamisen keskimääräinen millisekuntien määrä kaikkien samanaikaisten pyyntöjen osalta. Pienempi on parempi.

Yhtäkkiä solmun suorituskyky putoaa merkittävästi, koska kunkin pyynnön CPU-intensiiviset operaatiot estävät toisiaan. Ja mielenkiintoista kyllä, PHP:n suorituskyky paranee huomattavasti (suhteessa muihin) ja voittaa Javan tässä testissä. (Kannattaa huomata, että PHP:ssä SHA-256-toteutus on kirjoitettu C:llä ja suorituspolku kuluttaa paljon enemmän aikaa tuossa silmukassa, koska teemme nyt 1000 hash-iteraatiota).

Kokeillaan nyt 5000 samanaikaista yhteyttä (N=1:llä) – tai niin lähelle sitä kuin pystyin. Valitettavasti useimmissa näistä ympäristöistä epäonnistumisprosentti ei ollut merkityksetön. Tässä kaaviossa tarkastellaan pyyntöjen kokonaismäärää sekunnissa. Mitä suurempi, sitä parempi:

Total number of requests per second. Suurempi on parempi.

Ja kuva näyttää aivan erilaiselta. Se on arvaus, mutta näyttää siltä, että suurilla yhteysmäärillä uusien prosessien synnyttämiseen ja siihen liittyvään lisämuistiin liittyvä yhteyskohtainen yleiskustannus PHP+Apachessa näyttää muodostuvan hallitsevaksi tekijäksi ja tankkaavan PHP:n suorituskykyä. Go on tässä selvästi voittaja, ja sen jälkeen tulevat Java, Node ja lopulta PHP.

Vaikka kokonaissuorittuvuuteen vaikuttavia tekijöitä on monia ja ne myös vaihtelevat suuresti sovelluskohtaisesti, mitä enemmän ymmärrät konepellin alla tapahtuvasta toiminnasta ja siihen liittyvistä kompromisseista, sitä paremmin pärjäät.

Yhteenvetona

Kaiken edellä esitetyn perusteella on melko selvää, että kielten kehittyessä myös ratkaisut suuren mittakaavan sovellusten käsittelyyn, jotka tekevät paljon I/O:ta, ovat kehittyneet sen mukana.

Ollaksemme reiluja, sekä PHP:llä että Javalla on tässä artikkelissa esitetyistä kuvauksista huolimatta toteutuksia ei-blokkaavasta I/O:sta, joita voidaan käyttää web-sovelluksissa. Ne eivät kuitenkaan ole yhtä yleisiä kuin edellä kuvatut lähestymistavat, ja tällaisia lähestymistapoja käyttävien palvelimien ylläpidosta aiheutuvat yleiskustannukset olisi otettava huomioon. Puhumattakaan siitä, että koodisi on rakennettava siten, että se toimii tällaisissa ympäristöissä; ”tavallinen” PHP- tai Java-verkkosovelluksesi ei yleensä toimi ilman merkittäviä muutoksia tällaisessa ympäristössä.

Vertailuna, jos otamme huomioon muutamia merkittäviä tekijöitä, jotka vaikuttavat suorituskykyyn sekä helppokäyttöisyyteen, saamme seuraavan tuloksen:

Kieli Lankoja vs. Prosessit Ei…blocking I/O Ease of Use
PHP Processes No
Java Threads Available Requires Callbacks
Node.js Threads Yes Requires Callbacks
Go Threads (Goroutines) Yes No Callbacks Needed

Säikeet ovat yleensä paljon muistitehokkaampia kuin prosessit, koska ne jakavat saman muistitilan, kun taas prosessit eivät. Kun tämä yhdistetään lukkiutumattomaan I/O:hon liittyviin tekijöihin, voidaan nähdä, että ainakin edellä tarkasteltujen tekijöiden osalta yleinen asetelma I/O:n suhteen paranee, kun siirrytään luettelossa alaspäin. Joten jos minun pitäisi valita voittaja edellä mainitussa kilpailussa, se olisi varmasti Go.

Käytännössä ympäristön valitseminen sovelluksen rakentamista varten liittyy kuitenkin läheisesti siihen, kuinka hyvin tiimisi tuntee kyseisen ympäristön ja kuinka tuottavaksi sen avulla voit yleisesti ottaen tulla. Jokaisen tiimin ei siis välttämättä ole järkevää vain sukeltaa ja alkaa kehittää verkkosovelluksia ja -palveluita Node- tai Go-kielellä. Kehittäjien löytäminen tai oman tiimin tuttuus mainitaankin usein tärkeimpänä syynä olla käyttämättä eri kieltä ja/tai ympäristöä. Tästä huolimatta ajat ovat muuttuneet viimeisen noin viidentoista vuoden aikana paljon.

Toivottavasti edellä esitetty auttaa hahmottamaan selkeämmän kuvan siitä, mitä konepellin alla tapahtuu, ja antaa sinulle joitakin ideoita siitä, miten voit käsitellä sovelluksesi skaalautuvuutta todellisessa maailmassa. Hyvää syöttöä ja tulostusta!

Vastaa

Sähköpostiosoitettasi ei julkaista.