High Computational Load Performance mit Node.JS Multithreading optimieren

|
Node.js multithreaded

In diesem Artikel möchte ich dich auf eine Reise mitnehmen, die dich durch die Welt von Multitasking und Multithreading in Node.JS führt. Dabei möchte ich dir zeigen, wie wir bei mittwald die Performance eines Micro-Services optimiert haben, indem wir grundsätzliche Missverständnisse über die Concurrency Mechaniken von Node.JS aufgedeckt und ausgeräumt haben.

Wie das Hashing von Recovery Codes unsere Response Zeit versaut hat

Als Teil der Produktentwicklung bei mittwald bin ich unter anderem verantwortlich für die fachliche Domäne der Registrierung und Authentifizierung im neuen mStudio. Diese haben wir in Form des Signup-Service als Node.JS Micro-Service in Typescript entwickelt. Ein essenzieller Bestandteil der Signup-Domäne ist die Multi-Faktor-Authentifizierung (MFA) nach dem Prinzip des Time-based One-time Password (TOTP). Neben der Erzeugung eines Secrets besteht die Initialisierung der MFA auch aus der Generierung von Recovery Codes, die dem Nutzer übermittelt werden. Für den Fall, dass das Gerät mit dem Secret verloren geht, kann der Nutzer einen der zur Verfügung stehenden Recovery Codes als Ersatz für ein One-time Password (OTP) verwenden. Dadurch kann bspw. die MFA deaktiviert und ggf. neu initialisiert werden.

Um selbst keine Informationen über die Recovery Codes haben zu müssen, werden die Recovery Codes gehasht. Somit können wir bei der Nutzung jener zwar abgleichen, ob der eingegebene Recovery Code korrekt ist, müssen im Sinne der Datensicherheit jedoch nicht die Recovery Codes selbst kennen. So können sie nicht gestohlen werden.

Nun mussten wir leider beobachten, dass das Hashen von 20 Recovery Codes nach folgendem Muster dazu geführt hat, dass die Response Zeit bei der Initialisierung der MFA deutlich zu hoch war:

export class RecoveryCodeGenerator {
    private async hashRecoveryCodes(
        bareRecoveryCodes: string[],
    ): Promise<string[]> {
        const hashedRecoveryCodes: string[] = [];
        for (const code of bareRecoveryCodes) {
            const hashedRecoveryCode = await this.hashSecret(code);
            hashedRecoveryCodes.push(hashedRecoveryCode);
        }
        return hashedRecoveryCodes;
    };
}

Anhand des Code Beispiels lässt sich schnell erklären, warum dieser Vorgang ineffizient ist. Die Codes werden seriell, also einer nach dem anderen gehasht und nicht parallelisiert. Nun war der erste Lösungsversuch, nennen wir es die "0% Lösung", die Recovery Codes parallel zu hashen. Die Promises wurden gesammelt und anschließend mit Promise.all aufgelöst:

export class RecoveryCodeGenerator {
    private async hashRecoveryCodes(
        bareRecoveryCodes: string[],
    ): Promise<string[]> {
        const hashedRecoveryCodePromises: Promise<string>[] = [];
        for (const code of bareRecoveryCodes) {
            hashedRecoveryCodePromises.push(this.hashSecret(code));
        }
        return Promise.all(hashedRecoveryCodePromises);
    };
}

Dieser Ansatz schien nicht zu funktionieren, da die Response Zeit unverändert hoch blieb. Der Grund dafür offenbarte sich jedoch noch nicht.

80% Lösung: Ahead of time Generierung von Recovery Codes

Stattdessen war der erste (teilweise) Erfolg bringende Lösungsansatz, während des Startvorgangs des Signup-Services mehrere Sets von Recovery Codes zu generieren und diese im RAM vorzuhalten. Nun konnte bei der Initialisierung der MFA einfach ein Satz an Recovery Codes aus der Reserve genommen werden. Außerdem wurde sichergestellt, dass periodisch neue Recovery Codes generiert werden, um die Reserve wieder aufzufüllen. Falls keine Recovery Codes mehr in der Reserve vorhanden waren, wurde ein neues Set an Recovery Codes generiert und dieses direkt zurückgegeben.

export class RecoveryCodeGenerator {
    private fillUpPrecachedRecoveryCodes(): void {
        const fillUpAfterTimeout = () => setTimeout(() => {
            this.fillUpPrecachedRecoveryCodes();
        }, this.regenerationIntervalInSeconds * 1000);

        if (this.preGeneratedRecoveryCodes.length >= this.precachedRecoveryCodesAmount) {
            fillUpAfterTimeout();
            return;
        }
        this.generateRecoveryCodesSet()
            .catch((e) => logger.error(e));

        fillUpAfterTimeout()
    }

    public async requestRecoveryCodesSet(): Promise<RecoveryCodesSet> {
        return this.preGeneratedRecoveryCodes.pop()
            ?? (await this.getNewRecoveryCodesSet());
    }
}

Auf den ersten Blick schien es so, als ob dieser Ansatz funktioniert. Die Response Zeit der MFA Initialisierung wurde isoliert getestet, war logischerweise erheblich schneller und das Issue wurde geschlossen. Ende gut, alles gut?

Wenn dies das Ende der Geschichte gewesen wäre, wäre die Geschichte wohl keinen Artikel wert. Wie man sich also denken kann, ist dies nicht das Ende der Geschichte. Nach einer gewissen Zeit wurde festgestellt, dass das Problem eigentlich nicht vollständig behoben wurde, sondern nur verschoben. In unseren Metriken war ersichtlich, dass die Response Zeit dieses konkreten Endpunkts verglichen mit den vergangenen Werten erheblich gesunken war. Jedoch war die durchschnittliche Response Zeit aller anderen Requests insgesamt etwas gestiegen, wenn auch nicht äquivalent zur Senkung der Response Zeit der MFA Initialisierung. Wie lässt sich das erklären?

Don't block the Event Loop

In Anlehnung an den Artikel Don't block the Event Loop (or the Worker Pool) möchte ich diese Frage aufgreifen und näher darauf eingehen, wie Multitasking in Node.JS funktioniert.

Multitasking in Node.JS

Das im Umlauf befindliche Wissen über Node.JS besagt, dass der Javascript Code in einer Single-Threaded Runtime ausgeführt wird. Das bedeutet, dass alle Operationen in Node.JS auf einem einzigen Thread ausgeführt werden. Dieser Thread wird als Event Loop bezeichnet. Der Event Loop ist dafür zuständig, dass alle Operationen in Node.JS ausgeführt werden. Dabei wird immer nur ein Task zur selben Zeit ausgeführt. Sobald ein Task abgearbeitet wurde, wird der nächste Task aus der Queue ausgeführt. Dieser Vorgang wird als Run-to-Completion bezeichnet. Das bedeutet, dass ein Task immer vollständig ausgeführt wird, bevor der nächste Task begonnen wird. Das geschieht so lange, bis der Code explizit die Kontrolle an den Event Loop zurückgibt (wie das geht, dazu gleich).

Dieses Verhalten lässt sich einfach durch den folgenden Code demonstrieren:

process.on('SIGTERM', function () {
    console.log('Shutting down application');
    process.exit(0);
});

process.kill(process.pid);

for (var i = 0; i < 100; i++) {
    console.log(i);
}

Man könnte leicht erwarten, dass mitten in der Schleife das Programm beendet oder nicht ein einziger log aus der Schleife ausgegeben wird, da process.exit aufgerufen wird. Tatsächlich wird jedoch die Schleife vollständig ausgeführt, weil die Schleife I/O Blocking ist. Das bedeutet, dass die Schleife nicht an den Event Loop zurückgibt, sondern den Event Loop blockiert. Der Event Loop kann also erst den nächsten Task ausführen, wenn die Schleife vollständig abgearbeitet wurde.

Die Schleife blockiert den Loop
Die Schleife blockiert den Loop

Dieses Verhalten erklärt, warum der erste Lösungsversuch, die Recovery Codes parallel zu hashen, nicht funktioniert hat. Es wurde zwar auf alle Promises gleichzeitig gewartet, da der Event Loop jedoch Single-Threaded ist, konnten die Hashs nur nacheinander gebildet werden.

Auch erklärt es, warum zu beobachten war, dass trotz ahead of time generierter Recovery Codes die Response Zeit der Requests im Schnitt gestiegen ist. Die Generierung der Recovery Codes hat zwar nicht mehr den Request zur Initialisierung der MFA blockiert, jedoch den Event Loop während des Auffüllens der vorgenerierten Recovery Codes – und damit alle parallel ausgeführten Requests.

Die Kontrolle an den Event Loop zurückgeben

Die Aktion, bewusst die Kontrolle an den Event Loop zurück gegeben, wird im kooperativen Multitasking als Yielding bezeichnet. In Node.JS kann die Kontrolle an den Event Loop zurückgegeben werden, indem asynchrone Funktionen aufgerufen werden. Diese Funktionen geben die Kontrolle an den Event Loop zurück, sobald sie aufgerufen werden. Das bedeutet, dass der Event Loop wieder ausgeführt werden kann, während der Aufrufer auf eine Antwort wartet. Sobald die Antwort eintrifft, wird die asynchrone Funktion wieder in die Queue des Event Loops eingereiht und der Event Loop kann die Funktion wieder ausführen.

Hier ein paar sehr einfache Beispiele, wie sich dies bewerkstelligen lässt:

export class CooperativeMultiTaskExecutor {
    public static async yieldWithPromise(doSomething: () => any): Promise<void> {
        await new Promise((done) => done(doSomething()));
    }

    public static yieldWithSetTimeout(doSomething: () => any, afterMilliseconds: number): void {
        setTimeout(doSomething, afterMilliseconds);
    }

    public static yieldWithSetImmediate(doSomething: () => any): void {
        setImmediate(doSomething);
    }
}

Immer wenn ein Promise awaited wird oder setTimeout bzw. setImmediate verwendet wird, wird die Kontrolle an den Event Loop zurückgegeben. Dies erklärt auch, warum die Response Zeit der Requests während des Auffüllens der Recovery Codes nicht um die Dauer des Hashens gestiegen ist, sondern nur um die Dauer eines Hashing Schrittes. Jeder Hashing Schritt wurde in einem Promise ausgeführt und hat somit die Kontrolle an den Event Loop zurückgegeben.

Zur Visualisierung greife ich das Logging Beispiel von oben noch mal auf. Auf folgende Weise kannst du sehen, dass die Schleife tatsächlich unterbrochen wird:

process.on('SIGTERM', function () {
    console.log('Shutting down application');
    process.exit(0);
});

process.kill(process.pid);

for (var i = 0; i < 100; i++) {
    setImmediate(() => console.log(i));
}
"setImmediate" gibt die Kontrolle zurück an den Event Loop.
"setImmediate" gibt die Kontrolle zurück an den Event Loop.

Multithreading in Node.JS

Was ist aber, wenn man Computational Load erwartet, die den Event Loop gar nicht blockieren soll oder nicht in verschiedene asynchrone Bestandteile partitioniert werden kann? Wer aufmerksam gelesen hat, dem wird aufgefallen sein, dass ich erwähnt habe, dass es ein „im Umlauf befindliches Wissen“ ist, dass Node.JS Single-Threaded ist. Diese Worte waren bewusst gewählt, denn das ist nur die halbe Wahrheit. Tatsächlich ist Node.JS nämlich nicht Single-Threaded sondern bietet einen Worker Pool an, um bspw. Tasks wie File oder Netzwerk I/O daran abzugeben. Der Worker Pool ist ein Pool von Threads, die parallel zum Event Loop ausgeführt werden.

Wer sich schon mal gewundert hat, warum Standard-Library Funktionen wie fs.readFile auch eine Sync Variante haben, findet hier die Antwort. Die synchronen Varianten werden im Gegensatz zu den asynchronen nicht im Worker Pool ausgeführt sondern blockieren den Event Loop.

Warum werden dann nicht alle Operationen im Worker Pool ausgeführt? Das liegt daran, dass die Kommunikation zwischen dem Event Loop und dem Worker Pool nicht ganz trivial ist. In einem Worker kann nicht einfach auf einen State aus der Applikation zugegriffen werden. Alle Informationen, die in einen Worker Task gegeben werden oder von diesem zurückgegeben werden, müssen serialisiert und deserialisiert werden. Aufgrund des damit einhergehenden Overheads ist es nicht sinnvoll, alle Operationen im Worker Pool auszuführen. Es ist nur sinnvoll, Operationen im Worker Pool auszuführen, die den Event Loop über den benötigten Overhead hinaus blockieren würden. Ob es sich lohnt, Computational Load in den Worker Pool zu offloaden ist also immer eine Ermessensfrage.

Darüber hinaus ist das eigentliche Offloaden an einen Worker ebenfalls nicht trivial. Mögliche Wege, Tasks von einem Worker übernehmen zu lassen wären

Die ersten beiden Ansätze sind für diesen Artikel ein wenig zu weitreichend, weshalb ich mich auf den letzten Ansatz beschränken werde.

Ein einfaches Beispiel für den letzten Ansatz ist der folgender Code. Insgesamt folgt das Beispiel nicht unbedingt meiner Definition von "schön", jedoch ist das Beispiel sehr weit verbreitet und sollte deswegen wenigstens Erwähnung finden.

import {
    Worker,
    isMainThread,
    parentPort,
    workerData
} from "worker_threads";

if (isMainThread) {
    const worker = new Worker(__filename, {workerData: "mittwald"});
    worker.on("message", msg => console.log(`Greetings from: ${msg}`));
    worker.on("error", err =>
        console.error(`got following error while executing worker: ${err}`)
    );
} else {
    const data = workerData;
    parentPort.postMessage(`You said \"${data}\".`);
}

Oder alternativ aufgeteilt in zwei Dateien (und etwas schöner):

./main.js:

import {Worker} from "worker_threads";

const pathToWorker = "./worker.js";

const worker = new Worker(pathToWorker, {workerData: "mittwald"});
worker.on("message", (msg) => console.log(`Greetings from: ${msg}`));
worker.on("error", (err) =>
    console.error(`got following error while executing worker: ${err}`),
);

./worker.js:

import {parentPort, workerData} from "worker_threads";

const data = workerData;
parentPort.postMessage(`You said \"${data}\".`);

Im ersten Beispiel wird die aktuell ausgeführte Datei erneut im Worker ausgeführt. Über die isMainThread Variable wird sichergestellt, dass im Worker nicht erneut rekursiv ein Worker gestartet wird. Die Kommunikation zwischen Main Thread und Worker Thread erfolgt über die parentPort Variable. Diese Variable ist ein MessagePort, der die Kommunikation zwischen Main Thread und Worker Thread ermöglicht. Der Worker Thread kann über die postMessage Funktion Nachrichten an den Main Thread senden. Über die on Funktion kann der Main Thread Nachrichten vom Worker Thread empfangen. Diese Messages können aber nur serialisierbare Daten sein. Lambda-Funktionen oder Klassen-Instanzen bspw. sind nicht möglich. Einfache Objekte könnten vor dem Senden z.B. als JSON serialisiert und vom Main-Thread im Message-Event-Handler wieder deserialisiert werden.

Insgesamt finde ich Worker-Threads hinsichtlich ihrer Usability schwierig zu handhaben. "Mal eben" eine Klasse zu schreiben, die einen Lambda im Worker ausführt, funktioniert leider nur mit sehr viel Overhead. Das würde zumindest erklären, warum so viele Node.JS Entwickler denken, dass Node.JS Single-Threaded ist. Auf Github sind zwar einige Libraries zu finden (allen voran sind da Parallel.js und Poolifier zu nennen) die das Thema aufgreifen und versuchen, die Komplexität zu bändigen. Für alle Use-Cases, in denen auf Basis von Input eines Requests eine direkte Antwort folgen soll, sind dies die zu favorisierenden Optionen. Auch wenn die Deployment Strategy der Applikation nicht trivial hergibt, die Applikation zu separieren, sind dies gut geeignete Lösungen. Es bleibt jedoch stets der Umstand, dass das Erzeugen von Worker Threads (oder im Falle von Parallel.js ganze Prozesse) und die Kommunikation mit diesen nicht unerheblichen Overhead erzeugt.

100% Lösung - Multi-Application Architektur

Für viele Use-Cases, in denen eine direkte Antwort nicht notwendig ist oder die Computational Load unabhängig von Input-Daten ist und somit ahead of time geschehen kann, bietet sich eine Multi-Application Architektur an. In diesem Fall wird der Monolith in zwei Applikationen aufgeteilt. Eine Applikation, die die Requests entgegennimmt und eine zweite Applikation, die die Computational Load übernimmt. Abgrenzend sollte natürlich erwähnt werden, dass dieser Architekturansatz stark von der Deployment Strategy abhängig ist. Wir bei mittwald setzen einheitlich auf Containerisierung unter Zuhilfenahme eines Kubernetes Clusters. Ein zusätzliches Deployment ist mit bestehenden Pipeline Templates, Dockerfile Templates und Helm sehr schnell erledigt. In anderen Deployment Szenarien kann es jedoch deutlich aufwändiger sein, eine zusätzliche Applikation zu deployen. In diesem Fall ist der Lösungsansatz mit Worker Threads ggf. die bessere Wahl.

Generierung der Recovery Codes

In unserem Beispiel werden die Recovery Codes im finalen Design nicht mehr vom Signup-Service direkt generiert. Stattdessen übernimmt eine separate Applikation die Aufgabe, Recovery Code Sets ahead of time zu generieren und diese in eine RedisDB zu speichern. Regelmäßig prüft die Applikation, ob noch genügend Sets verfügbar sind und generiert gegebenenfalls neue. Die Applikation ist in TypeScript geschrieben und verwendet den RecoveryCodeGenerator des Signup-Service als Library Code.

Der Signup Service versucht nun bei der Initialisierung der MFA ein Set von Recovery Codes aus der RedisDB auszulesen. Falls kein Set mehr vorhanden ist, wird ein Set vom Signup-Service selbst generiert.

Dieser Ansatz ist deutlich einfacher zu warten, als jeder Worker Ansatz. Gleichzeitig ist der Recovery Code Generator hoch skalierbar. Durch unsere Kubernetes Plattform lässt sich die Applikation jederzeit mit einfachsten Mitteln replizieren. Und schließlich ist der zu erwartende Overhead erheblich geringer, als wenn zur Generierung Worker gestartet werden würden. Der Signup Service muss, vereinfacht ausgedrückt, nur im bestehenden Worker Pool darauf warten, dass der Network I/O abgeschlossen ist. Und im Warten auf Netzwerk ist Node.JS erstaunlich gut ?

Tipp!

Du hast ein außergewöhnliches Projekt, das besonders viel Liebe braucht? Dann ist der proSpace das perfekte Hosting dafür. Warum? Du kannst deinem Projekt dedizierte Ressourcen geben und Node.js & Redis fully managed draufpacken. Teste den proSpace jetzt kostenlos!

Ähnliche Artikel:

Automatisiere deine Hosting-Prozesse
Hosting

mStudio API – Automatisiere deine Prozesse

Mit der API kannst du alle Prozesse des mStudios in deine individuelle Umgebung integrieren. Für Infos für Tipps geht's hier lang.

So optimierst du die Kennzahlen
Hosting

Performance KPIs im mStudio – So optimierst du die Kennzahlen

Die Hostingverwaltung mStudio zeigt dir zahlreiche Performance KPIs deiner Websites an. Hier liest du, wie du deine Werte optimierst.

Managed Cloud Hosting von mittwald
Hosting

Architektur der Managed Private Cloud

Performance, Skalierbarkeit, Zuverlässigkeit - darauf ist unsere Managed Private Cloud optimiert. Lies hier, wie wir dabei vorgegangen sind.

Kommentare

Daniel am
Wow, das war ein beeindruckender Deep Dive in die Welt des Multithreading in Node.JS. Ich war wirklich fasziniert davon, wie du und das Team bei mittwald die Performance-Probleme bei der Generierung von Recovery Codes behandelt habt. Was mich besonders beeindruckt hat, ist, wie du die Missverständnisse über Node.JS Concurrency Mechaniken aufgedeckt hast, die vielen von uns Entwicklern nicht bewusst waren.

Als jemand, der sich auch intensiv mit Suchmaschinenoptimierung beschäftigt, verstehe ich, wie wichtig die Ladezeit und die Performance einer Website oder Anwendung sind. Eine langsame Antwortzeit kann nicht nur zu Benutzerfrustration führen, sondern auch den SEO-Rank beeinflussen. Daher ist es so wichtig, solche Techniken und Lösungen zu verstehen, um die bestmögliche Performance sicherzustellen.

Ich denke, viele Entwickler, die sich auf die Backend-Seite konzentrieren, übersehen manchmal den Einfluss von Backend-Performance auf die SEO. Dieser Artikel ist ein perfektes Beispiel dafür, wie tiefgreifend und komplex die Optimierung hinter den Kulissen sein kann. Die Art und Weise, wie du den Event Loop und die Bedeutung des Nicht-Blockierens erläutert hast, war sehr aufschlussreich.

Ich werde definitiv einige der hier besprochenen Techniken in Betracht ziehen, um die Performance meiner eigenen Node.JS-Anwendungen zu verbessern. Vielen Dank für diesen wertvollen Beitrag und weiter so!

Beste Grüße
Antworten

Kommentar hinzufügen