Tuning delle performance di sgcWebSockets — scalare a 100k connessioni

· Funzionalità

Da "funziona" a "scala"

sgcWebSockets gestisce qualche migliaio di connessioni out of the box senza alcun tuning. Spingersi oltre 50.000 o 100.000 socket concorrenti su una singola macchina è anche possibile — ma richiede di scegliere la giusta classe di server, dimensionare i thread pool al carico di lavoro, scegliere la giusta strategia di compressione e ottimizzare il sistema operativo. Questo post passa in rassegna ogni leva, nell'ordine in cui dovresti tirarla, con numeri di benchmark da una Hetzner AX102 (Ryzen 9 7950X3D, 128 GB RAM, NIC 1 Gbps) che gira Windows Server 2025 e Ubuntu 24.04.

Una parola sulla metodologia prima di iniziare. Ogni numero che vedi sotto è da un workload sintetico di echo — payload da 100 byte, 10 messaggi/sec/client, nessuna logica di business. Le applicazioni reali atterreranno da qualche parte tra questi numeri e un ordine di grandezza più lenti a seconda di cosa il tuo handler OnMessage fa effettivamente. Trattali come soffitti, non promesse. Il punto è mostrare la forma della curva di scaling e dove sono le scogliere, non darti una garanzia per un workload arbitrario.

Inoltre: ottimizza per prima cosa la cosa più vicina all'utente. Non ha senso togliere 200 microsecondi al tuo loop di broadcast quando il tuo handshake TLS richiede 80 millisecondi. Profila, trova il collo di bottiglia, sistemalo, ripeti. La lista sotto è grosso modo ordinata per impatto-per-sforzo, ma ogni workload è diverso.

1. Scegli il giusto modello di I/O

sgcWebSockets distribuisce due famiglie di server: il TsgcWebSocketServer basato su Indy (un thread per connessione) e il TsgcWebSocketHTTPServer con backend IOCP/epoll (event-driven). Oltre circa 5.000 connessioni concorrenti, vuoi il secondo. Punto.

Server Modello di I/O Sweet spot Soffitto duro
TsgcWebSocketServer (Indy) Thread per connessione <5.000 ~10.000 (esaurimento dello stack dei thread)
TsgcWebSocketHTTPServer Win IOCP 10.000–100.000 ~250.000 (file descriptor)
TsgcWebSocketHTTPServer Lin epoll 10.000–100.000 ~1.000.000 (con tuning)
TsgcWebSocketHTTPServer Mac kqueue 10.000–50.000 ~100.000

2. Dimensiona il thread pool

Il server IOCP/epoll usa un pool di worker a dimensione fissa. Il default è CPUCount. Per workload puri di echo / fan-out tienilo piccolo (2–4 per core). Per workload che toccano il database o chiamano API esterne dentro OnMessage, aumentalo — altrimenti una richiesta lenta blocca N peer.

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;

Regola pratica: PoolSize = CPUCount + (average_blocking_ms / average_cpu_ms) * CPUCount. Se trovi Application Insights / il tuo APM che riporta una profondità di coda crescente, raddoppia il pool. C'è un limite superiore duro impostato dallo scheduler del kernel — decine di migliaia di thread OS su Windows non sono divertenti — ma di solito sei lontanissimo per workload sensati. Oltre 256 worker, considera di offloadare il lavoro bloccante a un pool di worker dedicato.

La singola migliore decisione architetturale è mantenere OnMessage non bloccante e spingere ogni vera unità di lavoro su una coda dedicata servita da un thread pool separato. Ciò disaccoppia il thread di I/O (che deve restare veloce) dal thread di business (che può essere lento senza conseguenze). Ti rende anche osservabile: la profondità della coda diventa l'indicatore principale di "stiamo per cadere".

3. Compressione: per-message-deflate

La compressione WebSocket (RFC 7692) taglia il 60–90% sui payload JSON o testuali. È anche pesante per la CPU. Abilitala globalmente solo quando il tuo workload è testo-intensivo E la tua CPU ha margine. Per payload binari o già compressi (JPEG, MP4, log gzippati) è puro 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

Imposta Threshold in modo che i messaggi più piccoli dell'overhead dell'header non vengano compressi. Saltare il deflate su un heartbeat da 60 byte risparmia più CPU di quanta ne costi.

Una trappola sottile: per-message-deflate usa una finestra scorrevole che mantiene stato tra i messaggi sulla stessa connessione. Quello stato è memoria per connessione. Con ServerMaxWindow=15 (il default), ogni connessione tiene circa 32 KB di dizionario. Moltiplica per 100.000 connessioni e hai 3 GB di RAM solo per lo stato di compressione. Abbassa ServerMaxWindow a 10 o 11 se sei memory-bound — perdi qualche percento del rapporto di compressione in cambio di circa 8x meno memoria per connessione.

4. Frammentazione

Frame grandi (>1 MB) tengono occupato il worker thread finché il messaggio completo non è riassemblato. Frammenta i messaggi in uscita per mantenere fluide le latenze e liberare worker per altri peer.

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

Lato server, imposta ReadOptions.MaxFrameSize a un limite superiore sensato (noi usiamo 4 MB) per proteggere contro peer malevoli che tentano di allocare buffer di gigabyte.

5. Ottimizzazione del broadcast

Inviare lo stesso messaggio a ogni client connesso è il collo di bottiglia #1 per server di chat / trading / pub-sub. Il loop ingenuo for each client: client.Send(msg) serializza e comprime lo stesso payload N volte. Usa il broadcast integrato che serializza una volta e riusa il frame codificato.

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

Su una macchina a 16 core, la differenza tra il loop ingenuo e BroadcastEncoded per un fan-out di 50.000 client è 12 secondi vs 380 ms. Lo stesso principio si applica ai canali — preencode il frame, poi cammina nella lista dei subscriber. Se i tuoi subscriber si dividono su molti canali, codifica una volta per canale e fai il broadcast dentro. Una pessimizzazione prematura in questo cammino di codice farà affondare un server altrimenti veloce.

6. Ottimizzazione a livello di OS

Il kernel impone limiti duri molto prima del componente. Ottimizzali prima di incolpare la libreria.

Impostazione Linux Windows Raccomandazione
Limite di file descriptor ulimit -n HKLM — MaxUserPort 2 × connessioni attese
Backlog TCP net.core.somaxconn TcpMaxConnectResponseRetransmissions 4096+
Riuso TIME_WAIT tcp_tw_reuse=1 TcpTimedWaitDelay=30 Ridurre l'esaurimento delle porte
SO_REUSEPORT kernel ≥3.9 N/A Acceptor multi-processo
Range di porte ephemeral net.ipv4.ip_local_port_range MaxUserPort 10000–65535

7. Heartbeat e rilevamento dell'inattività

I client mobile cadono dalla rete in continuazione. Senza heartbeat, il tuo server tiene il socket aperto finché non scatta il timer di TCP keepalive (tipicamente 2 ore). Configura heartbeat brevi e rilevamento di peer morti.

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

Questo cattura le connessioni semi-aperte entro 90 secondi invece di due ore, liberando migliaia di socket stantii su un server occupato.

8. Abbinamento al load balancer

Se hai bisogno di scalare oltre una singola macchina, abbina sgcWebSockets al nostro TsgcWebSocketHTTPServer_LoadBalancer o a un LB L7 esterno (HAProxy, nginx, AWS ALB). Due regole:

Benchmark di riferimento

Numeri da una singola macchina AX102 (16 core / 32 thread, 128 GB), che esegue un server echo con payload da 100 byte a 10 messaggi/sec/client.

Client concorrenti Throughput (msg/s) Latenza p50 Latenza p99 Uso CPU 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. Terminazione TLS

Gli handshake TLS sono costosi per la CPU. Se servi migliaia di nuove connessioni al secondo, terminare TLS nel processo Delphi può saturare i core facendo crittografia invece di servire frame. Per workload ad alto turnover terminiamo TLS in nginx o HAProxy davanti al server sgcWebSockets ed eseguiamo il backend in HTTP semplice. Il frontend ottiene l'accelerazione hardware AES, la session resumption e l'OCSP stapling gratis, e il processo Delphi può spendere il 100% della sua CPU sulla logica applicativa.

Per workload con connessioni persistenti di lunga durata (un tipico scenario chat / trading), il TLS in-process va bene perché l'handshake è ammortizzato su ore o giorni. Per burst di connect-disconnect-reconnect (client mobile su reti instabili), metti davanti un reverse proxy.

10. Tuning di NIC e rete

A 1 Gbps è improbabile che tu saturi la NIC. Sopra i 10 Gbps devi pensare a interrupt coalescing, receive-side scaling (RSS) e pinning dei worker thread di sgcWebSockets a core NUMA-locali. Su Linux, ethtool -L e set_irq_affinity.sh sono i tuoi amici. Su Windows, imposta RSS Profile a NUMAScaling nelle proprietà della NIC e verifica con Get-NetAdapterRss. Vale la pena ottimizzare solo se il tuo monitoring ti dice che il kernel sta spendendo tempo reale in softirq o DPC.

Loop di tuning profile-guided

Il tuning è iterativo. Inizia con i default, esegui un carico rappresentativo, guarda: CPU per worker, GC / rate di allocazione, latenza p99 sotto fan-out e contatori di connessione a livello di OS. Cambia una cosa, riesegui, confronta. Le sorprese più comuni:

Letture aggiuntive

Se non hai ancora scelto la giusta classe di server, parti da Quale Edizione. Poi salta al componente Load Balancer per lo scaling multi-macchina. Sei nuovo della libreria? L'hub Per Iniziare ti guida attraverso l'installazione in cinque minuti.