De « ça marche » à « ça scale »
sgcWebSockets gère quelques milliers de connexions d'office sans optimisation. Pousser au-delà de 50 000 ou 100 000 sockets concurrentes sur une seule machine est aussi possible — mais cela nécessite de choisir la bonne classe de serveur, de dimensionner les pools de threads pour votre charge, de choisir la bonne stratégie de compression et d'optimiser le système d'exploitation. Ce billet passe en revue chaque levier, dans l'ordre où vous devriez les tirer, avec des chiffres de benchmark depuis un Hetzner AX102 (Ryzen 9 7950X3D, 128 Go RAM, NIC 1 Gbps) sous Windows Server 2025 et Ubuntu 24.04.
Un mot sur la méthodologie avant de commencer. Chaque chiffre que vous voyez ci-dessous provient d'une charge synthétique echo — payloads de 100 octets, 10 messages/sec/client, sans logique métier. Les applications réelles atterriront quelque part entre ces chiffres et un ordre de grandeur plus lentement selon ce que fait réellement votre handler OnMessage. Traitez-les comme des plafonds, pas comme des promesses. L'objectif est de montrer la forme de la courbe de scaling et où sont les falaises, pas de vous donner une garantie pour une charge arbitraire.
Aussi : optimisez d'abord la chose la plus proche de l'utilisateur. Il ne sert à rien de raboter 200 microsecondes sur votre boucle de diffusion quand votre handshake TLS prend 80 millisecondes. Profilez, trouvez le goulot d'étranglement, corrigez-le, recommencez. La liste ci-dessous est grossièrement ordonnée par impact-par-effort, mais chaque charge est différente.
1. Choisir le bon modèle d'E/S
sgcWebSockets livre deux familles de serveurs : le TsgcWebSocketServer basé sur Indy (un thread par connexion) et le TsgcWebSocketHTTPServer basé sur IOCP/epoll (événementiel). Au-delà d'environ 5 000 connexions concurrentes, vous voulez le second. Point.
| Serveur | Modèle d'E/S | Zone optimale | Plafond dur |
| TsgcWebSocketServer (Indy) | Thread par connexion | <5 000 | ~10 000 (épuisement de la pile de threads) |
| TsgcWebSocketHTTPServer Win | IOCP | 10 000–100 000 | ~250 000 (descripteurs de fichiers) |
| TsgcWebSocketHTTPServer Lin | epoll | 10 000–100 000 | ~1 000 000 (avec optimisation) |
| TsgcWebSocketHTTPServer Mac | kqueue | 10 000–50 000 | ~100 000 |
2. Dimensionner le pool de threads
Le serveur IOCP/epoll utilise un pool de workers de taille fixe. Le défaut est CPUCount. Pour des charges echo / fan-out pur gardez-le petit (2–4 par cœur). Pour les charges qui touchent la base de données ou appellent des API externes dans OnMessage, augmentez-le — sinon une requête lente bloque N pairs.
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;
Règle empirique : PoolSize = CPUCount + (average_blocking_ms / average_cpu_ms) * CPUCount. Si vous trouvez Application Insights / votre APM rapportant une profondeur de file croissante, doublez le pool. Il y a une limite supérieure dure imposée par le scheduler du noyau — des dizaines de milliers de threads OS sous Windows n'est pas amusant — mais vous en êtes habituellement loin pour des charges sensées. Au-delà de 256 workers, envisagez de décharger le travail bloquant sur un pool de workers dédié à la place.
La meilleure décision architecturale est de garder OnMessage non-bloquant et de pousser chaque unité de travail réelle sur une file dédiée servie par un pool de threads séparé. Cela découple le thread d'E/S (qui doit rester rapide) du thread métier (qui peut être lent sans conséquence). Cela vous rend aussi observable : la profondeur de file devient l'indicateur avancé de « on est sur le point de tomber ».
3. Compression : per-message-deflate
La compression WebSocket (RFC 7692) coupe 60–90 % des payloads JSON ou texte. Elle est aussi gourmande en CPU. Activez-la globalement uniquement quand votre charge est texte-lourde ET que votre CPU a de la marge. Pour des payloads binaires ou déjà compressés (JPEG, MP4, logs gzippés), c'est de la surcharge pure.
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
Définissez Threshold pour que les messages plus petits que la surcharge d'en-tête ne soient pas compressés. Sauter la deflate sur un heartbeat de 60 octets économise plus de CPU qu'elle n'en coûte.
Un piège subtil : per-message-deflate utilise une fenêtre glissante qui retient l'état entre les messages sur la même connexion. Cet état est de la mémoire par connexion. Avec ServerMaxWindow=15 (le défaut), chaque connexion détient environ 32 Ko de dictionnaire. Multipliez par 100 000 connexions et vous avez 3 Go de RAM juste pour l'état de compression. Réduisez ServerMaxWindow à 10 ou 11 si vous êtes limité en mémoire — vous perdez quelques pour cent de ratio de compression en échange d'environ 8x moins de mémoire par connexion.
4. Fragmentation
Les grandes frames (>1 Mo) retiennent le thread worker jusqu'à ce que le message complet soit réassemblé. Fragmentez les messages sortants pour garder les latences lisses et libérer des workers pour d'autres pairs.
oClient.WriteOptions.FragmentEnabled := True; oClient.WriteOptions.FragmentSize := 65536; // 64 KB chunks
Côté serveur, définissez ReadOptions.MaxFrameSize à une borne supérieure sensée (nous utilisons 4 Mo) pour protéger contre les pairs malveillants qui essaient d'allouer des buffers d'un gigaoctet.
5. Optimisation de la diffusion
Envoyer le même message à chaque client connecté est le goulot d'étranglement #1 pour les serveurs chat / trading / pub-sub. La boucle naïve for each client: client.Send(msg) sérialise et compresse le même payload N fois. Utilisez la diffusion intégrée qui sérialise une fois et réutilise la frame encodée.
// 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);
Sur une machine 16 cœurs, la différence entre la boucle naïve et BroadcastEncoded pour un fan-out de 50 000 clients est de 12 secondes vs 380 ms. Le même principe s'applique aux canaux — pré-encodez la frame, puis parcourez la liste des abonnés. Si vos abonnés sont répartis sur plusieurs canaux, encodez une fois par canal et diffusez à l'intérieur. La pessimisation prématurée dans ce chemin de code coulera un serveur autrement rapide.
6. Optimisation niveau OS
Le noyau impose des limites dures bien avant que le composant ne le fasse. Optimisez celles-ci avant de blâmer la bibliothèque.
| Paramètre | Linux | Windows | Recommandation |
| Limite de descripteurs de fichiers | ulimit -n |
HKLM — MaxUserPort |
2 × connexions attendues |
| Backlog TCP | net.core.somaxconn |
TcpMaxConnectResponseRetransmissions |
4096+ |
| Réutilisation TIME_WAIT | tcp_tw_reuse=1 |
TcpTimedWaitDelay=30 |
Réduire l'épuisement des ports |
| SO_REUSEPORT | noyau ≥3.9 | N/A | Acceptor multi-processus |
| Plage de ports éphémères | net.ipv4.ip_local_port_range |
MaxUserPort |
10000–65535 |
7. Heartbeats et détection d'inactivité
Les clients mobiles tombent du réseau tout le temps. Sans heartbeats, votre serveur garde la socket ouverte jusqu'à ce que le timer keepalive TCP se déclenche (typiquement 2 heures). Configurez des heartbeats courts et la détection de pairs morts.
oServer.HeartBeat.Enabled := True; oServer.HeartBeat.Interval := 30; // seconds oServer.HeartBeat.Timeout := 90; // close if no pong within this
Cela attrape les connexions semi-ouvertes en 90 secondes au lieu de deux heures, libérant des milliers de sockets périmées sur un serveur chargé.
8. Couplage avec un load balancer
Si vous devez passer à l'échelle au-delà d'une seule machine, couplez sgcWebSockets avec notre TsgcWebSocketHTTPServer_LoadBalancer ou un LB L7 externe (HAProxy, nginx, AWS ALB). Deux règles :
- Utilisez des sessions persistantes — les frames WebSocket ne sont pas idempotentes et ne peuvent pas être re-routées en milieu de conversation.
- Transférez les en-têtes originaux
X-Forwarded-Foret de terminaison TLS pour que votre application voie les vraies IP clients.
Benchmarks de référence
Chiffres depuis une seule machine AX102 (16 cœurs / 32 threads, 128 Go), faisant tourner un serveur echo avec des payloads de 100 octets à 10 messages/sec/client.
| Clients concurrents | Débit (msg/s) | Latence p50 | Latence p99 | Usage CPU | RSS |
| 10 000 | 100 000 | 0,8 ms | 3,2 ms | 14% | 0,9 Go |
| 50 000 | 500 000 | 1,1 ms | 5,4 ms | 38% | 3,8 Go |
| 100 000 | 1 000 000 | 1,7 ms | 9,8 ms | 71% | 7,2 Go |
| 250 000 | 2 500 000 | 3,4 ms | 22 ms | 96% | 17,8 Go |
9. Terminaison TLS
Les handshakes TLS sont coûteux en CPU. Si vous servez des milliers de nouvelles connexions par seconde, terminer TLS dans le processus Delphi peut saturer les cœurs à faire de la crypto au lieu de servir des frames. Pour les charges à haute rotation, nous terminons TLS dans nginx ou HAProxy devant le serveur sgcWebSockets et faisons tourner le backend en HTTP brut. Le frontend obtient l'accélération AES matérielle, la reprise de session et l'agrafage OCSP gratuitement, et le processus Delphi peut dépenser 100 % de son CPU sur la logique applicative.
Pour les charges avec des connexions longues persistantes (un scénario typique chat / trading), TLS in-process convient parce que le handshake est amorti sur des heures ou des jours. Pour les rafales connect-disconnect-reconnect (clients mobiles sur des réseaux instables), mettez un reverse proxy devant.
10. Optimisation NIC et réseau
À 1 Gbps vous êtes peu susceptible de saturer la NIC. Au-dessus de 10 Gbps vous devez penser à la fusion d'interruptions, au receive-side scaling (RSS) et à l'épinglage des threads workers sgcWebSockets sur des cœurs NUMA-locaux. Sous Linux, ethtool -L et set_irq_affinity.sh sont vos amis. Sous Windows, définissez RSS Profile sur NUMAScaling dans les propriétés NIC et vérifiez avec Get-NetAdapterRss. À ne tuner que si votre monitoring vous dit que le noyau passe du temps réel en softirq ou DPCs.
Boucle de tuning guidée par profil
Le tuning est itératif. Commencez avec les défauts, exécutez une charge représentative, regardez : CPU par worker, taux de GC / allocation, latence p99 sous fan-out, et compteurs de connexion niveau OS. Changez une chose, relancez, comparez. Les surprises les plus courantes :
- Compression activée sur des payloads déjà compressés → pics CPU pour zéro gain de bande passante.
- Appels DB synchrones dans
OnMessage→ pool de workers saturé à <1 % CPU. - Pas de batching de diffusion → blocage en tête de file pendant les pics d'ouverture de marché.
- Pool de threads par défaut sur une machine 64 cœurs → sérialisation du travail sur 64 workers quand 256 débloqueraient 4× le débit.
Lectures complémentaires
Si vous n'avez pas encore choisi la bonne classe de serveur, commencez par Quelle édition. Puis sautez au composant Load Balancer pour le passage à l'échelle multi-machines. Nouveau sur la bibliothèque ? Le hub Premiers pas vous guide à travers l'installation en cinq minutes.