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.
- IO-bound – Aufgaben, bei denen ein erheblicher Anteil der Zeit mit dem Warten auf Ein-/Ausgabeoperationen verbracht wird: Netzwerkanfragen, Datenbankabfragen, Lesen und Schreiben von Dateien. In diesen Momenten ist die CPU untätig.
- CPU-bound – Aufgaben, die intensive Berechnungen erfordern und den Prozessor nahezu ständig beschäftigen: komplexe Algorithmen, Datenverarbeitung, Kryptografie.
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:
L– die durchschnittliche Anzahl der Aufgaben im Systemλ– die durchschnittliche Rate eingehender AnfragenW– die durchschnittliche Zeit, die eine Aufgabe im System verbringt
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:
- Wartezeit,
- Durchsatz,
- und das erforderliche Niveau der Nebenläufigkeit.
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}}\]T_cpu– die Zeit, die für Berechnungen auf der CPU aufgewendet wirdT_io– die Zeit, die mit dem Warten auf I/O-Operationen verbracht wird
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:
U → 1kennzeichnet eine rechenintensive (CPU-bound) AufgabeU → 0kennzeichnet eine Aufgabe, die die meiste Zeit mit dem Warten auf I/O verbringt (IO-bound)
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:
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:
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
- Ungefähr 20 SQL-Abfragen werden pro HTTP-Anfrage ausgeführt
- Die Berechnung beschränkt sich auf Datenmapping, Antwortserialisierung und Logging
- Die Datenbank befindet sich außerhalb des Anwendungsprozesses (Remote-I/O)
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:
- Eine SQL-Abfrage:
- I/O-Wartezeit:
T_io ≈ 4 ms - CPU-Berechnungszeit:
T_cpu ≈ 0,05 ms
- I/O-Wartezeit:
Gesamt pro HTTP-Anfrage:
T_io = 20 × 4 ms = 80 msT_cpu = 20 × 0,05 ms = 1 ms
Ü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:
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:
- Durchsatz: λ ≈ 440 req/s
- Anzahl gleichzeitig bedienter Anfragen (PHP-FPM-Worker): L = 15
- Datenbank auf Localhost, also T_io ≈ 0
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:
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:
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:
| 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):
- PHP-FPM würde 648 Prozesse und 25,9 GB RAM benötigen – unrealistisch
- Koroutinen benötigen dieselben 648 Aufgaben und 1,27 GiB – ~20x weniger
Schritt 5: Littles Gesetz – Verifikation über den Durchsatz
Verifizieren wir das Ergebnis für T_cpu = 5 ms:
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ü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
- Eine Erhöhung der Koroutinenanzahl über das Optimum hinaus bringt selten einen Vorteil, ist aber auch kein Problem: Koroutinen sind leichtgewichtig, und der Overhead durch “zusätzliche” Koroutinen ist unvergleichlich gering gegenüber den Kosten von OS-Threads
- Die tatsächlichen Einschränkungen werden:
- Datenbank-Verbindungspool
- Netzwerklatenz
- Back-Pressure-Mechanismen
- Limits für offene Dateideskriptoren (ulimit)
- Für solche Arbeitslasten erweist sich das Event Loop + Koroutinen-Modell als deutlich effizienter als das klassische blockierende Modell
Fazit
Für eine typische moderne Webanwendung, bei der I/O-Operationen überwiegen, ermöglicht das asynchrone Ausführungsmodell:
- effektives Verbergen der I/O-Latenz
- deutliche Verbesserung der CPU-Auslastung
- Reduzierung des Bedarfs an einer großen Anzahl von Threads
Genau in solchen Szenarien werden die Vorteile der Asynchronität am deutlichsten demonstriert.
Weiterführende Lektüre
- Swoole in der Praxis: Reale Messungen – Produktionsfälle (Appwrite +91%, IdleMMO 35M req/Tag), unabhängige Benchmarks mit und ohne DB, TechEmpower
- Python asyncio in der Praxis – Duolingo +40%, Super.com -90% Kosten, uvloop-Benchmarks, Gegenargumente
- Empirische Grundlagen: Warum Single-Threaded-Koroutinen funktionieren – Messungen der Kontextwechselkosten, Vergleich mit OS-Threads, akademische Forschung und Industrie-Benchmarks
Referenzen und Literatur
- Brian Goetz, Java Concurrency in Practice (2006) – Formel für die optimale Thread-Pool-Größe:
N = cores × (1 + W/S) - Zalando Engineering: How to set an ideal thread pool size – praktische Anwendung der Goetz-Formel mit Beispielen und Herleitung über Littles Gesetz
- Backendhance: The Optimal Thread-Pool Size in Java – detaillierte Analyse der Formel unter Berücksichtigung der Ziel-CPU-Auslastung
- CYBERTEC: PostgreSQL Network Latency – Messungen der Auswirkungen der Netzwerklatenz auf die PostgreSQL-Leistung
- PostgresAI: What is a slow SQL query? – Richtlinien für akzeptable SQL-Abfragelatenzen in Webanwendungen