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:
- Gebruik sticky sessions — WebSocket-frames zijn niet idempotent en kunnen niet midden in het gesprek opnieuw worden gerouteerd.
- Geef de oorspronkelijke
X-Forwarded-For- en TLS-terminatie-headers door zodat je applicatie echte client-IP’s ziet.
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:
- Compressie ingeschakeld op al gecomprimeerde payloads → CPU-pieken voor nul bandbreedtewinst.
- Synchrone DB-calls binnen
OnMessage→ worker pool verzadigd bij <1% CPU. - Geen broadcast-batching → head-of-line blocking tijdens market-open-pieken.
- Standaard thread pool op een 64-core box → werk serialiseren op 64 workers wanneer 256 4× doorvoer zou ontsluiten.
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.