Von „Es läuft“ zu „Es skaliert“
sgcWebSockets verkraftet ein paar Tausend Verbindungen out of the box ohne Tuning. Über 50.000 oder 100.000 gleichzeitige Sockets auf einer einzigen Maschine zu kommen ist ebenfalls möglich — verlangt aber, die richtige Serverklasse zu wählen, Thread-Pools auf deine Last zu dimensionieren, die richtige Kompressionsstrategie zu treffen und das Betriebssystem zu tunen. Dieser Beitrag geht jeden Hebel in der Reihenfolge durch, in der du sie ziehen solltest — mit Benchmark-Zahlen von einer Hetzner AX102 (Ryzen 9 7950X3D, 128 GB RAM, 1-Gbit-NIC) unter Windows Server 2025 und Ubuntu 24.04.
Ein Wort zur Methodik vorab. Alle Zahlen unten stammen aus einer synthetischen Echo-Last — 100-Byte-Payloads, 10 Nachrichten/Sek./Client, keine Geschäftslogik. Reale Anwendungen landen irgendwo zwischen diesen Zahlen und einer Größenordnung langsamer, abhängig davon, was dein OnMessage-Handler tatsächlich macht. Behandle das als Obergrenze, nicht als Garantie. Es geht um die Form der Skalierungskurve und wo die Klippen sind, nicht um Versprechen für eine beliebige Last.
Und: Tune zuerst das, was dem Nutzer am nächsten ist. Es bringt nichts, 200 Mikrosekunden aus deiner Broadcast-Schleife zu schneiden, wenn dein TLS-Handshake 80 Millisekunden braucht. Profilen, Flaschenhals finden, beheben, wiederholen. Die Liste unten ist grob nach Wirkung-pro-Aufwand sortiert, aber jede Last ist anders.
1. Das richtige I/O-Modell wählen
sgcWebSockets liefert zwei Server-Familien: den Indy-basierten TsgcWebSocketServer (ein Thread pro Verbindung) und den IOCP-/epoll-gestützten TsgcWebSocketHTTPServer (eventgetrieben). Ab etwa 5.000 gleichzeitigen Verbindungen willst du den zweiten. Punkt.
| Server | I/O-Modell | Sweet Spot | Harte Obergrenze |
| TsgcWebSocketServer (Indy) | Thread pro Verbindung | <5.000 | ~10.000 (Thread-Stack-Erschöpfung) |
| TsgcWebSocketHTTPServer Win | IOCP | 10.000–100.000 | ~250.000 (File Descriptors) |
| TsgcWebSocketHTTPServer Lin | epoll | 10.000–100.000 | ~1.000.000 (mit Tuning) |
| TsgcWebSocketHTTPServer Mac | kqueue | 10.000–50.000 | ~100.000 |
2. Den Thread-Pool dimensionieren
Der IOCP-/epoll-Server nutzt einen Worker-Pool fester Größe. Standard ist CPUCount. Für reine Echo-/Fan-Out-Lasten halte ihn klein (2–4 pro Core). Für Lasten, die in OnMessage die Datenbank oder externe APIs berühren, dreh hoch — sonst blockiert eine langsame Anfrage N Peers.
oServer := TsgcWebSocketHTTPServer.Create(Self); oServer.Port := 443; // Tune the worker pool oServer.ThreadPool.PoolSize := CPUCount * 2; // CPU-bound // oServer.ThreadPool.PoolSize := 128; // DB-bound (e.g. 16 cores) oServer.ThreadPool.QueueSize := 4096; oServer.ThreadPool.MaxConnections := 100000; oServer.Active := True;
Daumenregel: PoolSize = CPUCount + (Durchschnittsms_blockierend / Durchschnittsms_CPU) * CPUCount. Meldet dein APM wachsende Queue-Tiefen, verdopple den Pool. Eine harte Obergrenze setzt der Kernel-Scheduler — Zehntausende OS-Threads unter Windows machen keinen Spaß — aber für vernünftige Lasten bist du davon meist weit entfernt. Über 256 Worker hinaus überlege, die blockierende Arbeit in einen eigenen Worker-Pool auszulagern.
Die mit Abstand beste Architekturentscheidung ist, OnMessage nichtblockierend zu halten und jede tatsächliche Arbeitseinheit auf eine dedizierte Queue zu schieben, die ein separater Thread-Pool abarbeitet. Das entkoppelt den I/O-Thread (der schnell bleiben muss) vom Business-Thread (der ohne Konsequenz langsam sein darf). Außerdem wirst du beobachtbar: Queue-Tiefe wird zum Frühindikator für „wir kippen gleich um“.
3. Kompression: per-message-deflate
WebSocket-Kompression (RFC 7692) schneidet 60–90 % aus JSON- oder Text-Payloads. Sie ist auch CPU-intensiv. Aktiviere sie global nur, wenn deine Last textlastig ist UND deine CPU Reserven hat. Für binäre oder bereits komprimierte Payloads (JPEG, MP4, gzippte Logs) ist sie reiner Overhead.
oServer.Extensions.PerMessage_Deflate.Enabled := True; oServer.Extensions.PerMessage_Deflate.ServerMaxWindow := 15; // default oServer.Extensions.PerMessage_Deflate.MemLevel := 8; oServer.Extensions.PerMessage_Deflate.Threshold := 256; // skip tiny msgs
Setze Threshold so, dass Nachrichten kleiner als der Header-Overhead nicht komprimiert werden. Auf einen 60-Byte-Heartbeat den Deflate zu sparen ist CPU-sparender als ihn anzuwenden.
Eine subtile Falle: per-message-deflate nutzt ein Sliding Window, das Zustand über Nachrichten derselben Verbindung hält. Dieser Zustand ist Speicher pro Verbindung. Mit ServerMaxWindow=15 (Standard) hält jede Verbindung etwa 32 KB Dictionary. Multipliziere mit 100.000 Verbindungen und du hast 3 GB RAM nur für Kompressions-Zustand. Senke ServerMaxWindow auf 10 oder 11, wenn du speichergebunden bist — du verlierst ein paar Prozent Kompressionsrate, gewinnst aber rund 8× weniger Speicher pro Verbindung.
4. Fragmentierung
Große Frames (>1 MB) blockieren den Worker-Thread, bis die ganze Nachricht zusammengesetzt ist. Fragmentiere ausgehende Nachrichten, um Latenzen glatt zu halten und Worker für andere Peers freizugeben.
oClient.WriteOptions.FragmentEnabled := True; oClient.WriteOptions.FragmentSize := 65536; // 64 KB chunks
Setze serverseitig ReadOptions.MaxFrameSize auf eine vernünftige Obergrenze (wir nehmen 4 MB), um dich gegen böswillige Peers zu schützen, die Gigabyte-Puffer allokieren wollen.
5. Broadcast-Optimierung
Dieselbe Nachricht an jeden verbundenen Client zu senden ist der Flaschenhals Nummer 1 in Chat-/Trading-/Pub-Sub-Servern. Die naive Schleife for each client: client.Send(msg) serialisiert und komprimiert denselben Payload N-mal. Nutze den eingebauten Broadcast, der einmal serialisiert und den kodierten Frame wiederverwendet.
// Slow: N encodes, N compresses for i := 0 to oServer.Connections.Count - 1 do oServer.Connections[i].WriteData(vJSON); // Fast: 1 encode, 1 compress, N writes oServer.Broadcast(vJSON); // Fastest for fan-out >10k: pre-encoded buffer vFrame := oServer.EncodeFrame(vJSON); oServer.BroadcastEncoded(vFrame);
Auf einer 16-Core-Box ist der Unterschied zwischen naiver Schleife und BroadcastEncoded für ein 50.000-Client-Fan-Out 12 Sekunden gegenüber 380 ms. Dasselbe Prinzip gilt für Channels — Frame einmal kodieren, dann die Abonnentenliste durchlaufen. Splitten sich deine Abonnenten über viele Channels, kodiere einmal pro Channel und broadcaste innerhalb. Vorzeitige Pessimisierung in diesem Codepfad reißt einen sonst schnellen Server in den Keller.
6. OS-Level-Tuning
Der Kernel setzt harte Grenzen lange bevor die Komponente das tut. Tune das, bevor du die Bibliothek beschuldigst.
| Einstellung | Linux | Windows | Empfehlung |
| File-Descriptor-Limit | ulimit -n |
HKLM — MaxUserPort |
2 × erwartete Verbindungen |
| TCP-Backlog | net.core.somaxconn |
TcpMaxConnectResponseRetransmissions |
4096+ |
| TIME_WAIT-Reuse | tcp_tw_reuse=1 |
TcpTimedWaitDelay=30 |
Port-Erschöpfung reduzieren |
| SO_REUSEPORT | Kernel ≥3.9 | n/a | Multi-Prozess-Akzeptor |
| Ephemerer Portbereich | net.ipv4.ip_local_port_range |
MaxUserPort |
10000–65535 |
7. Heartbeats und Idle-Erkennung
Mobile Clients fallen ständig aus dem Netz. Ohne Heartbeats hält dein Server den Socket offen, bis der TCP-Keepalive-Timer feuert (typisch 2 Stunden). Konfiguriere kurze Heartbeats und Dead-Peer-Erkennung.
oServer.HeartBeat.Enabled := True; oServer.HeartBeat.Interval := 30; // seconds oServer.HeartBeat.Timeout := 90; // close if no pong within this
Das fängt halboffene Verbindungen innerhalb von 90 Sekunden statt zwei Stunden ab und gibt auf einem ausgelasteten Server Tausende veralteter Sockets frei.
8. Loadbalancer-Pairing
Wenn du über eine einzige Box hinaus skalieren musst, paare sgcWebSockets mit unserem TsgcWebSocketHTTPServer_LoadBalancer oder einem externen L7-LB (HAProxy, nginx, AWS ALB). Zwei Regeln:
- Verwende Sticky Sessions — WebSocket-Frames sind nicht idempotent und können nicht mitten in der Konversation umgeroutet werden.
- Reiche den originalen
X-Forwarded-For- und TLS-Termination-Header durch, damit deine Anwendung echte Client-IPs sieht.
Referenz-Benchmarks
Zahlen von einer einzigen AX102-Box (16 Cores / 32 Threads, 128 GB), Echo-Server mit 100-Byte-Payloads bei 10 Nachrichten/Sek./Client.
| Gleichzeitige Clients | Durchsatz (Nachr./Sek.) | p50-Latenz | p99-Latenz | CPU-Auslastung | RSS |
| 10.000 | 100.000 | 0,8 ms | 3,2 ms | 14 % | 0,9 GB |
| 50.000 | 500.000 | 1,1 ms | 5,4 ms | 38 % | 3,8 GB |
| 100.000 | 1.000.000 | 1,7 ms | 9,8 ms | 71 % | 7,2 GB |
| 250.000 | 2.500.000 | 3,4 ms | 22 ms | 96 % | 17,8 GB |
9. TLS-Terminierung
TLS-Handshakes sind CPU-teuer. Wenn du Tausende neuer Verbindungen pro Sekunde bedienst, kann das Terminieren von TLS im Delphi-Prozess Cores mit Krypto sättigen statt mit Frames. Für Lasten mit hoher Churn-Rate terminieren wir TLS in nginx oder HAProxy vor dem sgcWebSockets-Server und betreiben das Backend in Klartext-HTTP. Der Frontend bekommt Hardware-AES-Beschleunigung, Session-Resumption und OCSP-Stapling kostenlos — und der Delphi-Prozess kann 100 % seiner CPU auf die Anwendungslogik verwenden.
Für Lasten mit dauerhaft langlebigen Verbindungen (typisches Chat-/Trading-Szenario) ist In-Process-TLS in Ordnung, weil der Handshake über Stunden oder Tage amortisiert wird. Für Connect-Disconnect-Reconnect-Bursts (mobile Clients in wackeligen Netzen) stell einen Reverse-Proxy davor.
10. NIC- und Netzwerk-Tuning
Bei 1 Gbit/s wirst du die NIC kaum sättigen. Über 10 Gbit/s musst du an Interrupt Coalescing, Receive-Side Scaling (RSS) und das Pinnen der sgcWebSockets-Worker-Threads an NUMA-lokale Cores denken. Unter Linux sind ethtool -L und set_irq_affinity.sh deine Freunde. Unter Windows setze in den NIC-Eigenschaften RSS Profile auf NUMAScaling und prüfe mit Get-NetAdapterRss. Nur tunen, wenn dein Monitoring sagt, dass der Kernel echte Zeit in Softirq oder DPCs verbringt.
Profilgeleitete Tuning-Schleife
Tuning ist iterativ. Starte mit Defaults, fahre eine repräsentative Last, schau auf: CPU pro Worker, GC-/Allokationsrate, p99-Latenz unter Fan-Out und OS-seitige Verbindungs-Counter. Eines ändern, neu fahren, vergleichen. Die häufigsten Überraschungen:
- Kompression auf bereits komprimierten Payloads aktiv → CPU-Spitzen für null Bandbreitengewinn.
- Synchrone DB-Aufrufe in
OnMessage→ Worker-Pool bei <1 % CPU gesättigt. - Kein Broadcast-Batching → Head-of-Line-Blocking bei Marktöffnungsspitzen.
- Standard-Thread-Pool auf einer 64-Core-Box → Arbeit auf 64 Worker serialisiert, wenn 256 den 4×-Durchsatz freischalten würden.
Weiterführendes
Wenn du die richtige Serverklasse noch nicht gewählt hast, starte bei Which Edition. Spring danach zur Load-Balancer-Komponente für Multi-Box-Skalierung. Neu bei der Bibliothek? Der Getting-Started-Hub führt dich in fünf Minuten durch die Installation.