Über unsMediaKontaktImpressum
Sven Kölpin 19. März 2019

Node.js: Unblock the Event-Loop

Node.js ist durch das asynchrone und eventbasierende Ausführungsmodell oftmals leichtgewichtiger als andere Ansätze und hat sich deshalb längst für skalierbare, serverseitige Anwendungen etabliert. Inzwischen setzen Technologiegiganten wie Netflix, PayPal, Twitter, eBay, Microsoft und sogar die NASA in den verschiedensten Bereichen auf die Runtime. Allerdings können bereits kleine Unachtsamkeiten große Auswirkungen auf den Durchsatz einer Node.js-Anwendung haben. Deshalb gibt es eine goldene Regel: Blockiere niemals den Event-Loop.

Architektur

Um die Funktionsweise von Node.js zu verstehen, lohnt sich ein Blick auf die Architektur der Runtime. Diese unterscheidet sich in vielen Punkten von anderen Umgebungen, wie zum Beispiel der Java Virtual Machine (JVM). Node.js basiert auf einem eventgetriebenen, asynchronen Ausführungsmodell. Die Plattform ist somit vornehmlich darauf ausgelegt, JavaScript-Code in nur einem einzigen Thread auszuführen. Damit eine Node.js-Anwendung trotzdem auch unter einer hohen Last einen guten Durchsatz (Verarbeitung von Anfragen/Sekunde) aufweist, werden etwaige blockierende Eingabe- und Ausgabeoperationen (I/O-Operationen) an andere Teile des Systems delegiert. Die wichtigsten Bestandteile einer Node.js-Instanz sind Prozesse & Threads, der Event-Loop und der Worker-Pool.   

Prozesse & Threads

Jede in Node.js entwickelte Anwendung wird in einem separaten Prozess ausgeführt. Ein Prozess oder Task stellt eine Umgebung zur Verfügung, in der Ressourcen vom Betriebssystem bereitgestellt werden. Beispielsweise nutzt jeder Task seinen eigenen Bereich im Hauptspeicher, eine direkte Kommunikation zwischen Prozessen ist also nicht ohne weiteres möglich. Ein Prozess hat mindestens einen Thread (Haupt-Thread) und kann beliebige weitere Threads starten. Im Gegensatz zu Prozessen teilen diese sich Systemressourcen wie zum Beispiel einen Speicherbereich. Das macht eine Kommunikation mit geringerem Overhead möglich.

Auch innerhalb eines Node.js-Prozesses werden Threads verwendet, jedoch ist nur der Haupt-Thread direkt für Entwickler verwendbar. Die manuelle Erstellung weiterer Threads war bislang nicht möglich. Deshalb wird Node.js oftmals auch als single-threaded bezeichnet. Neben dem eigenen JavaScript-Code wird im Haupt-Thread noch die JavaScript-Engine V8 sowie das Herzstück von Node.js, der Event-Loop, ausgeführt.

Event Loop

Node.js basiert auf einem eventgetriebenen Modell, das durch den Event-Loop gesteuert wird. Im Groben besteht dessen Hauptaufgabe darin, auf Events des Betriebssystems zu reagieren und gegebenenfalls in JavaScript definierte callbacks auszuführen. Vereinfacht gesagt kann man sich den Event-Loop demnach als eine Art von nicht-blockierender while-Schleife vorstellen, die auf externe Events (z. B. Netzwerk-Requests) wartet und als direkte Reaktion vom Entwickler definierte JavaScript-Funktionen aufruft. Die Ausführung einer solchen Funktion ist stets synchron, blockiert also den Haupt-Thread für einen gewissen Zeitraum. Innerhalb eines Funktionsaufrufes können stets beliebige weitere callback-Funktionen registriert werden, beispielsweise um auf Events weiterer, nicht-blockierender Operationen wie Datenbankabfragen oder Dateisystemzugriffe, zu hören. Während das Registrieren von callbacks in frühen Versionen von Node.js nur vergleichsweise unschön über Funktionsparameter umsetzbar war, werden heute eher Promises und async/await dazu verwendet, auf asynchrone Events zu lauschen (Listing 1).

Listing 1: Callbacks in Node.js

const fs = require('fs');
const { promisify } = require('util');

//read directory with a callback (early Node.js versions).
fs.readdir('./', (error, files) => {
  if (error) {
    console.error(err);
  } else {
    console.log(files);
  }
});

//promisify the API (since Node.js v8.0.0).
//This way we can use fs with promises & async / await
const readDir = promisify(fs.readdir);

//read directory with a promise
readDir('./')
  .then(files => console.log(files))
  .catch(error => console.error(error));

//read directory with async / await
async function readAsync() {
  try {
    const files = await readDir('./');
    console.log(files);
  } catch (error) {
    console.error(e);
  }
}
readAsync();

Jede Iteration des Event-Loops durchläuft verschiedene Phasen. In diesen werden unter anderem auf I/O-Events gewartet (Phase poll) oder gesondert registrierte callbacks ausgeführt (Phase check). Die check-Phase wird im weiteren Verlauf dieses Artikels noch eine Rolle spielen. Eine detaillierte Beschreibung der Phasen sowie die genaue Funktionsweise des Event-Loops lässt sich in der offiziellen Dokumentation nachlesen [1].

Worker-Pool

Mit dem Worker-Pool gibt es in Node.js noch einen weiteren essenziellen Bestandteil. Vereinfacht gesagt wird dieser dafür verwendet, "teure" Aufgaben zu übernehmen, um den Haupt-Thread freizuhalten. Zu diesen zählen vornehmlich blockierende I/O-Operationen und CPU-intensive Berechnungen. Dazu verwaltet das Modul einen eigenen Thread-Pool und kann so etwaige blockierende Prozesse parallelisieren. Die Node.js-Runtime ist also gar nicht wirklich single-threaded. Lediglich der eigene JavaScript-Code wird in nur einem Event-Loop-Thread ausgeführt.

Der Worker-Pool wird von libuv verwaltet. Diese in C geschriebene Bibliothek ermöglicht eine effiziente Handhabung asynchroner I/O-Operationen und sorgt dafür, dass Node.js ressourcenschonend bleibt. Wann immer es möglich ist, verwendet libuv dazu native asynchrone I/O-APIs, die heute in den meisten Betriebssystemen verfügbar sind (z. B. AIO bei Linux). So lässt sich ein Blockieren von Threads im Worker-Pool für viele Operationen vermeiden. CPU-intensive Tasks oder solche, die keine nativen asynchronen APIs bereitstellen, müssen aber Threads des Worker-Pools verwenden.

Die Stärken

Node.js eignet sich durch die nicht-blockierende Architektur vornehmlich für Anwendungen, die ein hohes I/O-Aufkommen, aber keine CPU-intensiven Operationen haben. Das ist bei den meisten web-basierten Anwendungen der Fall und deshalb einer der Hauptgründe für den Erfolg in diesem Bereich.

Die Ressourceneffizienz von Node.js ist vor allem darin begründet, dass auch bei hoher Last vergleichsweise wenige Threads benötigt werden. Das trägt im Allgemeinen zu einem geringeren Verbrauch von Systemressourcen bei, weil die verfügbare Prozessorzeit auf weniger Threads aufgeteilt werden muss. Das Hin- und Herwechseln zwischen Threads (context-switching) verursacht nämlich einen gewissen Overhead. Je mehr context-switching betrieben werden muss, desto geringer ist die Wahrscheinlichkeit, dass die für den aktuellen Thread benötigten Daten (Kontextdaten) in dem CPU-Cache verfügbar sind. Die Kontextdaten müssen deshalb beim Kontextwechsel zunächst vergleichsweise aufwändig aus dem Hauptspeicher geladen und später wieder zurückgeschrieben werden. Neben dem zeitlichen Mehraufwand steigt so durch zu viele parallele Threads auch der Speicherverbrauch an [2]. Eine Architektur mit wenigen Threads, wie sie bei Node.js vorliegt, ist also im Allgemeinen ressourceneffizient und gut skalierbar.

Das Ausführungsmodell von Node.js bietet außerdem auch Vorteile während der Entwicklung. Das menschliche Gehirn ist schließlich darauf ausgelegt, dass Aufgaben sequenziell ("in einem Thread") und nicht parallel ("in mehreren Threads gleichzeitig") abgearbeitet werden. Der Single-threaded-Ansatz von Node.js macht das Entwicklerleben so in vielen Bereichen einfacher. Zusätzlich muss man sich bei diesem Vorgehen nicht mehr um die typischen Probleme der parallelen Programmierung kümmern (z. B. race conditions und dead locks).

Die Schwächen

Leider hat das Ausführungsmodell von Node.js einen entscheidenden Nachteil. Wie eingangs erwähnt, wird nicht nur der eigene Code, sondern der gesamte Event-Loop in einem einzigen Thread verarbeitet. Nutzt man Node.js als Server, so werden also alle eingehenden HTTP-Anfragen und ausgehenden Antworten an nur einer Stelle verarbeitet.

Die Folge davon ist, dass eine Blockade des Haupt-Threads, beispielsweise durch CPU-intensive oder blockierende I/O-Operationen, den Event-Loop und damit die gesamte Anwendung zum "Stillstand" bringt. I/O-Callbacks, zum Beispiel von HTTP- oder Datenbankanfragen, können dann nicht verarbeitet werden. Dadurch wird der Gesamtdurchsatz einer Anwendung verringert und es kann im schlimmsten Fall sogar dazu kommen, dass der Server gar keine Anfragen mehr annehmen kann.

Auch eine Überlastung des Worker-Pools kann negative Auswirkungen auf den Durchsatz einer Anwendung haben. libuv allokiert standardmäßig einen Pool mit einer Größe von vier Threads. Für CPU-gebundene Aufgaben oder I/O-Operationen, die keine asynchronen APIs nutzen, müssen Threads des Worker-Pools temporär blockiert werden. Zu viele parallele Anfragen verlangsamen somit auch die, aus Sicht des Event-Loops, asynchrone Verarbeitung von Prozessen im Worker-Pool.

Native Node.js-APIs, die zu einer Blockade der Threads des Worker Pools beitragen, sind beispielsweise in den Modulen fs[3] und zlib[4] zu finden. Deshalb sollte das Einlesen großer Dateien ohne die Verwendung von Streams (via fs.readFile [5]) vermieden und das Komprimieren von Antworten (via zlib.Gzip[6]) für Anwendungen mit hoher Last außerhalb von Node.js vorgenommen werden (z. B. durch den Einsatz eines Reverse Proxy wie nginx[7]).

Eine Vergrößerung der Thread-Anzahl im Worker-Pool führt im Übrigen nur selten zu einer Verbesserung. Im Gegenteil kann ein zu großer Pool, je nach Hardware, sogar negative Auswirkungen auf den Durchsatz haben (z. B. aufgrund des höheren Ressourcenverbrauchs durch context-switching).

Unblock the Event-Loop

Selbst bei der Verwendung von JavaScript-Code, der nicht für den Event-Loop optimiert ist, reicht die Leistungsfähigkeit von Node.js-Anwendungen zumeist vollkommen aus. Vor allem die schnelle JavaScript-Engine V8 trägt maßgeblich dazu bei, dass die JavaScript-callbacks effizient ausgeführt werden. Dennoch können aufwändige Schleifen oder andere CPU-intensive Aufgaben, dazu zählt übrigens auch das Parsen großer JSON-Strings (via JSON.parse()), die Performanz des Event-Loops negativ beeinflussen. Deshalb sollten JavaScript-Funktionen in Node.js immer unter der Prämisse entwickelt werden, dass diese nur wenige CPU-Zyklen benötigen. So lässt sich ein zu langes Blockieren des Haupt-Threads verhindern, was wiederum positive Auswirkungen auf den Durchsatz der Anwendung hat. Ohne Weiteres ist das aber leider in der Realität nicht immer möglich. Glücklicherweise gibt es verschiedene Pattern und APIs, die das "Freihalten" des Event-Loop-Threads auch bei eigentlich blockierenden Operationen ermöglichen. Zu diesen gehören Offloading, Partitioning, Clustering sowie seit neuestem auch Worker Threads.

Offloading

Auch wenn es trivial klingt: Am einfachsten lassen sich aufwändige Berechnungen und blockierende Operationen außerhalb von Node.js umsetzen. Wie bereits erwähnt, zeigt die Plattform ihre Stärken vor allem bei I/O-intensiven Aufgaben. Das Delegieren von langwierigen oder aufhaltenden Prozessen ist somit fester Bestandteil der gesamten Architektur. Beispielsweise ist es deshalb vollkommen valide, Tasks über HTTP (o. ä.) an externe Services zu übergeben und so asynchron, also ohne zu blockieren, auf das Ergebnis zu "warten" (Listing 2).

Auch mit Hilfe von C++-Addons [8] und der noch relativ jungen N-API [9] lassen sich blockierende Aufgaben auslagern. Diese APIs erlauben es, native – also in C oder C++ geschriebene – Addons für Node.js zu entwickeln und diese im Worker-Pool ablaufen zu lassen. Zwar lässt sich blockierender Code so außerhalb des Event-Loops und mit geringem Kommunikationsaufwand ausführen, es gelten aber trotzdem die vorher erwähnten Limitierungen.

Listing 2: : Offload to external an service

//node-fetch is an external module. It can be installed via "npm i node-fetch".
const fetch = require('node-fetch');

const calculatePi = async () => {
  const pi = await fetch('https://calculte-pi.math').then(response => response.json());
  //...
};
calculatePi();

Partitioning

In Node.js ist der Entwickler dafür verantwortlich, dass jede Anfrage an den Server im zeitlichen Sinne fair behandelt wird. Aufwändige Berechnungen wie große Schleifen sollten deshalb idealerweise in mehrere Abschnitte unterteilt werden (Partitioning), anstatt eine lange blockierende Berechnung vorzunehmen. Durch Partitionierung bekommen alle Serveranfragen immer wieder die Chance, ein paar CPU-Zyklen zu nutzen. Dadurch ist auch bei langlaufenden Prozessen ein gleichmäßiger Durchsatz möglich. Auf der anderen Seite verursacht das Vorgehen selbstverständlich eine Erhöhung der Gesamtberechnungszeit, weil Schleifen o. ä. immer wieder unterbrochen werden.

Partitioning kann in Node.js unter Zuhilfenahme des Event-Loops umgesetzt werden. Wie bereits erwähnt, besteht dieser aus mehreren Phasen. Für das Unterteilen einer langen Berechnung in kleinere Abschnitte ist die check-Phase geeignet. Die callbacks dieser werden dann ausgeführt, wenn keine weiteren I/O-callbacks mehr abgearbeitet werden müssen. In diesem Fall hat der Event-Loop-Thread gerade nichts zu tun und würde andernfalls warten [10]. In der check-Phase lassen sich deshalb immer wieder kurze blockierende Operationen ausführen, ohne den Haupt-Thread dauerhaft zu belegen. Das Registrieren von callbacks für diese Phase ist über die setImmediate()-Funktion möglich. Listing 3 zeigt ein einfaches Beispiel. Das Vorgehen ist selbstverständlich nur begrenzt einsetzbar. Beispielsweise eignet es sich vornehmlich für aufwändige Schleifen. Bei komplexen anderweitigen Berechnungen sollten besser alternative Methoden gewählt werden (z. B. Offloading).

Listing 3: Partitioning

const server = require('./server');

const VERY_BIG_NUMBER = 1000000000;
const CHUNK_SIZE = 1000000;

//server listens for GET requests on route “/”.
server.get('/', (request, response) => useTheEventLoop(response));

const useTheEventLoop = response => {
  let i = 0;
  const increase = () => {
    let chunk = 0;
    let done = false;

    //do some work.
    while (chunk < CHUNK_SIZE) {
      i++;
      chunk++;
      if (i === VERY_BIG_NUMBER) {
        done = true;
        break;
      }
    }

    //check if we're done
    if (done) {
      response.send({ result: i });
    } else {
      //if not, reschedule
      setImmediate(increase);
    }
  };
  increase();
};

Clustering

Clustering ist ein natives Feature von Node.js. Die Plattform ist darauf ausgelegt, auf mehreren Knoten (daher auch der Name Node.js) parallel betrieben zu werden. Ein Node.js-Prozess kann deshalb ohne großen Aufwand beliebige Kinder-Prozesse starten und so einen höheren Durchsatz erzeugen. Die Prozesse verwenden dabei die gleichen Ports, sodass Anfragen auf die verschiedenen Instanzen verteilt werden können. Vor allem auf Multi-Core-CPU-Systemen lässt sich so das volle Potential ausschöpfen.

Jeder gestartete Prozess ist vollwertig, hat also beispielsweise einen eigenen Speicherbereich und eine eigene Instanz der V8-Engine. Das Erzeugen von Kinder-Instanzen ist also vergleichsweise schwergewichtig und sollte nicht zu exzessiv genutzt werden (z. B. kein Prozess pro Request). Eine Faustregel ist, nicht mehr Instanzen zu erzeugen, als das Hostsystem CPU-Kerne hat. So lässt sich eine optimale Ressourcenausnutzung mit geringem Overhead erreichen.

Eine direkte Kommunikation zwischen den Node.js-Prozessen ist aufgrund der verschiedenen Speicherbereiche nicht möglich. Ein Austausch von Daten zwischen den Instanzen kann aber über Nachrichten (Inter-process communication) erfolgen. Diese müssen dazu allerdings serialisiert beziehungsweise geparsed werden, was einen gewissen Kommunikations-Overhead zur Folge hat.

Ein Vorteil der Prozessisolation ist, dass etwaige Ausnahmefälle und schwerwiegende Fehler die anderen Prozesse nicht beeinflussen. Selbst beim Absturz eines Kind-Prozesses können die Übrigen bedenkenlos weiterlaufen. Prozesse können programmatisch (Listing 4) oder mit der Hilfe von Tools, zum Beispiel pm2[11], erzeugt werden.

Listing 4: Clustering

const cluster = require('cluster');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  startMasterProcess();
} else {
  startChildProcess();
}

function startMasterProcess() {
  console.log(`Master ${process.pid} up.`);

  //start N child processes.
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  //add listener to handle the exit of a child process.
  cluster.on('exit', (worker, code, signal) => {
    console.error(`Worker ${worker} died. {code:${code},signal:${signal}. Restarting...`);
    //restart child process when sth. goes wrong.
    cluster.fork();
  });
}

function startChildProcess() {
  console.log(`Worker ${process.pid} started`);
  if (Math.random() > 0.8) {
    //kill process randomly.
    throw new Error();
  }
}

Worker-Threads

Seit der Version 11.7 unterstützt Node.js offiziell die Erzeugung von Threads durch Entwickler. JavaScript-Code kann nun also parallel zum Haupt-Thread innerhalb des gleichen Prozesses ausgeführt werden. Worker-Threads teilen sich, im Gegensatz zu den Kind-Prozessen beim Clustering, einen Speicherbereich. Sie können deshalb mit vergleichsweise geringem Overhead Daten austauschen. Ansonsten verhalten sich Worker-Threads allerdings ähnlich zu Prozessen. Beispielsweise wird auch hier eine V8-Engine, ein Worker-Pool und eine Node.js-Instanz pro Thread verwendet [12]. Worker-Threads sind also nicht gerade leichtgewichtig. In der offiziellen Dokumentation wird deshalb dazu geraten, stets Thread-Pools zu verwenden, um die Kosten für die Erzeugung möglichst gering zu halten.

Das API erinnert an die bereits aus dem Browser bekannten Web Workers. Im Gegensatz zu diesen können Entwickler in Worker-Threads aber alle APIs nutzen, die auch im Haupt-Thread zur Verfügung stehen. Beispielsweise kann require ohne Einschränkungen verwendet und beliebige Module importiert werden. Die einzige Limitierung liegt darin, dass kein globaler Status (z. B. env-Variablen oder Working-Directory) aus den Worker-Scripts heraus veränderbar ist. So können unter anderem race-conditions verhindert werden. Listing 5 zeigt die Verwendung von Worker Threads.

Listing 5: Worker-Threads

//File: index.js
const server = require('./server');
const { Worker } = require('worker_threads');
const path = require('path');

const VERY_BIG_NUMBER = 1000000000;

//server listens for GET requests on route “/”.
server.get('/', (request, response) => calcInOtherThread(response));

const calcInOtherThread = async response => {
  //wait for the result (not blocking).
  const result = await createWorker();
  //send response.
  response.send({ result });
};


const createWorker = () => new Promise((resolve, reject) => {
  //load worker and pass initial data.
  const worker = new Worker(path.resolve(__dirname, 'increase.worker.js'), {
    workerData: VERY_BIG_NUMBER,
  });
  //resolve on message, reject otherwise.
  worker.on('message', resolve);
  worker.on('error', reject);
  worker.on('exit', reject);
});

//File: increase.worker.js
const {
  parentPort,
  workerData,
} = require('worker_threads');


const increase = () => {
  let i = 0;

  //the workerData contains the "VERY_BIG_NUMBER"-value
  while (i < workerData) {
    i += 1;
  }
  //tell the Main-Thread that we're done.
  parentPort.postMessage(i);
};

increase();

Die Kommunikation zwischen den Worker-Threads ist über MessagePorts realisiert [13]. Diese erlauben es, Informationen asynchron zwischen den Threads auszutauschen. Es gibt hierfür zwei verschiedene Wege:

  • Zum einen können Daten vor dem Senden geklont werden. Der Algorithmus dazu basiert auf dem bereits aus Web Workern bekannten structured clone algorithm [14]. Dieser hat einige Limitierungen, es können zum Beispiel keine Funktionen oder Error-Objekte geklont und damit zwischen den Threads geteilt werden. Zirkuläre Datenstrukturen sind aber problemlos übertragbar. Das Klonen führt dazu, dass niemals parallel auf denselben Objekten gearbeitet wird. Eine Mutation von Daten nach dem Senden oder Empfangen hat also keine Auswirkungen auf die der anderen Threads.
  • Zum anderen ist es möglich, Speicherbereiche zu teilen oder zu übertragen. Das ist vor allem dann sinnvoll, wenn große Datenstrukturen zwischen den Threads ausgetauscht werden müssen. Das Teilen von Speicherbereichen ist mit der Datenstruktur SharedArrayBuffer möglich [15]. Das Transferieren mit Hilfe von einfachen Arraybuffer-Objekten [16]. Listing 6 und 7 zeigen dazu ein Beispiel.

Listing 6: Share memory between Threads

//File: index.js
const path = require('path');
const {Worker} = require('worker_threads');

const shareMemoryWithThread = async () => {
    //Create 4 byte of shared memory
    const sharedMemory = new SharedArrayBuffer(4);

    const myArray = new Int32Array(sharedMemory);

    console.log(myArray[0]); //prints 0
    await runWorker(sharedMemory);
    console.log(myArray[0]); //prints 42 -> memory was mutated in thread.
};


const runWorker = memoryToUse => new Promise((resolve, reject) => {
    //load worker.
    const worker = new Worker(path.resolve(__dirname, 'worker.js'));
    //share the memory with this worker.
    worker.postMessage(memoryToUse);

    //resolve on message, reject otherwise.
    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', reject);
});


shareMemoryWithThread();
//File: worker.js
const {parentPort} = require('worker_threads');

parentPort.on('message', data => {
    const array = new Int32Array(data);
    array[0] = 42; //Mutate the shared memory.
    parentPort.postMessage('Done');
});

Listing 7: Transfer memory to Worker Thread

//File: index.js
const path = require('path');
const {Worker} = require('worker_threads');

const transferMemoryToThread = async () => {
    //Create 4 byte of transferable memory
    const transferableMemory = new ArrayBuffer(4);

    const myArray = new Int32Array(transferableMemory);
    //mutate memory
    myArray[0] = 42;

    console.log('BEFORE', myArray[0]); //prints 42
    await runWorker(transferableMemory);
    console.log('AFTER', myArray[0]); //prints undefined. Memory was transferred to worker.
};


const runWorker = memoryToTransfer => new Promise((resolve, reject) => {
    //load worker.
    const worker = new Worker(path.resolve(__dirname, 'worker.js'));
    //share the memory with this worker.
    worker.postMessage({memoryToUse: memoryToTransfer}, [memoryToTransfer]);

    //resolve on message, reject otherwise.
    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', reject);
});


transferMemoryToThread();

//File: worker.js

const {parentPort} = require('worker_threads');

parentPort.on('message', data => {
    const array = new Int32Array(data.memoryToUse);
    console.log('WORKER', array[0]);  //prints 42.
    parentPort.postMessage('Done');
});

 

Fazit

Die Architektur von Node.js ermöglicht die Entwicklung skalierbarer, I/O-intensiver Anwendungen. Das Ausführungsmodell ist aber nicht ohne Weiteres darauf ausgelegt, CPU-intensive oder andere blockierende Operationen auszuführen.

Das Freihalten des Event-Loop-Threads muss bei Node.js-Anwendungen stets oberste Priorität haben. Es gibt dazu mit Offloading, Partitioning, Clustering und Worker-Threads verschiedene Möglichkeiten. Der konkrete Lösungsweg ist dabei stark von dem jeweiligen Anwendungsfall abhängig.

Mit Worker-Threads haben Entwickler die Möglichkeit, eigenhändig Threads zu starten und so die Ausführung von JavaScript-Code zu parallelisieren. So lassen sich nun auch CPU-intensive Anwendungsfälle direkt in Node.js abbilden. Dabei gelten aber die gleichen Limitierungen wie bei allen Multi-Threaded-Ansätzen – mehr Threads bedeuten also nicht zwingend auch mehr Geschwindigkeit. Worker-Threads sind aktuell noch vergleichsweise schwergewichtig und sollten deshalb nicht ohne die Verwendung von Pools eingesetzt werden. Eventuell wird sich das aber in kommenden Versionen noch verbessern.

Autor

Sven Kölpin

Sven Kölpin ist Enterprise-Entwickler, Speaker und Autor bei der open knowledge GmbH in Oldenburg. Schwerpunkt und Leidenschaft ist die Konzeption und Entwicklung von Webanwendungen.
>> Weiterlesen
botMessage_toctoc_comments_9210