Viime aikoina Node.js on vilahdellut vähän väliä web-maailman blogeissa. Itse olen lukenut lähinnä tämän esittelyn, mutta se riitti kiinnostumaan aiheesta.

Node.js:n perusajatuksena on viedä selaimista tuttu JavaScriptin asynkroninen luonne palvelinpuolelle. Jokainen web-suunnittelija on joskus kirjoittanut tämän tapaisen koodinpätkän:

var msg = 'Sekunti kului';
setTimeout(function() {
    alert(msg);
}, 10000);

Idea on, että selain ei jää 10 sekunniksi jumiin odottelemaan ajan kulumista. Sen sijaan se ajastetaan setTimeout()-funktiolla jatkamaan ohjelman suoritusta 10 sekunnin kuluttua. Odottelun aikana skripti ei kuluta selaimen resursseja millään lailla. Erityisen tärkeää on, että skriptiä varten ei tarvitse varata omaa dedikoitua prosessia tai säiettä CPU:sta täksi ajaksi.

Closurejen ansiosta msg-muuttuja on edelleen käytettävissä, kun skriptin ajo jatkuu 10 sekunnin kuluttua. Tämä tekee asynkronisesta ohjelmoinnista JavaScriptillä yksinkertaista ja selkeää.

JavaScript palvelinpuolella

Kuvitellaan, että ylläoleva skripti ajetaankin palvelinpäässä ja se tekee pelkän odottelun sijasta tietokantahaun, joka kestää 10 sekuntia. Lopuksi se palauttaa vastauksen HTTP-pyyntöön. Oletan tässä, että tietokantahaku tehdään CouchDB:hen REST-pyyntönä jQueryn avulla:

jQuery.getJSON('http://.../_view/recent', function(data) {
    response.sendHeader(...)
    response.sendBody('<html>....</html>');
});
// Oikeasti tätä ei tehtäisi Node.js:ssä jQueryllä.

Mekanismi on täsmälleen sama kuin selaimen JavaScriptissäkin. Sillä välin kun REST-pyyntöön odotellaan vastausta, skripti ei varaa dedikoitua prosessia tai säiettä. Se aktivoituu vasta sitten, kun tietokanta lopulta palauttaa vastauksen hakupyyntöön.

Tämä tarkoittaa, että palvelin voi käsitellä tuhansittain yhtaikaisia hakupyyntöjä, jotka eivät jää varaamaan prosesseja tai säikeitä CPU:sta. Tyypillinen prefork-Apache-palvelin taas pystyy palvelemaan vain noin 100-200 yhtaikaista pyyntöä, koska jokaiselle luodaan erillinen prosessi koko pyynnön ajaksi.

Ei MySQL:lle?

Asynkronisen web-palvelimen edellytys on, että sivuja muodostettaessa käytetään asynkronisia I/O-operaatioita. Tämä tarkoittaa yleensä lähinnä tietokantapyyntöjen tekemistä non-blocking-yhteyksillä. Pyyntö lähetetään ensin tietokantapalvelimelle, jonka jälkeen ei jäädä odottelemaan vastausta, vaan pannaan ohjelma nukkumaan kunnes vastaus saapuu joskus myöhemmin.

CouchDB:lle tämä malli sopii mainiosti, koska se toimii natiivisti juuri näin. Tietokantahaku lähetetään ensin CouchDB:lle HTTP-pyyntönä, ja jonkin ajan kuluttua siihen saadaan HTTP-vastaus.

Mikä parasta, CouchDB toimii sisäisestikin ilman säikeitä. Yhtaikaisten asiakkaiden määrälle ei ole sinänsä rajoitusta. Pyyntöjen käsittely vain kestää vähän pitempään, kun palvelin kuormittuu.

MySQL:n client-protokolla on taas suunniteltu blokkaavaksi ja se muodostuu monesta eri vaiheesta. Asiakas lähettää ensin handshake-paketin johon palvelin vastaa vastaavalla paketilla. Sitten asiakas lähettää autentikointipaketin ja odottelee jälleen vastausta. Sitten lähetellään erilaisia prepare- ja execute- ja fetch-paketteja ja odotellaan aina kuhunkin vastausta. Yhteyden muuttaminen asynkroniseksi vaatisi koko asiakaskirjaston ja sen rajapintojen uudelleensuunnittelun puhtaalta pöydältä.

Tämän lisäksi MySQL toimii sisäisesti säiepohjaisesti. Kun uusi asiakas avaa siihen yhteyden, asiakkaalle luodaan säie yhteyden ajaksi. Näitä säikeitä voi olla vain tietty määrä yhtaikaa käynnissä. Jos pitkäkestoisia kyselyitä kasaantuu riittävästi, uudet asiakkaat eivät pääse enää sisään ja palvelin alkaa herjata virheilmoituksia.

Miksi säikeet ovat huono juttu

Tämän koko asynkronisuuden pihvi on siinä, että säikeet (kuten prosessitkin) ovat raskaita. Käyttöjärjestelmiä ei ole suunniteltu siten, että palvelinohjelmat voisivat luoda tuhansia tai kymmeniä tuhansia säikeitä asiakkaita palvelemaan. Jokaiselle säikeelle täytyy pitää yllä esimerkiksi erillistä stack-muistialuetta ja muuta kontekstitietoa, ja niiden skedulointiin ja context-switchaamiseen kuluu ylimääräistä prosessoriaikaa.

Asynkroninen I/O taas on huomattavasti kevyempää, koska muistissa pidetään käyttöjärjestelmän puolesta lähinnä muutaman tavun kokoisia deskriptoreita/pointtereita per yhteys. Linuxin epoll-rajapinta on suunniteltu siten, että yhteyksien määrällä ei ole nopeudelle suoraan merkitystä, koska deskriptorit ja callback-funktiot etsitään O(1)-algoritmeilla. Palvelinsofta voi huoleti ladata epoll-rajapintaan kymmeniä tuhansia rinnakkaisia I/O-operaatioita, ja odotella sitten kaikessa rauhassa niiden valmistumista.