Warten versus Berechnen: Konkurrenz und Parallelität in Node.js und Go
Ausführbare Node.js- und Go-Beispiele, die Konkurrenz von Parallelismus trennen, und zeigen, wann Promises ausreichen, wann Worker-Threads benötigt werden und wie Goroutines sich unterscheiden.
Quellen: Concurrency vs parallelism, explained with Node.js and Go
Die Gleichsetzung von Konkurrenz und Parallelität mit Synonymen führt zu echten Fehlern, wie beispielsweise einer API, die unter Last blockiert. Der Vergleich zwischen Node.js und Go macht den Unterschied konkret sichtbar. Node.js bietet Konkurrenz ohne zusätzliche Anstrengung und zwingt einen dazu, Parallelität selbst zu implementieren, während Go beides über ein einziges Schlüsselwort bereitstellt. Mit kleinen, ausführbaren Programmen in jeder Sprache werden Sie herausfinden, welche für Ihre Arbeitslast geeignet ist.
Zwei Definitionen und eine Küche
Konkurrenz bedeutet, dass ein Worker mehrere Aufgaben bearbeitet, indem er auf eine andere Aufgabe wechselt, sobald die aktuelle wartet. Parallelismus bedeutet hingegen, dass mehrere Worker mehrere Aufgaben buchstäblich zur gleichen Zeit ausführen.
Stellen Sie sich eine Küche vor. Ein Koch mit drei Pfannen – das ist Konkurrenzverarbeitung: Der Koch rührt in der ersten Pfanne, während die zweite vor sich hin köchelt, schneidet er Gemüse für die dritte. Zu keinem Zeitpunkt geschehen zwei Dinge gleichzeitig, doch der Koch ist nie untätig. Setzen Sie drei Köche in die Küche, einen pro Pfanne – dann haben Sie Parallelverarbeitung.
Node.js: Konkurrenzverarbeitung ist bereits integriert
Ein Thread, viele laufende Anfragen
Node.js führt Ihren JavaScript-Code auf einem einzigen Thread aus. Dennoch kann es auch hohe Datenmengen gut verarbeiten, da die meiste Serverarbeit darauf wartet, auf eine Datenbank, eine Datei oder einen anderen Dienst zuzugreifen. Node überträgt dieses Warten an das Betriebssystem (und bei einigen Operationen an den internen Threadpool von libuv) und setzt anschließend mit dem nächsten JavaScript-Abschnitt fort. Dieses Scheduling-Mechanismus ist der Event-Loop. Für eine detailliertere Erklärung siehe wie libuv, der Event-Loop und der Threadpool miteinander zusammenwirken.
Das folgende Programm simuliert drei Anfragen, von denen jede eine Sekunde dauert, unter Verwendung von Timern, die in Promises eingebettet sind:
function fakeRequest(name, ms) {
return new Promise((resolve) => {
setTimeout(() => resolve(`${name} done`), ms);
});
}
async function main() {
console.time("total");
const results = await Promise.all([
fakeRequest("users", 1000),
fakeRequest("orders", 1000),
fakeRequest("products", 1000),
]);
console.log(results);
console.timeEnd("total");
}
main();
Weil Promise.all alle drei Aufgaben gleichzeitig startet und die Timer gleichzeitig ablaufen, gibt console.timeEnd etwa eine Sekunde an statt drei. Ein einziger Thread hatte drei laufende Aufgaben und war nie untätig, um auf eine von ihnen zu warten. Das ist Konkurrenzverarbeitung.
Was passiert bei echter CPU-Arbeit
Der Event-Loop kann nur Wartezeiten überschneiden. Wenn eine Aufgabe reine Berechnung ist, gibt es nichts, was an das Betriebssystem weitergegeben werden könnte. Dieses Programm addiert einen großen Bereich dreimal:
function countTo(n) {
let sum = 0;
for (let i = 0; i < n; i++) sum += i;
return sum;
}
console.time("total");
countTo(2_000_000_000);
countTo(2_000_000_000);
countTo(2_000_000_000);
console.timeEnd("total");
Die Gesamtdauer beträgt etwa das Dreifache der Dauer einer einzelnen Aufrufung. Das Einpacken der Aufrufe in Promise.all würde nicht helfen, da jede countTo-Funktion synchron von Anfang bis Ende ausgeführt wird, bevor etwas anderes laufen kann. Mit Küchenmetaphern ausgedrückt muss der einzige Koch nacheinander jede Pfanne bearbeiten. Noch schlimmer ist, dass ein Server während des Laufs der Schleife keine HTTP-Anfragen beantworten kann.
Parallelisierung mit Worker-Threads
Der Einsatz mehrerer CPU-Kerne erfordert mehrere Threads, und das entsprechende Tool in Node ist die worker_threads-Modul. Zunächst der Worker: eine separate Datei, die ihre Eingaben aus workerData liest, die Berechnungen durchführt und das Ergebnis wieder an den Elternprozess sendet.
// worker.js
const { parentPort, workerData } = require("worker_threads");
let sum = 0;
for (let i = 0; i < workerData; i++) sum += i;
parentPort.postMessage(sum);
Die Hauptdatei umhüllt jeden Worker in eine Promise, die beim message-Event des Workers erfüllt wird und bei einem error abgelehnt wird, anschließend werden drei Worker gleichzeitig gestartet:
// main.js
const { Worker } = require("worker_threads");
const path = require("path");
function countInWorker(n) {
return new Promise((resolve, reject) => {
const worker = new Worker(path.join(__dirname, "worker.js"), {
workerData: n,
});
worker.on("message", resolve);
worker.on("error", reject);
});
}
async function main() {
console.time("total");
await Promise.all([
countInWorker(2_000_000_000),
countInWorker(2_000_000_000),
countInWorker(2_000_000_000),
]);
console.timeEnd("total");
}
main();
Bei einem Rechner mit drei oder mehr Kernen sinkt die Gesamtzeit auf etwa die Dauer einer einzigen Aufrufung. Drei Köche, drei Töpfe – alle arbeiten gleichzeitig. Der Hauptthread bleibt außerdem frei, um Anfragen zu bearbeiten.
Betrachten Sie jedoch, was dafür nötig ist: eine eigene Datei, ein Nachrichtenkanal sowie eine Promise-Umhüllung. In Node.js muss Parallelität manuell konstruiert werden. In der Produktion sollte man außerdem vermeiden, pro Aufgabe einen neuen Worker zu erstellen, da das Starten eines Workers erhebliche Ressourcen verbraucht; eine wiederverwendbare Pools von Workern ist die übliche Lösung, wie in unserem Leitfaden zu Worker-Threads und Pools erklärt.
Go: Ein Schlüsselwort für beides
Goroutinen anstelle eines Event-Loops
In Go startet das Vorfügen von go vor einem Funktionsaufruf eine Goroutine. Jede Goroutine beginnt mit einem Stack von nur wenigen Kilobyte, der bei Bedarf wächst, sodass das Ausführen von Tausenden davon ganz normal ist. Der Scheduler der Go-Laufzeitumgebung ordnet Goroutinen den Threads des Betriebssystems zu und erlaubt standardmäßig so viele gleichzeitige Ausführungen wie CPU-Kerne vorhanden sind – eine Beschränkung, die durch GOMAXPROCS gesteuert wird.
Hier ist die Simulation mit den drei Anfragen erneut, wobei sync.WaitGroup verwendet wird, um zu warten, bis alle Goroutinen abgeschlossen sind:
package main
import (
"fmt"
"sync"
"time"
)
func fakeRequest(name string, wg *sync.WaitGroup) {
defer wg.Done()
time.Sleep(time.Second)
fmt.Println(name, "done")
}
func main() {
start := time.Now()
var wg sync.WaitGroup
for _, name := range []string{"users", "orders", "products"} {
wg.Add(1)
go fakeRequest(name, &wg)
}
wg.Wait()
fmt.Println("total:", time.Since(start))
}
Jede Goroutine ruft über defer wg.Done() auf, und wg.Wait() blockiert, bis alle abgeschlossen sind. Das Ergebnis entspricht der Node.js Promise.all-Version: insgesamt etwa eine Sekunde.
Arbeiten, die vom CPU-Betrieb limitiert werden, benötigen keine zusätzlichen Mechanismen
Nun das Zählbeispiel. Während Node.js ein Worker-File sowie den Nachrichtenaustausch benötigte, behält die Go-Version genau dieselbe Struktur wie zuvor:
package main
import (
"fmt"
"sync"
"time"
)
func countTo(n int, wg *sync.WaitGroup) {
defer wg.Done()
sum := 0
for i := 0; i < n; i++ {
sum += i
}
fmt.Println("sum:", sum)
}
func main() {
start := time.Now()
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go countTo(2_000_000_000, &wg)
}
wg.Wait()
fmt.Println("total:", time.Since(start))
}
Auf einem Mehrkernrechner wird dies in etwa der Zeit einer einzigen Aufrufung abgeschlossen. Der Scheduler fand drei ausführbare Goroutinen und führte sie gleichzeitig auf drei Threads aus. Sie haben die Arbeit konkurrent dargestellt, und der Laufzeitumgebung gelang es, dies in parallele Ausführung umzuwandeln.
Dies verdeutlicht den Unterschied, den Rob Pike in seinem berühmten Vortrag „Concurrency is not parallelism“ macht: Konkurrenz ist die Art und Weise, wie man ein Programm als unabhängig ausführbare Teile strukturiert, während Parallelismus das ist, was die Laufzeitumgebung mit dieser Struktur anstellen kann, wenn mehrere Kerne verfügbar sind.
Der Preis des gemeinsamen Speichers
Die Worker von Node.js verfügen jeweils über ihren eigenen isolierten Heap und können die Variablen der anderen nicht lesen; sie kommunizieren durch das Übergeben von Nachrichten. (Falls Sie in Node.js tatsächlich gemeinsamen Speicher benötigen, gibt es SharedArrayBuffer zusammen mit Atomics, was jedoch zu denselben Synchronisierungsproblemen führt, die unten beschrieben werden.)
Goroutinen hingegen teilen denselben Speicher. Wenn zwei Goroutinen ohne Koordination einen Zähler erhöhen, handelt es sich um einen Datenwettlauf, und der endgültige Wert wird falsch sein. Die übliche Lösung in Go besteht darin, Daten über Kanäle zu übertragen und nur dann einen Mutex zu verwenden, wenn der Austausch von Zuständen unvermeidlich ist.
Dieser Worker-Pool zeigt den Kanalansatz. Drei Goroutinen lesen Aufgaben aus einem Kanal und schreiben verdoppelte Werte in einen anderen:
package main
import "fmt"
func worker(jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * 2
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
for w := 1; w <= 3; w++ {
go worker(jobs, results)
}
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs)
for r := 1; r <= 9; r++ {
fmt.Println(<-results)
}
}
Nur die Kanäle selbst werden geteilt, daher sind keine Sperren erforderlich, und das Hinzufügen von Arbeitern ermöglicht es dem Pool, mehr Kerne zu nutzen. Die Ergebnisse kommen in der Reihenfolge, in der die Arbeiter fertig werden, was für dieses Muster erwartet wird. Das Ausführen des Programms mit go run -race main.go aktiviert den Renndetektor, der jeden unbeabsichtigten unsynchronisierten Zugriff auf den gemeinsamen Speicher meldet.
Vergleich der beiden Modelle
- Einheit der Konkurrenz: Node.js verwendet Promises und Callbacks, die auf einem Event-Loop geplant werden; Go verwendet Goroutinen.
- Parallelismus: In Node.js ist er optional, über
worker_threadsoder dascluster-Modul (das mehrere Prozesse ausführt); in Go ist er standardmäßig aktiv, wobei der Scheduler alle Kerne nutzt.
Die richtige Werkzeugwahl: Warten oder Berechnen?
Eine einzige Frage klärt die meisten Fälle: Ist der langsame Teil Ihrer Arbeitslast im Warten oder in Berechnungen beschäftigt?
Falls es sich um Warten handelt (auf eine Datenbank, das Dateisystem oder einen Netzwerkaufruf), benötigen Sie lediglich Konkurrenzverarbeitung. In Node.js bedeutet das Promises und async/await. In Go bedeutet es Goroutinen, wobei der Scheduler die blockierten aussetzt, damit sie keine CPU verbrauchen.
Falls es um Rechenoperationen geht (Anpassung der Bildgröße, Hashing, Auswerten einer sehr großen Datei), ist Parallelität erforderlich. In Node.js sollte die Arbeit auf Worker-Threads verlagert werden oder cluster genutzt werden, um die Anfragenverarbeitung auf mehrere Prozesse zu verteilen. In Go werden Goroutinen gestartet, wobei sichergestellt werden muss, dass sie Daten über Kanäle austauschen oder den gemeinsamen Zustand mit einem Mutex schützen.
Die Parallelität wird durch die Hardware begrenzt: In einem Ein-Kern-Container laufen drei CPU-intensive Aufgaben in keiner der Sprachen schneller als nacheinander.
Kernpunkte
- Konkurrenz bezieht sich auf die Struktur: Die Arbeit so anordnen, dass nichts untätig bleibt, während etwas wartet. Parallelität hängt von der Hardware ab: Wie viele Aufgaben tatsächlich gleichzeitig ausgeführt werden können.
- In Node.js überlagern sich die Wartezeiten mit
Promise.all, doch es wird nichts für synchrone Rechenoperationen getan, wodurch jede Anfrage im Thread blockiert wird.