IO-Bound vs CPU-bound

Wie viel Nebenläufigkeit oder Parallelität einen Leistungsgewinn bringt, hängt von der Art der Arbeitslast ab. In Serveranwendungen werden typischerweise zwei Haupttypen von Aufgaben unterschieden.

In den letzten Jahren haben sich die meisten Webanwendungen in Richtung IO-bound-Arbeitslasten verschoben. Dies wird durch das Wachstum von Microservices, entfernten APIs und Cloud-Diensten vorangetrieben. Ansätze wie Frontend for Backend (BFF) und API Gateway, die Daten aus mehreren Quellen aggregieren, verstärken diesen Effekt.

Eine moderne Serveranwendung ist auch schwer ohne Logging, Telemetrie und Echtzeit-Monitoring vorstellbar. All diese Operationen sind von Natur aus IO-bound.

Effizienz von IO-bound-Aufgaben

Die Effizienz der nebenläufigen Ausführung von IO-bound-Aufgaben wird davon bestimmt, welchen Anteil der Zeit die Aufgabe tatsächlich die CPU nutzt im Vergleich dazu, wie viel Zeit sie mit dem Warten auf den Abschluss von I/O-Operationen verbringt.

Littles Gesetz

In der Warteschlangentheorie ist eine der grundlegenden Formeln das Littlesche Gesetz (Little’s Law):

\[L = \lambda \cdot W\]

Wobei:

Dieses Gesetz ist universell und hängt nicht von der spezifischen Systemimplementierung ab: es spielt keine Rolle, ob Threads, Koroutinen oder asynchrone Callbacks verwendet werden. Es beschreibt die grundlegende Beziehung zwischen Last, Latenz und dem Grad der Nebenläufigkeit.

Bei der Abschätzung der Nebenläufigkeit für eine Serveranwendung lösen Sie im Wesentlichen das Problem, wie viele Aufgaben gleichzeitig im System sein müssen, damit Ressourcen effizient genutzt werden.

Für IO-bound-Arbeitslasten ist die durchschnittliche Anfrageverarbeitungszeit groß im Vergleich zur Zeit, die für aktive Berechnungen aufgewendet wird. Daher muss eine ausreichende Anzahl nebenläufiger Aufgaben im System vorhanden sein, damit die CPU nicht untätig ist.

Genau diese Größe lässt sich durch formale Analyse abschätzen, indem man verbindet:

Ein ähnlicher Ansatz wird in der Industrie zur Berechnung der optimalen Thread-Pool-Größe verwendet (siehe Brian Goetz, “Java Concurrency in Practice”).

Die tatsächlichen statistischen Daten für jedes Element dieser Formeln (Anzahl der SQL-Abfragen pro HTTP-Anfrage, DB-Latenzen, PHP-Framework-Durchsatz) sind in einem separaten Dokument gesammelt: Statistische Daten für die Nebenläufigkeitsberechnung.

Grundlegende CPU-Auslastung

Um zu berechnen, welchen Anteil der Zeit der Prozessor tatsächlich nützliche Arbeit bei der Ausführung einer einzelnen Aufgabe leistet, kann folgende Formel verwendet werden:

\[U = \frac{T_{cpu}}{T_{cpu} + T_{io}}\]

Die Summe T_cpu + T_io stellt die Gesamtlebensdauer einer Aufgabe von Anfang bis Ende dar.

Der Wert U liegt zwischen 0 und 1 und gibt den Grad der Prozessorauslastung an:

Die Formel liefert somit eine quantitative Bewertung, wie effizient die CPU genutzt wird und ob die betreffende Arbeitslast IO-bound oder CPU-bound ist.

Auswirkung der Nebenläufigkeit

Bei der gleichzeitigen Ausführung mehrerer IO-bound-Aufgaben kann die CPU die I/O-Wartezeit einer Aufgabe nutzen, um Berechnungen für eine andere durchzuführen.

Die CPU-Auslastung mit N nebenläufigen Aufgaben kann geschätzt werden als:

\[U_N = \min\left(1,\; N \cdot \frac{T_{cpu}}{T_{cpu} + T_{io}}\right)\]

Eine Erhöhung der Nebenläufigkeit verbessert die CPU-Auslastung, aber nur bis zu einem bestimmten Limit.

Effizienzgrenze

Der maximale Gewinn durch Nebenläufigkeit ist begrenzt durch das Verhältnis von I/O-Wartezeit zu Berechnungszeit:

\[E(N) \approx \min\left(N,\; 1 + \frac{T_{io}}{T_{cpu}}\right)\]

In der Praxis bedeutet dies, dass die Anzahl wirklich nützlicher nebenläufiger Aufgaben ungefähr dem Verhältnis T_io / T_cpu entspricht.

Optimale Nebenläufigkeit

\[N_{opt} \approx 1 + \frac{T_{io}}{T_{cpu}}\]

Die Eins in der Formel berücksichtigt die Aufgabe, die gerade auf der CPU ausgeführt wird. Bei einem großen T_io / T_cpu-Verhältnis (was typisch für IO-bound-Arbeitslasten ist), ist der Beitrag der Eins vernachlässigbar, und die Formel wird oft zu T_io / T_cpu vereinfacht.

Diese Formel ist ein Spezialfall (für einen einzelnen Kern) der klassischen Formel für die optimale Thread-Pool-Größe, die von Brian Goetz im Buch “Java Concurrency in Practice” (2006) vorgeschlagen wurde:

\[N_{threads} = N_{cores} \times \left(1 + \frac{T_{wait}}{T_{service}}\right)\]

Das Verhältnis T_wait / T_service ist als Blockierungskoeffizient bekannt. Je höher dieser Koeffizient ist, desto mehr nebenläufige Aufgaben können von einem einzelnen Kern effektiv genutzt werden.

Bei diesem Niveau der Nebenläufigkeit verbringt der Prozessor die meiste Zeit mit nützlicher Arbeit, und eine weitere Erhöhung der Aufgabenzahl bringt keinen merklichen Gewinn mehr.

Genau deshalb sind asynchrone Ausführungsmodelle für IO-bound Web-Arbeitslasten am effektivsten.

Beispielrechnung für eine typische Webanwendung

Betrachten wir ein vereinfachtes, aber ziemlich realistisches Modell einer durchschnittlichen serverseitigen Webanwendung. Nehmen wir an, dass die Verarbeitung einer einzelnen HTTP-Anfrage hauptsächlich die Interaktion mit einer Datenbank umfasst und keine rechenintensiven Operationen enthält.

Ausgangsannahmen

Warum 20 Abfragen? Dies ist die Medianschätzung für ORM-Anwendungen mittlerer Komplexität. Zum Vergleich:

  • WordPress generiert ~17 Abfragen pro Seite,
  • Drupal ohne Caching – von 80 bis 100,
  • und eine typische Laravel/Symfony-Anwendung – von 10 bis 30.

Die Hauptquelle des Wachstums ist das N+1-Pattern, bei dem das ORM verwandte Entitäten mit separaten Abfragen lädt.

Ausführungszeitschätzung

Für die Schätzung verwenden wir gemittelte Werte:

Gesamt pro HTTP-Anfrage:

Über die gewählten Latenzwerte. Die I/O-Zeit für eine einzelne SQL-Abfrage besteht aus der Netzwerklatenz (Round-Trip) und der Ausführungszeit der Abfrage auf dem DB-Server. Der Netzwerk-Round-Trip innerhalb eines einzelnen Rechenzentrums beträgt ~0,5 ms, und für Cloud-Umgebungen (Cross-AZ, Managed RDS) – 1–5 ms. Unter Berücksichtigung der Ausführungszeit einer mäßig komplexen Abfrage sind die resultierenden 4 ms pro Abfrage eine realistische Schätzung für eine Cloud-Umgebung. Die CPU-Zeit (0,05 ms) deckt ORM-Ergebnismapping, Entity-Hydration und grundlegende Verarbeitungslogik ab.

Arbeitslastcharakteristiken

Das Verhältnis von Wartezeit zu Berechnungszeit:

\[\frac{T_{io}}{T_{cpu}} = \frac{80}{1} = 80\]

Dies bedeutet, dass die Aufgabe überwiegend IO-bound ist: Der Prozessor verbringt die meiste Zeit im Leerlauf und wartet auf den Abschluss von I/O-Operationen.

Schätzung der Anzahl der Koroutinen

Die optimale Anzahl nebenläufiger Koroutinen pro CPU-Kern entspricht ungefähr dem Verhältnis von I/O-Wartezeit zu Berechnungszeit:

\[N_{coroutines} \approx \frac{T_{io}}{T_{cpu}} \approx 80\]

Mit anderen Worten: ungefähr 80 Koroutinen pro Kern ermöglichen das nahezu vollständige Verbergen der I/O-Latenz bei gleichzeitig hoher CPU-Auslastung.

Zum Vergleich: Zalando Engineering liefert ein Beispiel mit einem Microservice, bei dem die Antwortzeit 50 ms und die Verarbeitungszeit 5 ms beträgt auf einer Dual-Core-Maschine: 2 × (1 + 50/5) = 22 Threads – dasselbe Prinzip, dieselbe Formel.

Skalierung nach Anzahl der Kerne

Für einen Server mit C Kernen:

\[N_{total} \approx C \cdot \frac{T_{io}}{T_{cpu}}\]

Zum Beispiel für einen 8-Kern-Prozessor:

\[N_{total} \approx 8 \times 80 = 640 \text{ Koroutinen}\]

Dieser Wert spiegelt das nützliche Niveau der Nebenläufigkeit wider, nicht ein festes Limit.

Empfindlichkeit gegenüber der Umgebung

Der Wert von 80 Koroutinen pro Kern ist keine universelle Konstante, sondern das Ergebnis spezifischer Annahmen über die I/O-Latenz. Je nach Netzwerkumgebung kann die optimale Anzahl nebenläufiger Aufgaben erheblich abweichen:

Umgebung T_io pro SQL-Abfrage T_io gesamt (×20) N pro Kern
Localhost / Unix-Socket ~0,1 ms 2 ms ~2
LAN (einzelnes Rechenzentrum) ~1 ms 20 ms ~20
Cloud (Cross-AZ, RDS) ~4 ms 80 ms ~80
Remote-Server / Cross-Region ~10 ms 200 ms ~200

Je größer die Latenz, desto mehr Koroutinen werden benötigt, um die CPU vollständig mit nützlicher Arbeit auszulasten.

PHP-FPM vs Koroutinen: Ungefähre Berechnung

Um den praktischen Nutzen von Koroutinen abzuschätzen, vergleichen wir zwei Ausführungsmodelle auf demselben Server mit derselben Arbeitslast.

Ausgangsdaten

Server: 8 Kerne, Cloud-Umgebung (Cross-AZ RDS).

Arbeitslast: typischer Laravel-API-Endpunkt – Autorisierung, Eloquent-Abfragen mit Eager Loading, JSON-Serialisierung.

Basierend auf Benchmark-Daten von Sevalla und Kinsta:

Parameter Wert Quelle
Laravel-API-Durchsatz (30 vCPU, Localhost-DB) ~440 req/s Sevalla, PHP 8.3
Anzahl der PHP-FPM-Worker im Benchmark 15 Sevalla
Antwortzeit (W) im Benchmark ~34 ms L/λ = 15/440
Speicher pro PHP-FPM-Worker ~40 MB Typischer Wert

Schritt 1: Schätzung von T_cpu und T_io

Im Sevalla-Benchmark läuft die Datenbank auf Localhost (Latenz <0,1 ms). Bei ~10 SQL-Abfragen pro Endpunkt beträgt das gesamte I/O weniger als 1 ms.

Gegeben:

Nach Littles Gesetz:

\[W = \frac{L}{\lambda} = \frac{15}{440} \approx 0,034 \, \text{s} \approx 34 \, \text{ms}\]

Da in diesem Benchmark die Datenbank auf localhost läuft und das gesamte I/O weniger als 1 ms beträgt, spiegelt die resultierende durchschnittliche Antwortzeit fast vollständig die CPU-Verarbeitungszeit pro Anfrage wider:

\[T_{cpu} \approx W \approx 34 \, \text{ms}\]

Das bedeutet, dass unter localhost-Bedingungen nahezu die gesamte Antwortzeit (~34 ms) CPU ist: Framework, Middleware, ORM, Serialisierung.

Verschieben wir denselben Endpunkt in eine Cloud-Umgebung mit 20 SQL-Abfragen:

\[T_{cpu} = 34 \text{ ms (Framework + Logik)}\] \[T_{io} = 20 \times 4 \text{ ms} = 80 \text{ ms (DB-Wartezeit)}\] \[W = T_{cpu} + T_{io} = 114 \text{ ms}\]

Blockierungskoeffizient:

\[\frac{T_{io}}{T_{cpu}} = \frac{80}{34} \approx 2,4\]

Schritt 2: PHP-FPM

Im PHP-FPM-Modell ist jeder Worker ein separater OS-Prozess. Während der I/O-Wartezeit blockiert der Worker und kann keine anderen Anfragen verarbeiten.

Um 8 Kerne vollständig auszulasten, werden genug Worker benötigt, sodass zu jedem Zeitpunkt 8 von ihnen CPU-Arbeit verrichten:

\[N_{workers} = 8 \times \left(1 + \frac{80}{34}\right) = 8 \times 3,4 = 27\]
Metrik Wert
Worker 27
Speicher (27 × 40 MB) 1,08 GB
Durchsatz (27 / 0,114) 237 req/s
CPU-Auslastung ~100%

In der Praxis setzen Administratoren oft pm.max_children = 50--100, was über dem Optimum liegt. Zusätzliche Worker konkurrieren um CPU, erhöhen die Anzahl der OS-Kontextwechsel und verbrauchen Speicher, ohne den Durchsatz zu steigern.

Schritt 3: Koroutinen (Event Loop)

Im Koroutinen-Modell bedient ein einzelner Thread (pro Kern) viele Anfragen. Wenn eine Koroutine auf I/O wartet, wechselt der Scheduler in ~200 Nanosekunden zu einer anderen (siehe Empirische Grundlagen).

Die optimale Anzahl der Koroutinen ist dieselbe:

\[N_{coroutines} = 8 \times 3,4 = 27\]
Metrik Wert
Koroutinen 27
Speicher (27 × ~2 MiB) 54 MiB
Durchsatz 237 req/s
CPU-Auslastung ~100%

Der Durchsatz ist derselbe – weil die CPU der Engpass ist. Aber der Speicher für Nebenläufigkeit: 54 MiB vs 1,08 GB – ein ~20-facher Unterschied.

Über die Stack-Größe von Koroutinen. Der Speicherbedarf einer Koroutine in PHP wird durch die reservierte C-Stack-Größe bestimmt. Standardmäßig beträgt diese ~2 MiB, kann aber auf 128 KiB reduziert werden. Mit einem 128-KiB-Stack würde der Speicher für 27 Koroutinen nur ~3,4 MiB betragen.

Schritt 4: Was wenn die CPU-Last geringer ist?

Das Laravel-Framework im FPM-Modus verbraucht ~34 ms CPU pro Anfrage, was die Neuinitialisierung von Services bei jeder Anfrage einschließt.

In einer zustandsbehafteten Laufzeitumgebung (was True Async ist) werden diese Kosten erheblich reduziert: Routen sind kompiliert, der Dependency-Container ist initialisiert, Verbindungspools werden wiederverwendet.

Wenn T_cpu von 34 ms auf 5 ms sinkt (was für den zustandsbehafteten Modus realistisch ist), ändert sich das Bild dramatisch:

T_cpu Blockierungskoeff. N (8 Kerne) λ (req/s) Speicher (FPM) Speicher (Koroutinen)
34 ms 2,4 27 237 1,08 GB 54 MiB
10 ms 8 72 800 2,88 GB 144 MiB
5 ms 16 136 1 600 5,44 GB 272 MiB
1 ms 80 648 8 000 25,9 GB 1,27 GiB

Bei T_cpu = 1 ms (leichtgewichtiger Handler, minimaler Overhead):

Schritt 5: Littles Gesetz – Verifikation über den Durchsatz

Verifizieren wir das Ergebnis für T_cpu = 5 ms:

\[\lambda = \frac{L}{W} = \frac{136}{0,085} = 1\,600 \text{ req/s}\]

Um denselben Durchsatz zu erreichen, benötigt PHP-FPM 136 Worker. Jeder belegt ~40 MB:

\[136 \times 40 \text{ MB} = 5,44 \text{ GB nur f&uuml;r Worker}\]

Koroutinen:

\[136 \times 2 \text{ MiB} = 272 \text{ MiB}\]

Die freigewordenen ~5,2 GB können für Caches, DB-Verbindungspools oder die Verarbeitung weiterer Anfragen verwendet werden.

Zusammenfassung: Wann Koroutinen einen Vorteil bieten

Bedingung Vorteil durch Koroutinen
Schweres Framework, Localhost-DB (T_io ≈ 0) Minimal – die Arbeitslast ist CPU-bound
Schweres Framework, Cloud-DB (T_io = 80 ms) Moderat – ~20x Speichereinsparung bei gleichem Durchsatz
Leichtgewichtiger Handler, Cloud-DB Maximum – Durchsatzsteigerung bis zu 13x, ~20x Speichereinsparung
Microservice / API Gateway Maximum – nahezu reines I/O, Zehntausende req/s auf einem Server

Fazit: Je größer der Anteil von I/O an der Gesamtanfragezeit und je leichter die CPU-Verarbeitung, desto größer ist der Vorteil durch Koroutinen. Für IO-bound-Anwendungen (die Mehrheit der modernen Webdienste) ermöglichen Koroutinen eine mehrfach effizientere Nutzung derselben CPU, bei um Größenordnungen geringerem Speicherverbrauch.

Praktische Hinweise

Fazit

Für eine typische moderne Webanwendung, bei der I/O-Operationen überwiegen, ermöglicht das asynchrone Ausführungsmodell:

Genau in solchen Szenarien werden die Vorteile der Asynchronität am deutlichsten demonstriert.


Weiterführende Lektüre


Referenzen und Literatur