Prestanda för I/O på serversidan: Node vs. PHP vs. Java vs. Go

okt 22, 2021
admin

En förståelse för I/O-modellen (Input/Output) för din applikation kan innebära skillnaden mellan en applikation som klarar av den belastning den utsätts för och en applikation som faller ihop i verkliga användningsfall. Medan din applikation kanske är liten och inte betjänar höga belastningar kan det spela betydligt mindre roll. Men när trafikbelastningen i din applikation ökar kan arbetet med fel I/O-modell leda till att du hamnar i en värld av smärta.

Och som i de flesta situationer där flera tillvägagångssätt är möjliga är det inte bara en fråga om vilket som är bäst, utan en fråga om att förstå kompromisserna. Låt oss ta en promenad genom I/O-landskapet och se vad vi kan upptäcka.

I den här artikeln kommer vi att jämföra Node, Java, Go och PHP med Apache, diskutera hur de olika språken modellerar sin I/O, fördelarna och nackdelarna med varje modell, och avsluta med några rudimentära benchmarks. Om du är orolig för I/O-prestandan i din nästa webbapplikation är den här artikeln för dig.

I/O Basics: För att förstå de faktorer som är involverade i I/O måste vi först gå igenom begreppen på operativsystemnivå. Även om det är osannolikt att du kommer att behöva hantera många av dessa begrepp direkt, hanterar du dem indirekt genom din applikations körtidsmiljö hela tiden. Och detaljerna har betydelse.

Systemanrop

För det första har vi systemanrop, som kan beskrivas på följande sätt:

  • Ditt program (i ”användarlandet”, som man säger) måste be operativsystemets kärna att utföra en I/O-operation för dess räkning.
  • Ett ”syscall” är det sätt med vilket ditt program ber kärnan att göra något. Detaljerna för hur detta implementeras varierar mellan olika operativsystem, men det grundläggande konceptet är detsamma. Det kommer att finnas någon specifik instruktion som överför kontrollen från ditt program till kärnan (som ett funktionsanrop, men med en speciell sås specifikt för att hantera denna situation). Generellt sett är syscalls blockerande, vilket innebär att ditt program väntar på att kärnan ska återvända till din kod.
  • Kärnan utför den underliggande I/O-operationen på den fysiska enheten i fråga (disk, nätverkskort etc.) och svarar på syscallen. I den verkliga världen kan kärnan behöva göra ett antal saker för att uppfylla din begäran, inklusive att vänta på att enheten är redo, uppdatera sitt interna tillstånd etc., men som programutvecklare bryr du dig inte om det. Det är kärnans jobb.

Blockerande vs. icke-blockerande anrop

Nu sa jag nyss ovan att syscalls är blockerande, och det är sant i en allmän mening. Vissa anrop kategoriseras dock som ”icke-blockerande”, vilket innebär att kärnan tar emot din förfrågan, lägger den i en kö eller buffert någonstans och sedan omedelbart återvänder utan att vänta på att den faktiska I/O sker. Den ”blockerar” alltså bara under en mycket kort tidsperiod, bara tillräckligt länge för att ställa din begäran i kö.

Några exempel (på Linux syscalls) kan hjälpa till att förtydliga: – read() är ett blockerande anrop – du ger den ett handtag som säger vilken fil och en buffert där den ska leverera data som den läser, och anropet återvänder när datan finns där. Observera att detta har den fördelen att det är trevligt och enkelt. – epoll_create(), epoll_ctl() och epoll_wait() är anrop som respektive låter dig skapa en grupp handtag att lyssna på, lägga till/ta bort handläggare från den gruppen och sedan blockera tills det sker någon aktivitet. På så sätt kan du effektivt styra ett stort antal I/O-operationer med en enda tråd, men jag går före mig själv. Detta är bra om du behöver funktionaliteten, men som du kan se är det definitivt mer komplext att använda.

Det är viktigt att förstå storleksordningen på skillnaden i timing här. Om en CPU-kärna körs på 3 GHz, utan att gå in på optimeringar som CPU:n kan göra, utför den 3 miljarder cykler per sekund (eller 3 cykler per nanosekund). Ett icke-blockerande systemanrop kan ta 10 000 cykler att slutföra – eller ”relativt få nanosekunder”. Ett anrop som blockerar för information som tas emot via nätet kan ta mycket längre tid – låt oss säga till exempel 200 millisekunder (1/5 av en sekund). Och låt oss till exempel säga att det icke-blockerande samtalet tog 20 nanosekunder och att det blockerande samtalet tog 200 000 000 nanosekunder. Din process har just väntat 10 miljoner gånger längre på det blockerande anropet.

Kärnan tillhandahåller medel för att göra både blockerande I/O (”läs från den här nätverksanslutningen och ge mig data”) och icke-blockerande I/O (”tala om för mig när någon av de här nätverksanslutningarna har nya data”). Och vilken mekanism som används kommer att blockera den anropande processen under dramatiskt olika lång tid.

Scheduling

Den tredje saken som är kritisk att följa är vad som händer när man har många trådar eller processer som börjar blockera.

För våra syften finns det inte någon större skillnad mellan en tråd och en process. I verkligheten är den mest märkbara prestandarelaterade skillnaden att eftersom trådar delar samma minne och processer har varsitt eget minnesutrymme, tenderar det att ta upp mycket mer minne om man gör separata processer. Men när vi talar om schemaläggning är det egentligen en lista över saker (trådar och processer) som var och en behöver få en del av sin exekveringstid på de tillgängliga CPU-kärnorna. Om du har 300 trådar igång och 8 kärnor att köra dem på måste du dela upp tiden så att var och en får sin andel, där varje kärna körs under en kort tidsperiod och sedan går vidare till nästa tråd. Detta görs genom en ”kontextväxling”, vilket innebär att CPU:n växlar från att köra en tråd/process till nästa.

Dessa kontextväxlingar har en kostnad förknippad med dem – de tar en viss tid. I vissa snabba fall kan det vara mindre än 100 nanosekunder, men det är inte ovanligt att det tar 1 000 nanosekunder eller längre beroende på detaljerna i genomförandet, processorns hastighet/arkitektur, CPU-cache osv.

Och ju fler trådar (eller processer), desto fler kontextbyten. När vi talar om tusentals trådar och hundratals nanosekunder för varje tråd kan det bli mycket långsamt.

Non-blockerande anrop säger i princip till kärnan att ”ring mig bara när du har nya data eller en ny händelse på någon av dessa anslutningar”. Dessa icke-blockerande anrop är utformade för att effektivt hantera stora I/O-belastningar och minska kontextbyten.

Har du förstått mig än så länge? För nu kommer den roliga delen: Låt oss titta på vad några populära språk gör med dessa verktyg och dra några slutsatser om kompromisser mellan användarvänlighet och prestanda… och andra intressanta saker.

Som en notering, även om de exempel som visas i den här artikeln är triviala (och partiella, med bara de relevanta bitarna visade); databasåtkomst, externa caching-system (memcache, et. all) och allt som kräver I/O kommer att sluta med att utföra någon form av I/O-anrop under huven som kommer att ha samma effekt som de enkla exemplen som visas. När det gäller de scenarier där I/O beskrivs som ”blockerande” (PHP, Java) är HTTP-förfrågan och -svarets läsning och skrivning i sig blockerande anrop: Återigen, mer I/O gömt i systemet med tillhörande prestandaproblem att ta hänsyn till.

Det finns många faktorer som spelar in när man väljer ett programmeringsspråk för ett projekt. Det finns till och med många faktorer när man bara tar hänsyn till prestanda. Men om du är orolig för att ditt program främst kommer att begränsas av I/O, om I/O-prestanda är avgörande för ditt projekt, är detta saker du behöver veta.

Den enkla metoden: PHP

På 90-talet var det många som hade Converse-skor och skrev CGI-skript i Perl. Sedan kom PHP, och även om vissa människor gillar att skälla på det, gjorde det det mycket enklare att göra dynamiska webbsidor.

Modellen som PHP använder sig av är ganska enkel. Det finns en del variationer, men en vanlig PHP-server ser ut på följande sätt:

En HTTP-förfrågan kommer in från en användares webbläsare och träffar din Apache-webbserver. Apache skapar en separat process för varje begäran, med vissa optimeringar för att återanvända dem för att minimera hur många den behöver göra (att skapa processer är, relativt sett, långsamt).Apache anropar PHP och säger åt den att köra den lämpliga .php-filen på disken.PHP-koden körs och gör blockerande I/O-anrop. Du anropar file_get_contents() i PHP och under huven gör den read() syscalls och väntar på resultaten.

Och naturligtvis är själva koden helt enkelt inbäddad rakt in i din sida, och operationerna är blockerande:

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

Om hur det här integreras med systemet är det så här:

Så enkelt är det: en process per begäran. I/O-anrop blockerar bara. Fördel? Det är enkelt och det fungerar. Nackdel? Om 20 000 klienter samtidigt belastar den kommer din server att brinna upp i lågor. Detta tillvägagångssätt skalar inte bra eftersom de verktyg som kärnan tillhandahåller för att hantera I/O med hög volym (epoll osv.) inte används. Och för att lägga till skada, att köra en separat process för varje begäran tenderar att använda en hel del systemresurser, särskilt minne, vilket ofta är det första du får slut på i ett scenario som detta.

Notera: Det tillvägagångssätt som används för Ruby liknar i hög grad det som används för PHP, och på ett brett, generellt, handgripligt sätt kan de betraktas som likadana för våra syften.

Det flervridna tillvägagångssättet: Java

Så kommer Java, ungefär samtidigt som du köpte ditt första domännamn och det var coolt att bara slumpmässigt säga ”dot com” efter en mening. Och Java har multithreading inbyggt i språket, vilket (särskilt för när det skapades) är ganska häftigt.

De flesta Java-webservrar fungerar genom att starta en ny exekveringstråd för varje begäran som kommer in och sedan i denna tråd så småningom anropa den funktion som du som programutvecklare har skrivit.

Att göra I/O i en Java Servlet brukar se ut ungefär så här:

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

Då vår doGet-metod ovan motsvarar en förfrågan och körs i en egen tråd, har vi istället för en separat process för varje förfrågan, som kräver eget minne, en separat tråd. Detta har några trevliga fördelar, som att kunna dela tillstånd, cachad data etc. mellan trådar eftersom de kan komma åt varandras minne, men effekten på hur det interagerar med schemat är fortfarande nästan identisk med vad som görs i PHP-exemplet tidigare. Varje begäran får en ny tråd och de olika I/O-operationerna blockeras i den tråden tills begäran är helt hanterad. Trådar poolas för att minimera kostnaden för att skapa och förstöra dem, men fortfarande innebär tusentals anslutningar tusentals trådar, vilket är dåligt för schemaläggaren.

En viktig milstolpe är att Java i version 1.4 (och en betydande uppgradering igen i 1.7) fick möjlighet att göra icke-blockerande I/O-anrop. De flesta tillämpningar, webb och andra, använder inte detta, men det finns åtminstone tillgängligt. Vissa Java-webbservrar försöker dra nytta av detta på olika sätt, men den stora majoriteten av de installerade Java-applikationerna fungerar fortfarande på det sätt som beskrivs ovan.

Java kommer närmare och har säkert en del bra out-of-the-box-funktionalitet för I/O, men det löser fortfarande inte riktigt problemet med vad som händer när man har en applikation som är hårt bunden till I/O och som får många tusen blockerande trådar.

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

Node.js är det populära barnet i kvarteret när det gäller bättre I/O. Alla som har haft ens en kort introduktion till Node har fått höra att den är ”icke-blockerande” och att den hanterar I/O på ett effektivt sätt. Och detta är sant i en allmän mening. Men djävulen ligger i detaljerna och sättet som denna häxkonst uppnåddes på spelar roll när det gäller prestanda.

Essentiellt sett är paradigmskiftet som Node implementerar att istället för att i huvudsak säga ”skriv din kod här för att hantera förfrågan”, säger de istället ”skriv kod här för att börja hantera förfrågan”. Varje gång du behöver göra något som involverar I/O gör du begäran och ger en callback-funktion som Node anropar när den är klar.

Typisk Node-kod för att göra en I/O-operation i en begäran ser ut så här:

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

Som du kan se finns det två callback-funktioner här. Den första anropas när en begäran startar och den andra anropas när filinformationen är tillgänglig.

Vad detta gör är i princip att ge Node en möjlighet att effektivt hantera I/O mellan dessa callbacks. Ett scenario där det skulle vara ännu mer relevant är om du gör ett databaskall i Node, men jag kommer inte att bry mig om exemplet eftersom det är exakt samma princip: Du startar databaskall och ger Node en callback-funktion, den utför I/O-operationerna separat med hjälp av icke-blockerande anrop och anropar sedan din callback-funktion när de data du bad om är tillgängliga. Denna mekanism att ställa I/O-anrop i kö och låta Node hantera dem och sedan få en callback-funktion kallas för ”Event Loop” (händelseslinga). Och den fungerar ganska bra.

Det finns dock en hake med denna modell. Under huven har orsaken till det mycket mer att göra med hur V8 JavaScript-motorn (Chromes JS-motor som används av Node) är implementerad 1 än något annat. Den JS-kod som du skriver körs i en enda tråd. Tänk på det en stund. Det betyder att medan I/O utförs med hjälp av effektiva icke-blockerande tekniker, körs din JS-kod som utför CPU-bundna operationer i en enda tråd, där varje kodstycke blockerar nästa. Ett vanligt exempel på när det här kan inträffa är att man loopar över databasposter för att bearbeta dem på något sätt innan de skickas ut till klienten. Här är ett exempel som visar hur det fungerar:

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

Och även om Node hanterar I/O effektivt använder for-slingan i exemplet ovan CPU-cykler i din enda huvudtråd. Detta innebär att om du har 10 000 anslutningar kan denna slinga få hela programmet att gå i stå, beroende på hur lång tid den tar. Varje begäran måste dela på en del av tiden, en i taget, i din huvudtråd.

Principen som hela detta koncept bygger på är att I/O-operationerna är den långsammaste delen, och därför är det viktigast att hantera dem effektivt, även om det innebär att annan behandling görs seriellt. Detta är sant i vissa fall, men inte i alla.

Den andra punkten är att, och även om detta bara är en åsikt, kan det vara ganska tröttsamt att skriva en massa inbäddade callbacks och vissa hävdar att det gör koden betydligt svårare att följa. Det är inte ovanligt att callbacks ligger fyra, fem eller fler nivåer djupt i Node-koden.

Vi är tillbaka till kompromisserna. Node-modellen fungerar bra om ditt huvudsakliga prestandaproblem är I/O. Dess akilleshäl är dock att du kan gå in i en funktion som hanterar en HTTP-förfrågan och lägga in CPU-intensiv kod och få varje anslutning att gå i stå om du inte är försiktig.

Naturligt icke-blockerande: Go

Innan jag går in på avsnittet om Go är det lämpligt att jag avslöjar att jag är en Go-fanboy. Jag har använt det i många projekt och jag är en öppen förespråkare av dess produktivitetsfördelar, och jag ser dem i mitt arbete när jag använder det.

Detta sagt, låt oss titta på hur det hanterar I/O. En viktig egenskap hos Go-språket är att det innehåller en egen schemaläggare. Istället för att varje exekveringstråd motsvarar en enda OS-tråd arbetar det med konceptet ”goroutiner”. Go-körtiden kan tilldela en goroutine till en OS-tråd och låta den exekvera, eller avbryta den och låta den inte vara associerad med en OS-tråd, baserat på vad goroutinen gör. Varje begäran som kommer in från Go:s HTTP-server hanteras i en separat goroutin.

Diagrammet över hur schemaläggaren fungerar ser ut så här:

Under huven implementeras detta av olika punkter i Go-körtiden som implementerar I/O-anropet genom att göra begäran om att skriva/läsa/ansluta/etc, sätter den aktuella goroutinen i vila, med information om att väcka goroutinen igen när ytterligare åtgärder kan vidtas.

I själva verket gör Go-körtiden något som inte är särskilt olikt det som Node gör, förutom att callback-mekanismen är inbyggd i genomförandet av I/O-anropet och interagerar med schemaläggaren automatiskt. Den lider inte heller av begränsningen att all hanteringskod måste köras i samma tråd, Go kommer automatiskt att mappa dina Goroutines till så många OS-trådar som den anser lämpliga baserat på logiken i schemaläggaren. Resultatet blir kod som denna:

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}

Som du kan se ovan liknar den grundläggande kodstrukturen för det vi gör de mer förenklade tillvägagångssätten, och ändå uppnås icke-blockerande I/O under huven.

I de flesta fall slutar det här med att vara ”det bästa av två världar”. Icke-blockerande I/O används för alla viktiga saker, men din kod ser ut som om den är blockerande och tenderar därför att vara enklare att förstå och underhålla. Interaktionen mellan Go-schemaläggaren och OS-schemaläggaren hanterar resten. Det är inte fullständig magi, och om du bygger ett stort system är det värt att lägga ner tid på att förstå mer detaljer om hur det fungerar, men samtidigt fungerar den miljö du får ”out-of-the-box” och skalar ganska bra.

Go kan ha sina fel, men generellt sett är sättet som det hanterar I/O inte en av dem.

Lies, Damned Lies and Benchmarks

Det är svårt att ge exakta tidsangivelser för kontextbytet som är involverat med dessa olika modeller. Jag skulle också kunna hävda att det är mindre användbart för dig. Så i stället ska jag ge dig några grundläggande riktmärken som jämför den totala HTTP-serverprestandan för dessa servermiljöer. Tänk på att många faktorer är inblandade i prestandan för hela HTTP-förfrågan/svarsvägen från början till slut, och de siffror som presenteras här är bara några exempel som jag satt ihop för att ge en grundläggande jämförelse.

För var och en av dessa miljöer skrev jag lämplig kod för att läsa in en 64k-fil med slumpmässiga bytes, körde en SHA-256-hash på den N antal gånger (N anges i webbadressens frågetecken, t.ex. .../test.php?n=100) och skrev ut det resulterande hashresultatet i hex. Jag valde detta eftersom det är ett mycket enkelt sätt att köra samma benchmarks med en viss konsekvent I/O och ett kontrollerat sätt att öka CPU-användningen.

Se dessa benchmark notes för lite mer detaljer om de miljöer som används.

Först ska vi titta på några exempel med låg samtidighet. Om vi kör 2000 iterationer med 300 samtidiga förfrågningar och endast en hash per förfrågan (N=1) får vi följande resultat:

Tiderna är det genomsnittliga antalet millisekunder för att slutföra en förfrågan för alla samtidiga förfrågningar. Lägre är bättre.

Det är svårt att dra en slutsats från bara detta enda diagram, men för mig verkar det som om vi vid denna volym av anslutningar och beräkningar ser tider som har mer att göra med den allmänna utförandet av själva språken, mycket mer än med I/O. Observera att de språk som anses vara ”skriptspråk” (lös typning, dynamisk tolkning) presterar långsammast.

Men vad händer om vi ökar N till 1000, fortfarande med 300 samtidiga förfrågningar – samma belastning men 100x fler hash-iterationer (betydligt mer CPU-belastning):

Tiderna är det genomsnittliga antalet millisekunder för att slutföra en förfrågan över alla samtidiga förfrågningar. Lägre är bättre.

Helt plötsligt sjunker nodens prestanda avsevärt eftersom de CPU-intensiva operationerna i varje begäran blockerar varandra. Intressant nog blir PHP:s prestanda mycket bättre (i förhållande till de andra) och slår Java i det här testet. (Det är värt att notera att i PHP är SHA-256-implementationen skriven i C och exekveringsvägen spenderar mycket mer tid i den slingan, eftersom vi gör 1000 hash-iterationer nu).

Nu ska vi prova 5000 samtidiga anslutningar (med N=1) – eller så nära det som jag kunde komma. Tyvärr var felprocenten för de flesta av dessa miljöer inte obetydlig. För det här diagrammet tittar vi på det totala antalet begäranden per sekund. Ju högre desto bättre:

Totalt antal förfrågningar per sekund. Högre är bättre.

Och bilden ser helt annorlunda ut. Det är en gissning, men det ser ut som att vid hög anslutningsvolym verkar overheadkostnaden per anslutning som är involverad med att spawna nya processer och det extra minnet som är förknippat med det i PHP+Apache bli en dominerande faktor och tanka PHP:s prestanda. Go är helt klart vinnaren här, följt av Java, Node och slutligen PHP.

Och även om de faktorer som spelar in på din totala genomströmning är många och varierar mycket från program till program, så är det bättre för dig ju mer du förstår vad som händer under huven och vilka avvägningar som görs, desto bättre för dig.

Sammanfattningsvis

Med allt ovanstående är det ganska tydligt att i takt med att språken har utvecklats har lösningarna för att hantera storskaliga applikationer som gör mycket I/O utvecklats med det.

För att vara rättvis har både PHP och Java, trots beskrivningarna i den här artikeln, implementeringar av icke-blockerande I/O som är tillgängliga för användning i webbapplikationer. Men dessa är inte lika vanliga som de metoder som beskrivs ovan, och man måste ta hänsyn till de operativa kostnader som är förknippade med underhållet av servrar som använder sådana metoder. För att inte tala om att din kod måste vara strukturerad på ett sätt som fungerar med sådana miljöer; din ”normala” PHP- eller Java-webbapplikation kommer vanligtvis inte att kunna köras utan betydande ändringar i en sådan miljö.

Som jämförelse, om vi tar hänsyn till några viktiga faktorer som påverkar både prestanda och användarvänlighet, får vi följande:

Språk Threads vs. Processer Non-blockerande I/O Användningsvänlighet
PHP Processer Nej
Java Threads Available Requires Callbacks
Node.js Threads Ja Kräver Callbacks
Go Threads (Goroutines) Ja Ingen återkoppling behövs

Threads är i allmänhet mycket mer minneseffektiva än processer, eftersom de delar samma minnesutrymme medan processer inte gör det. Om vi kombinerar detta med de faktorer som rör icke-blockerande I/O kan vi se att åtminstone med de faktorer som beaktas ovan förbättras den allmänna inställningen när vi rör oss nedåt i listan när det gäller I/O. Så om jag var tvungen att utse en vinnare i ovanstående tävling skulle det definitivt vara Go.

Men i praktiken är valet av en miljö för att bygga din applikation nära förknippat med den förtrogenhet som ditt team har med denna miljö och den totala produktivitet som du kan uppnå med den. Därför är det kanske inte meningsfullt för alla team att bara dyka in och börja utveckla webbapplikationer och tjänster i Node eller Go. Att hitta utvecklare eller att det interna teamet är bekant är ofta det främsta skälet till att inte använda ett annat språk och/eller en annan miljö. Med det sagt har tiderna förändrats under de senaste femton åren eller så, en hel del.

Förhoppningsvis hjälper ovanstående till att måla upp en tydligare bild av vad som händer under huven och ger dig några idéer om hur du ska hantera verklig skalbarhet för din applikation. Lycka till med inmatning och utmatning!

Lämna ett svar

Din e-postadress kommer inte publiceras.