sgcWebSockets Performance Tuning — Schalen naar 100k verbindingen

· Features

Van “Het werkt” naar “Het schaalt”

sgcWebSockets handelt out-of-the-box enkele duizenden verbindingen af zonder tuning. Voorbij 50.000 of 100.000 gelijktijdige sockets op een enkele box duwen is ook mogelijk — maar het vereist het kiezen van de juiste server-klasse, het dimensioneren van thread pools naar je workload, het kiezen van de juiste compressie-strategie, en het tunen van het besturingssysteem. Dit bericht doorloopt elke hendel, in de volgorde waarin je ze moet trekken, met benchmark-getallen van een Hetzner AX102 (Ryzen 9 7950X3D, 128 GB RAM, 1 Gbps NIC) draaiend op Windows Server 2025 en Ubuntu 24.04.

Een woord over methodologie voordat we beginnen. Elk getal dat je hieronder ziet, komt van een synthetische echo-workload — 100-byte payloads, 10 berichten/sec/client, geen businesslogica. Echte applicaties zullen ergens tussen deze getallen en een orde van grootte trager landen, afhankelijk van wat je OnMessage-handler werkelijk doet. Behandel deze als plafonds, niet als beloftes. Het punt is de vorm van de schaalcurve te tonen en waar de kliffen zitten, niet om je een garantie te geven voor een willekeurige workload.

Ook: tune het ding dat het dichtst bij de gebruiker zit eerst. Er is geen zin om 200 microseconden van je broadcast-loop af te halen wanneer je TLS-handshake 80 milliseconden duurt. Profileer, vind de bottleneck, fix het, herhaal. De onderstaande lijst is grofweg geordend op impact-per-inspanning, maar elke workload is anders.

1. Kies het juiste I/O-model

sgcWebSockets levert twee server-families: de Indy-gebaseerde TsgcWebSocketServer (een-thread-per-verbinding) en de IOCP/epoll-aangedreven TsgcWebSocketHTTPServer (event-gedreven). Voorbij ongeveer 5.000 gelijktijdige verbindingen wil je de tweede. Punt.

Server I/O-model Sweet spot Hard plafond
TsgcWebSocketServer (Indy) Thread per verbinding <5.000 ~10.000 (thread-stack-uitputting)
TsgcWebSocketHTTPServer Win IOCP 10.000–100.000 ~250.000 (file descriptors)
TsgcWebSocketHTTPServer Lin epoll 10.000–100.000 ~1.000.000 (met tuning)
TsgcWebSocketHTTPServer Mac kqueue 10.000–50.000 ~100.000

2. Dimensioneer de thread pool

De IOCP/epoll-server gebruikt een fixed-size worker pool. Standaard is CPUCount. Voor pure echo- / fan-out-workloads houd hem klein (2–4 per core). Voor workloads die de database raken of externe API’s aanroepen binnen OnMessage, schroef hem op — anders blokkeert een trage request 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;

Vuistregel: PoolSize = CPUCount + (average_blocking_ms / average_cpu_ms) * CPUCount. Als je Application Insights / je APM groeiende wachtrijdiepte ziet rapporteren, verdubbel de pool. Er is een harde bovengrens gezet door je kernel-scheduler — tienduizenden OS-threads op Windows is niet leuk — maar je zit daar meestal nergens in de buurt voor zinnige workloads. Voorbij 256 workers, overweeg het blokkerende werk over te dragen aan een toegewijde worker pool.

De beste enkele architecturale beslissing is om OnMessage non-blocking te houden en elke daadwerkelijke werkeenheid te pushen op een toegewijde wachtrij bediend door een aparte thread pool. Dat ontkoppelt de I/O-thread (die snel moet blijven) van de business-thread (die zonder gevolgen traag kan zijn). Het maakt je ook observable: queue depth wordt de leading indicator van “we staan op het punt om te vallen”.

3. Compressie: per-message-deflate

WebSocket-compressie (RFC 7692) snijdt 60–90% van JSON- of tekst-payloads af. Het is ook CPU-zwaar. Schakel het globaal alleen in wanneer je workload tekst-zwaar is EN je CPU ruimte over heeft. Voor binaire of al gecomprimeerde payloads (JPEG, MP4, gzipped logs) is het pure 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

Stel Threshold zo in dat berichten kleiner dan de header-overhead niet worden gecomprimeerd. Het overslaan van de deflate op een 60-byte heartbeat bespaart meer CPU dan het kost.

Een subtiele valkuil: per-message-deflate gebruikt een sliding window die state behoudt over berichten op dezelfde verbinding. Die state is per-verbinding-geheugen. Met ServerMaxWindow=15 (de standaard) houdt elke verbinding ongeveer 32 KB aan dictionary. Vermenigvuldig met 100.000 verbindingen en je hebt 3 GB RAM alleen voor compressie-state. Verlaag ServerMaxWindow naar 10 of 11 als je memory-bound bent — je verliest een paar procent compressieratio in ruil voor grofweg 8x minder per-verbinding-geheugen.

4. Fragmentatie

Grote frames (>1 MB) houden de worker-thread vast tot het volledige bericht is samengesteld. Fragmenteer uitgaande berichten om latencies soepel te houden en workers vrij te maken voor andere peers.

oClient.WriteOptions.FragmentEnabled := True;
oClient.WriteOptions.FragmentSize    := 65536;  // 64 KB chunks

Aan de server-kant zet ReadOptions.MaxFrameSize op een verstandige bovengrens (we gebruiken 4 MB) om je te beschermen tegen kwaadwillende peers die gigabyte-buffers proberen te alloceren.

5. Broadcast-optimalisatie

Hetzelfde bericht naar elke verbonden client sturen is de #1 bottleneck voor chat- / trading- / pub-sub-servers. De naieve loop for each client: client.Send(msg) serialiseert en comprimeert dezelfde payload N keer. Gebruik de ingebouwde broadcast die een keer serialiseert en het gecodeerde frame hergebruikt.

// 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);

Op een 16-core box is het verschil tussen de naieve loop en BroadcastEncoded voor een 50.000-client fan-out 12 seconden vs 380 ms. Hetzelfde principe geldt voor kanalen — pre-encodeer het frame, doorloop dan de subscriberlijst. Als je subscribers gesplitst zijn over veel kanalen, encodeer een keer per kanaal en broadcast binnen. Premature pessimisation in dit code-pad zal een anderszins snelle server kelderen.

6. OS-niveau tuning

De kernel legt harde limieten op lang voordat de component dat doet. Tune deze voordat je de bibliotheek de schuld geeft.

Instelling Linux Windows Aanbeveling
File descriptor-limiet ulimit -n HKLM — MaxUserPort 2 × verwachte verbindingen
TCP-backlog net.core.somaxconn TcpMaxConnectResponseRetransmissions 4096+
TIME_WAIT-hergebruik tcp_tw_reuse=1 TcpTimedWaitDelay=30 Verminder poortuitputting
SO_REUSEPORT kernel ≥3.9 N/A Multi-proces-acceptor
Ephemerale poortrange net.ipv4.ip_local_port_range MaxUserPort 10000–65535

7. Heartbeats en idle-detectie

Mobiele clients vallen voortdurend van het netwerk. Zonder heartbeats houdt je server de socket open tot de TCP-keepalive-timer afgaat (typisch 2 uur). Configureer korte heartbeats en dead-peer-detectie.

oServer.HeartBeat.Enabled  := True;
oServer.HeartBeat.Interval := 30;     // seconds
oServer.HeartBeat.Timeout  := 90;     // close if no pong within this

Dit vangt half-open verbindingen binnen 90 seconden in plaats van twee uur, waardoor duizenden stale sockets op een drukke server vrijkomen.

8. Load-balancer-pairing

Als je voorbij een enkele box moet schalen, koppel sgcWebSockets dan met onze TsgcWebSocketHTTPServer_LoadBalancer of een externe L7-LB (HAProxy, nginx, AWS ALB). Twee regels:

Referentie-benchmarks

Getallen van een enkele AX102-box (16 cores / 32 threads, 128 GB), met een echo-server met 100-byte payloads bij 10 berichten/sec/client.

Gelijktijdige clients Doorvoer (msg/s) p50-latentie p99-latentie CPU-gebruik 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-terminatie

TLS-handshakes zijn CPU-duur. Als je duizenden nieuwe verbindingen per seconde bedient, kan TLS-terminatie in het Delphi-proces cores verzadigen die crypto doen in plaats van frames serveren. Voor high-churn-workloads termineren we TLS in nginx of HAProxy voor de sgcWebSockets-server en draaien we de backend in plain HTTP. De frontend krijgt hardware AES-versnelling, session resumption en OCSP stapling gratis, en het Delphi-proces kan 100% van zijn CPU aan applicatielogica besteden.

Voor workloads met persistente langlevende verbindingen (een typisch chat- / trading-scenario) is in-process TLS prima omdat de handshake wordt geamortiseerd over uren of dagen. Voor connect-disconnect-reconnect-bursts (mobiele clients op wankele netwerken), zet een reverse proxy ervoor.

10. NIC- en netwerk-tuning

Bij 1 Gbps is het onwaarschijnlijk dat je de NIC verzadigt. Boven 10 Gbps moet je nadenken over interrupt coalescing, receive-side scaling (RSS) en het pinnen van sgcWebSockets-worker-threads aan NUMA-lokale cores. Op Linux zijn ethtool -L en set_irq_affinity.sh je vrienden. Op Windows zet je RSS Profile op NUMAScaling in de NIC-eigenschappen en verifieer met Get-NetAdapterRss. Alleen waard om te tunen als je monitoring zegt dat de kernel echte tijd doorbrengt in softirq of DPC’s.

Profile-gedreven tuning-loop

Tunen is iteratief. Begin met defaults, draai een representatieve load, kijk naar: CPU per worker, GC- / allocation-rate, p99-latentie onder fan-out, en OS-niveau verbindingstellers. Verander een ding, draai opnieuw, vergelijk. De meest voorkomende verrassingen:

Verder lezen

Als je de juiste server-klasse nog niet hebt gekozen, begin bij Welke editie. Spring dan naar de Load Balancer-component voor multi-box-schaling. Nieuw bij de bibliotheek? De Aan de slag-hub loopt je in vijf minuten door de installatie.