Det så kallade C10K-problemet, ett för många relativt nytt begrepp, beskrivs enligt Wikipedia på följande sätt:

The C10k problem[1] is the numeronym given to a limitation that most web servers currently have which limits the web server’s capabilities to only handle about ten thousand simultaneous connections. This limitation is partly due to operating system constraints and software limitations.

Det här är ett problem som knappt existerade för inte allt för länge sedan, men i takt med Facebook, Twitter och andra tjänster med krav på extremt hög skalbarhet har ökat i popularitet så har även C10K-problemet blivit mer relevant (en annan term man brukar höra i det här sammanhanget är web scale).

Grundproblemet är alltså att de flesta av dagens web-servrar inte är designade för att hantera ett stort antal samtidiga användare (klienter).

För att förstå varför detta blir ett problem tänkte jag börja med att kort beskriva tre olika tekniker som web-servar kan använda sig av för just hanteringen av flera klienter.

1. “En klient i taget”

Den naiva lösningen är att helt enkelt ta hand om en klient åt gången. Om flera klienter anropar servern samtidigt så köar man upp dem och tar dem i turordning.

Med pseudo-kod kan det beskrivas så här:

while(true) {
    waitForClient() {
        handleClient()
    }
}

Anropet waitForClient() läser lämpligen av en kö med väntande klient-anrop. Så fort det finns ett klient-anrop hanterar man det i handleClient(), och därefter tar man nästa klient.

Nackdelarna med det här angreppssättet är ganska uppenbara, om servern har många köande anrop kommer det ta väldigt lång tid innan ett nyinkommet anrop tas om hand. Tex, om varje anrop till handleClient() tar 1 sek, och det finns 9 väntande anrop i kön så kommer man som ny klient behöva vänta runt 10sek innan man får svar från servern. Inte direkt en skalbar lösning, här kommer man stöta på problem långt innan 10.000 anrop.

2. “Trådad server”

För att försöka komma ifrån ovanstående problem kan man tänka sig lösning där hanteringen av ett nytt klient-anrop tas om hand i en egen tråd.

Pseudo-kod:

while(true) {
    waitForClient() {
        Thread t = new Thread()
        t.handleClientRequest()
    }
}

Här skapas alltså en ny tråd upp för varje anrop, och sedan hanteras respektive klient i sin “egen” tråd. Fördelen med detta är att man inte “blockar” för varje klient, och en ny klient behöver inte vänta på att alla tidigare anrop ska bli klara först. Tyvärr finns det andra nackdelar med den här lösningen:

  • För det första är uppskapandet av trådar relativt dyrt/långsamt, och mycket tid kommer gå åt bara till det.
  • Förutom det tillkommer en hel del overhead av att växla mellan de olika trådarna som ska köras (detta brukar kallas “context-switch”). I takt med att antalet trådar blir fler och fler, kommer tiden för “context-switching” öka, tillslut kommer servern i princip vara upptagen med att bara sköta den här växlingen mellan trådar.

Även om det här är en mycket bättre lösning än den som presenterades ovan, så löser den tyvärr inte C10K-problemet.

3. “Trådpoolad server”

En mycket vanlig lösning för att komma bort från problemet ovan är att man redan från start bestämmer sig för hur många klienter man maximalt ska kunna hantera. Man kan då se till att skapa upp alla trådar som behövs direkt, och sedan får servern “hämta” trådar från en tråd-pool istället för att skapa upp nya själv.

Pseudo-kod:

while(true) {
    waitForClient() {
        Thread t = getThreadFromPool()
        t.handleClientRequest()
        returnToPool(t)
    }
}

Den här approachen är den som används av de flesta web-servrar. Man slipper overheaden med att skapa upp nya trådar, dock kommer man stöta på samma problem med context/thread switching om man satt ett för högt maximalt antal klienter (trådar).

Det här kommer alltså fungera bra så länge vi inte har krav på för många samtidiga klienter. Tyvärr är alltså inte heller detta tillräckligt bra för att lösa C10K-problemet, som var vår utgångspunkt.

“Lösningen”

För att verkligen lösa detta finns en fjärde approach, nämligen vad man kan kalla “Single-trådad asynkron / non-blocking server”.

Grundtanken här är att man precis som i punkt 1 låter servern köra på bara en tråd, men att själva hanteringen av klienter sker icke-blockande / asynkront. På så vis kommer man ifrån det första problemet att varje klient-anrop låser upp servern en viss tid och eftersom man bara använder en tråd så slipper man även undan problemet med tråd-swtichning.

Pseudokod:

while(true) {
    waitForClient() {
        handleClientAsyncronous()
    }
}

Vid första anblick är det väldigt likt lösningen som presenterades i 1. Den stora skillnaden är att hanteringen av varje klient sker asynkront, på så vis kan servern direkt ta hand om nästa anrop. Detta skalar även väldigt bra för fall när man behöver hantera “long-lived connections”, t ex en chat-tjänst, där klienterna hela tiden behöver vara uppkopplade mot chat-servern för att kunna ta emot meddelanden. En server som då använder sig av en tråd-pool kommer begränsa det maximala antalet klienter till tråd-poolens storlek. Ökar man storleken kommer fler klienter kunna vara uppkopplade, men samtidigt kommer overheaden (context-switch) öka.

Men en single-trådad server har man inget max-tak (ingen tråd-pool), och man slipper även undan problemet med ökad overhead för ökat antal klienter.

Deft web server

Det finns sedan tidigare ett antal servrar som använder sig av just en single-trådad lösning, t.ex. Tornado och node.js. Tornado är skriven i Python, och node.js är skriven i C++.

Med det här som bakgrund började jag och en person till (Roger Schildmeijer) undersöka möjligheterna att implementera en motsvarande web-server helt baserad på Java. Detta ledde så småningom fram till open source projektet “Deft“.

Deft är skriven helt i Java och använder sig av java.nio för alla underliggande I/O operationer.
För att kommunicera med servern används HTTP, och hanteringen av HTTP-request bestäms av något som vi kallar RequestHandlers. Deft implementerar en RequestHandler för att leverera statiska filer, t.ex. ett HTTP GET anrop för att hämta en bild.

Användare av Deft kan sedan skriva egna RequestHandlers där de implementerar den logik de själva vill ha.
Se gärna http://deftserver.org för exempel på hur en “HelloWorld”-RequestHandler kan se ut.

Performance

Något som är intressant i sammanhanget är så klart performance, hur många request servern faktiskt klarar av.

Detta avgörs givetvis till stor del av hur tung “logik” som varje request kräver, men för att kunna göra en jämförelse av hur Deft står sig mot Tornado och node.js, har vi implementerat “HelloWorld”-exemplet för de olika tre servarna.

För själva testandet har vi använt Apache HTTP Benchmarking “ab”.

Kommandot som kördes för testet var:

ab -k -c[5, 10, 15, 20, 25] -n800000 http://127.0.0.1:8080/

Grafen nedan visar hur många request per sekund de tre olika servarna klarar.

Deft klarar alltså nästan tre ggr fler req/sek är node.js, och runt nio ggr så mycket som Tornado! Detta var klart bättre siffror än vi vågat hoppas på när vi började utvecklingen av Deft.

Nästa steg i utvecklingen av Deft är bland annat bättre stöd av HTTP 1.1-specen och web socket support.

Utveckling och källkoden kan nås på http://github.com/rschildmeijer/deft.

En “getting-started-guide” finns på http://deftserver.org.

Ladda gärna ner och testa själva, och är det någon som har förslag på en bra logo så mottages tips tacksamt :)

//Jim Petersson