Non-blocking netwerkarchitectuur: hoe één server duizenden verbindingen tegelijk aankan
Stel je een receptioniste voor die achter een balie zit met tien telefoons. De ouderwetse aanpak is dat ze één telefoon opneemt, het hele gesprek afwacht tot de beller klaar is, ophangt, en pas dan de volgende telefoon opneemt. Als een beller lang stilvalt om iets op te zoeken, staat de receptioniste werkeloos te wachten en blijven de andere negen telefoons rinkelen. Non-blocking netwerkarchitectuur is de softwareversie van een receptioniste die anders werkt: ze neemt kort op, hoort de vraag, legt de hoorn even neer zodra ze weet dat de beller moet opzoeken wat hij wil zeggen, en pakt intussen een andere telefoon op. Zodra de eerste beller weer iets te melden heeft, komt zij terug. Zo bedient één persoon tien lijnen tegelijk, zonder ooit werkeloos te wachten.
In computertermen gaat het om de manier waarop serversoftware omgaat met netwerkverkeer: webverzoeken, chatberichten, databasequery's. Bij de klassieke aanpak krijgt elke verbinding zijn eigen 'medewerker' (een thread of proces) die blokkeert, dus stilstaat, zodra hij op een antwoord van bijvoorbeeld een harde schijf of een ander systeem moet wachten. Non-blocking architectuur laat één of enkele medewerkers duizenden verbindingen tegelijk bedienen, door nooit te wachten maar steeds door te schakelen naar werk dat wél klaarstaat. Dat klinkt als een detail voor programmeurs, maar het is de reden dat een enkele server tegenwoordig tienduizenden gebruikers tegelijk kan bedienen zonder duur te worden.
Wat is het precies?
Een netwerkserver moet voortdurend wachten: op binnenkomende data, op een antwoord van een database, op de harde schijf die een bestand leest. Die wachttijd is voor een computer enorm lang, al is het maar een paar milliseconden. In de klassieke, 'blokkerende' aanpak stopt het programma domweg met verder werken tot het antwoord er is. Om toch meerdere gebruikers tegelijk te bedienen, start een server dan voor elke verbinding een apart proces of een aparte thread (een lichte, parallelle uitvoeringslijn binnen een programma). Elke thread mag zelf blokkeren, want de andere threads draaien gewoon door. Het probleem: elke thread kost geheugen en de computer moet voortdurend tussen threads schakelen, wat overhead (extra rekenwerk zonder nuttige opbrengst) met zich meebrengt. Bij duizenden gelijktijdige verbindingen loopt dit vast; dit staat bekend als het 'C10k-probleem', genoemd naar het doel om tienduizend (10.000) gelijktijdige verbindingen op één server te ondersteunen.
Non-blocking netwerkarchitectuur lost dit anders op. In plaats van te wachten, vraagt het programma het besturingssysteem: 'zeg het maar als er iets te doen is'. Dit gebeurt via een mechanisme dat een event loop heet: een centrale lus die continu controleert welke van de vele open verbindingen klaarstaat om verwerkt te worden, en die taken één voor één kort afhandelt zonder ooit te blokkeren. Het besturingssysteem helpt hierbij met efficiënte meldsystemen, zoals epoll onder Linux of kqueue onder BSD en macOS, die in staat zijn duizenden verbindingen tegelijk te bewaken zonder dat de software ze allemaal apart hoeft te controleren.
Voor de programmeur voelt dit meestal aan als asynchrone code: functies die niet direct een antwoord teruggeven, maar een belofte (in veel talen 'promise' of 'future' genoemd) of die een callback-functie aanroepen zodra het resultaat er is. Moderne programmeertalen maken dit leesbaarder met constructies als async/await, waarmee asynchrone code er bijna uitziet als gewone, sequentiële code, terwijl onder de motorkap alles non-blocking verloopt.
Wat wil men ermee bereiken?
Het hoofddoel is schaalbaarheid: met zo min mogelijk geheugen en rekenkracht zoveel mogelijk gelijktijdige gebruikers bedienen. Een thread-per-verbinding-server verbruikt al snel enkele megabytes geheugen per verbinding; een non-blocking server kan eenzelfde verbinding met slechts enkele kilobytes afhandelen. Dat scheelt niet alleen serverkosten, maar maakt ook diensten mogelijk die voorheen onbetaalbaar waren, zoals realtime chatapplicaties, livestreams met chatfunctie, of systemen die veel gelijktijdige, lang openstaande verbindingen nodig hebben (denk aan notificaties die direct binnenkomen zonder dat een app steeds hoeft te vernieuwen).
Een tweede doel is efficiënter gebruik van hardware. Threads wisselen kost tijd doordat de processor de volledige uitvoeringscontext moet omschakelen (context switching). Een event loop die op één thread draait, vermijdt dat grotendeels en kan daardoor, bij het juiste soort werk, meer verzoeken per seconde verwerken met dezelfde processor.
Belangrijk is wel de beperking: non-blocking architectuur helpt vooral bij I/O-gebonden taken (input/output, zoals netwerk- en schijfverkeer), waarbij een programma vooral wacht. Voor CPU-intensieve taken, zoals zware berekeningen of videobewerking, biedt het weinig voordeel en kan het zelfs slechter presteren dan een aanpak met meerdere threads of processen, omdat één blokkerende berekening dan de hele event loop kan vastzetten.
Voorbeelden uit de praktijk
De webserver Nginx, in 2004 uitgebracht door de Russische ontwikkelaar Igor Sysojev, was een van de eerste grote successen van deze aanpak. Sysojev bouwde Nginx expliciet als antwoord op het C10k-probleem, met een event-driven ontwerp in plaats van het proces-per-verbinding-model van de toen dominante webserver Apache. Nginx draait vandaag op een groot deel van 's werelds websites.
Node.js, gelanceerd door Ryan Dahl in 2009, bracht non-blocking netwerkprogrammering naar JavaScript-ontwikkelaars. Node.js combineert de V8 JavaScript-engine van Google met de bibliotheek libuv, die de event loop en non-blocking I/O verzorgt. Het werd populair omdat het dezelfde taal als in browsers (JavaScript) ook aan de serverkant bruikbaar maakte voor snel, gelijktijdig netwerkwerk.
Netty, een Java-framework dat sinds 2004 in ontwikkeling is (aanvankelijk bij JBoss, later door de gemeenschap voortgezet), biedt vergelijkbare non-blocking netwerkfunctionaliteit voor de Java-wereld en wordt onder meer gebruikt door grote techbedrijven voor zware, gelijktijdige netwerktoepassingen.
De veelgebruikte database Redis, uitgebracht in 2009 door Salvatore Sanfilippo, verwerkt commando's van cliënten via één enkele, non-blocking event loop, wat bijdraagt aan de beroemde snelheid van het systeem bij veel gelijktijdige, korte verzoeken.
Ook de programmeertaal Go, ontwikkeld door Google en uitgebracht in 2009, verdient een vermelding: Go verbergt de non-blocking mechanismen achter zogeheten goroutines, lichte, door de taal zelf beheerde uitvoeringslijnen die intern gebruikmaken van non-blocking netwerkoproepen (de 'netpoller'), zodat programmeurs gewoonlijk ogende, blokkerend lijkende code kunnen schrijven die er onder water toch efficiënt en non-blocking uitziet.
Hoe ver is de techniek?
Non-blocking netwerkarchitectuur is inmiddels volwassen en breed ingeburgerd technologie, geen experimentele richting meer. Vrijwel elke moderne programmeertaal biedt inmiddels ingebouwde ondersteuning voor asynchrone I/O, van Python (met de asyncio-bibliotheek, sinds 2014 stap voor stap uitgebreid) tot C# (async/await, geïntroduceerd in 2012) en Rust (met externe runtimes zoals Tokio).
Toch blijft het vakgebied in beweging. Een belangrijke recente ontwikkeling is io_uring, een nieuw asynchroon I/O-mechanisme dat Linux-kernelontwikkelaar Jens Axboe in 2019 aan de Linux-kernel toevoegde. Het vermindert de overhead van eerdere mechanismen zoals epoll verder en wordt sindsdien geleidelijk overgenomen door databases, webservers en programmeertaal-runtimes, al is de adoptie nog niet compleet omdat oudere Linux-versies het niet ondersteunen en de programmeerinterface complexer is.
Een blijvend obstakel is de leesbaarheid en foutgevoeligheid van asynchrone code. Ontwikkelaars spreken soms van 'callback hell': geneste, moeilijk te volgen code wanneer veel asynchrone stappen na elkaar moeten gebeuren. Async/await-syntax heeft dit sterk verbeterd, maar het debuggen van non-blocking systemen blijft lastiger dan bij eenvoudige, sequentiële code, onder meer omdat foutmeldingen en stacktraces (overzichten van de aanroepvolgorde bij een fout) minder overzichtelijk zijn. Ook blijft een enkele, per ongeluk blokkerende operatie een bekende valkuil: die kan een hele event loop en dus alle gelijktijdige verbindingen laten vastlopen.
Wie werken eraan?
Het veld is sterk verspreid over bedrijven, open source-gemeenschappen en de Linux-kernelontwikkeling. De OpenJS Foundation (voorheen de Node.js Foundation), met steun van onder meer IBM, Microsoft en Google, beheert de ontwikkeling van Node.js. Nginx werd oorspronkelijk door Igor Sysojev geleid en is sinds de overname van het bedrijf Nginx Inc. door het Amerikaanse F5 in 2019 daar ondergebracht, naast een levendige open source-gemeenschap.
Aan de kernelkant werkt Jens Axboe, in dienst bij verschillende bedrijven waaronder momenteel het opslagbedrijf dat io_uring mede financiert, samen met de bredere Linux-kernelgemeenschap aan de verdere ontwikkeling van io_uring. Google onderhoudt de programmeertaal Go en daarmee ook de onderliggende non-blocking netpoller. Redis Ltd. (voorheen Redis Labs) begeleidt de ontwikkeling van Redis, terwijl grote clouddiensten zoals Amazon Web Services, Microsoft Azure en Google Cloud non-blocking architecturen gebruiken in hun eigen interne infrastructuur en die ook aanbieden aan klanten. Academisch onderzoek naar schaalbare netwerksystemen wordt onder meer gepubliceerd via USENIX-conferenties, waar veel van de onderliggende ideeën over event-driven servers en het C10k-probleem oorspronkelijk zijn besproken.