· Michael Stöckler · Development · 9 min read
Die Herausforderung der Skalierung
Ihre Java-Anwendung wächst und plötzlich brechen die Response-Zeiten ein? Was vor 25 Jahren als "C10k-Problem" die Entwicklerwelt beschäftigte, ist heute aktueller denn je. Erfahren Sie, warum der klassische Thread-pro-Request Ansatz an seine Grenzen stößt und wie moderne Architekturmuster Abhilfe schaffen.
1.1 Die Herausforderung der Skalierung
Stellen Sie sich vor, Sie entwickeln eine Anwendung für ein erfolgreiches Startup. Alles läuft gut, die Nutzerzahlen steigen - und plötzlich wird Ihre Anwendung langsam. Die CPU-Auslastung steigt, der Speicherverbrauch explodiert, und die Antwortzeiten werden unvorhersehbar. Was ist passiert? Möglicherweise sind Sie an einem Punkt gekommen, wo Sie zu viele gleichzeitige Anfragen bearbeiten müssen und eine synchron programmierte Anwendung nicht mehr skaliert. Sie habe ein C10k Problem.
Der vollständige Quellcode zu diesem Artikel ist jetzt verfügbar unter:
github.com/notizwerk/blog-async-programming
Star ⭐ das Repository für Updates!
Das C10k Problem: Ein alter Bekannter in modernem Gewand
📖 Begriffserklärung: C10k Problem
Das “C10k Problem” wurde 1999 von Dan Kegel beschrieben:
- C: steht für “Concurrent” (gleichzeitig)
- 10k: steht für 10.000 Verbindungen
- Problem: beschreibt die Herausforderung der effizienten Verarbeitung
Historischer Kontext: Die Bezeichnung wurde zu einer Zeit gewählt, als 10.000
gleichzeitige Verbindungen als extreme Herausforderung galten.
Auch wenn diese Zahl heute bescheiden klingt - die zugrundeliegende Problematik ist aktueller denn je. Moderne Anwendungen stehen vor noch größeren Herausforderungen:
- Microservices mit tausenden internen Verbindungen
- Real-time Websocket Verbindungen für Live-Updates
- IoT Geräte mit permanenten Verbindungen
- Event-Streaming Systeme
- Chat und Messaging Dienste
Heutzutage kann man für viele Probleme eher von einem C10m Problem sprechen.
Die traditionelle Herangehensweise: Ein Thread pro Request
Der naheliegendste Ansatz zum Beispiel einen Server zu implementieren ist, für jede Verbindung einen eigenen Thread zu verwenden. Schauen wir uns ein typisches Beispiel an:
Dieser Code zeigt den klassischen Ansatz: Für jeden eingehenden Request wird ein neuer Thread erstellt. Das erscheint zunächst logisch - jeder Client bekommt seinen eigenen Thread und damit seine eigenen Ressourcen. Doch dieser Ansatz hat mehrere entscheidende Nachteile.
Context Switching: Der versteckte Performance-Killer
📖 Begriffserklärung: Context Switching
Ein Context Switch (Kontextwechsel) tritt auf, wenn der Prozessor von einem Thread zu einem anderen wechselt. Dabei muss der komplette Zustand des aktuellen Threads gesichert und der Zustand des nächsten Threads geladen werden.
Der Thread-Kontext umfasst dabei:
- Prozessor-Register
- Programm Counter (aktuelle Position im Code)
- Stack Pointer
- Memory Maps
- Cache-Zustände
Die Kosten eines Context Switch
Ein Context Switch ist ein teurer Vorgang, der mehrere Schritte umfasst:
Sichern des aktuellen Thread-Zustands
- Alle CPU Register müssen in den Speicher geschrieben werden
- Der aktuelle Ausführungskontext muss gespeichert werden
Laden des neuen Thread-Zustands
- Register müssen mit den Werten des neuen Threads geladen werden
- Memory Management Unit (MMU) muss aktualisiert werden
- Translation Lookaside Buffer (TLB) muss möglicherweise geleert werden
Cache-Auswirkungen
- Cache-Lines des vorherigen Threads werden möglicherweise nicht mehr benötigt
- Der neue Thread muss seine Daten erst in den Cache laden
- Dies führt zu “Cache Misses” und damit zu Speicherzugriffen
Hier ein Beispiel, das die Kosten des Context Switching demonstriert:
Warum Context Switching zum Problem wird
Häufigkeit der Switches
- Ein typisches System führt tausende Context Switches pro Sekunde durch
- Jeder Switch kostet etwa 1-100 Mikrosekunden (abhängig von Hardware und OS)
- Bei vielen Threads steigt die Anzahl der Switches quadratisch
Cache-Effekte
Nach einem Context Switch sind die Daten des neuen Threads meist nicht im Cache,
was zu deutlich langsameren Speicherzugriffen führt.Scheduling Overhead
- Der OS Scheduler muss entscheiden, welcher Thread als nächstes laufen soll
- Je mehr Threads, desto komplexer diese Entscheidung
- “Ready Queue” wird länger, Scheduling-Algorithmen werden ineffizienter
Die Grenzen des Thread-per-Request Modells
Hardware-Limitierungen
Ein modernes System hat typischerweise 8-32 CPU-Kerne. Jeder Kern kann zu einem Zeitpunkt nur einen Thread ausführen. Nehmen wir ein typisches Server-System mit 16 Kernen und Hyperthreading:
- 16 physische Kerne × 2 (Hyperthreading) = 32 Hardware-Threads
- Zu jedem Zeitpunkt können maximal 32 Threads tatsächlich parallel ausgeführt werden
- Alle weiteren Threads müssen warten
Zwar suggeriert Hyperthreading (Intel) oder SMT (Simultaneous Multithreading bei AMD) die Möglichkeit, zwei Threads parallel auf einem Kern auszuführen, jedoch teilen sich diese Threads die Ressourcen des Kerns und erreichen damit nicht die doppelte Performance.
Der verborgene Ressourcenhunger: Thread Stack-Größe
Ein oft übersehener Aspekt ist der Speicherverbrauch durch Thread Stacks. Jeder Thread benötigt seinen eigenen Stack-Speicher. Die Größe dieses Stacks wird durch den JVM-Parameter -Xss
festgelegt und beträgt auf 64-bit Systemen standardmäßig 1MB.
Um den tatsächlichen Speicherverbrauch von Threads zu untersuchen, können wir ein praktisches Experiment durchführen:
Analyse der Thread-Speichermessungen
Unsere Messungen mit dem ThreadAnalyzer zeigen sehr deutlich, warum das Thread-per-Request Modell bei hoher Last problematisch wird. Schauen wir uns die konkreten Zahlen an:
Speicherverbrauch pro Thread
Messung | Reserved Memory | Committed Memory |
---|---|---|
Pro Thread | ~1.050 KB | ~70 KB |
100 Threads | ~105 MB | ~7 MB |
1.000 Threads | ~1.05 GB | ~70 MB |
10.000 Threads | ~10.5 GB | ~700 MB |
Der große Unterschied zwischen “reserved” und “committed” Memory zeigt, wie effizient das Betriebssystem mit Speicherreservierungen umgeht:
- Reserved Memory: Der vom Betriebssystem reservierte Adressraum (~1MB pro Thread)
- Committed Memory: Der tatsächlich allokierte physische Speicher (~70KB pro Thread)
Was bedeutet das für das C10k Problem?
Nehmen wir einen typischen Server mit 32GB RAM. Selbst wenn wir nur den committed Memory betrachten:
- Bei 10.000 gleichzeitigen Verbindungen benötigen wir ~700 MB allein für Thread-Stacks
- Bei 100.000 Verbindungen wären es theoretisch schon ~7 GB
- Bei 1 Million Verbindungen würden ~70 GB benötigt
Dabei haben wir noch nicht einmal den Speicherbedarf der eigentlichen Anwendung berücksichtigt!
Die versteckten Kosten
Der Speicherverbrauch ist aber nur ein Teil des Problems:
Context Switching:
- Je mehr Threads aktiv sind, desto mehr Zeit verbringt das System mit Context Switches
- Die CPU-Zeit für das Thread-Scheduling steigt quadratisch mit der Anzahl der Threads
Garbage Collection:
- Mehr Heap-Nutzung bedeutet längere GC-Pausen
- Thread-Stacks müssen vom GC durchsucht werden
Ressourcen-Limits:
- File Descriptors
- Kernel Limits
- Stack Size Limits
Fazit
Die Messungen bestätigen eindeutig: Das Thread-per-Request Modell skaliert nicht zufriedenstellend mit der Last. Ab einer gewissen Anzahl von Verbindungen (typischerweise im Bereich von 1.000 bis 10.000) wird der Overhead für Threading zu groß.
Dies erklärt, warum moderne Hochlast-Systeme auf alternative Ansätze wie asynchrone Programmierung, Event-Loop-basierte Architekturen, Non-Blocking I/O oder zum Beispiel Virtual Threads setzen müssen. Diese Alternativen werden wir in den nächsten Kapiteln im Detail behandeln.