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:
- Usa sessioni sticky — i frame WebSocket non sono idempotenti e non possono essere reinstradati a metà conversazione.
- Inoltra gli header originali
X-Forwarded-Fore di terminazione TLS in modo che la tua applicazione veda gli IP client reali.
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:
- Compressione abilitata su payload già compressi → picchi di CPU per zero guadagno di banda.
- Chiamate DB sincrone dentro
OnMessage→ pool di worker saturato all'1% di CPU. - Nessun batching del broadcast → head-of-line blocking durante i picchi di apertura del mercato.
- Thread pool di default su una macchina a 64 core → serializzare il lavoro su 64 worker quando 256 sbloccherebbero 4× di throughput.
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.