De "funciona" a "escala"
sgcWebSockets maneja unos pocos miles de conexiones de fábrica sin tuning. Empujar más allá de 50.000 o 100.000 sockets concurrentes en una sola caja también es posible — pero requiere elegir la clase de servidor correcta, dimensionar los thread pools a tu carga de trabajo, escoger la estrategia de compresión adecuada y ajustar el sistema operativo. Este post recorre cada palanca, en el orden en que deberías tirarlas, con números de benchmark en un Hetzner AX102 (Ryzen 9 7950X3D, 128 GB RAM, NIC 1 Gbps) corriendo Windows Server 2025 y Ubuntu 24.04.
Una palabra sobre metodología antes de empezar. Cada número que verás abajo es de una carga de eco sintética — payloads de 100 bytes, 10 mensajes/seg/cliente, sin lógica de negocio. Las aplicaciones reales caerán en algún punto entre estos números y un orden de magnitud más lento dependiendo de lo que haga tu manejador OnMessage realmente. Trátalos como techos, no promesas. El punto es mostrar la forma de la curva de escalado y dónde están los precipicios, no darte una garantía para una carga arbitraria.
Además: ajusta primero lo más cercano al usuario. No tiene sentido raspar 200 microsegundos de tu bucle de broadcast cuando tu handshake TLS tarda 80 milisegundos. Profilea, encuentra el cuello de botella, arréglalo, repite. La lista de abajo está aproximadamente ordenada por impacto-por-esfuerzo, pero cada carga es diferente.
1. Elige el modelo de I/O correcto
sgcWebSockets distribuye dos familias de servidor: el TsgcWebSocketServer basado en Indy (un-hilo-por-conexión) y el TsgcWebSocketHTTPServer respaldado por IOCP/epoll (event-driven). Pasadas unas 5.000 conexiones concurrentes, quieres el segundo. Punto.
| Servidor | Modelo de I/O | Punto óptimo | Techo duro |
| TsgcWebSocketServer (Indy) | Thread por conexión | <5.000 | ~10.000 (agotamiento de stack de hilo) |
| TsgcWebSocketHTTPServer Win | IOCP | 10.000–100.000 | ~250.000 (file descriptors) |
| TsgcWebSocketHTTPServer Lin | epoll | 10.000–100.000 | ~1.000.000 (con tuning) |
| TsgcWebSocketHTTPServer Mac | kqueue | 10.000–50.000 | ~100.000 |
2. Dimensiona el thread pool
El servidor IOCP/epoll usa un pool de workers de tamaño fijo. Por defecto es CPUCount. Para cargas puras de eco / fan-out mantenlo pequeño (2–4 por core). Para cargas que tocan la base de datos o llaman a APIs externas dentro de OnMessage, súbelo — si no una sola petición lenta bloquea a N pares.
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;
Regla general: PoolSize = CPUCount + (average_blocking_ms / average_cpu_ms) * CPUCount. Si ves que Application Insights / tu APM reporta una profundidad de cola creciente, dobla el pool. Hay un límite superior duro impuesto por el scheduler del kernel — decenas de miles de hilos de SO en Windows no es divertido — pero normalmente no estás ni de lejos cerca para cargas sensatas. Pasados 256 workers, considera descargar el trabajo bloqueante a un pool de workers dedicado.
La mejor decisión arquitectónica individual es mantener OnMessage no bloqueante y empujar cada unidad de trabajo real a una cola dedicada servida por un pool de hilos separado. Eso desacopla el hilo de I/O (que debe permanecer rápido) del hilo de negocio (que puede ser lento sin consecuencias). También te hace observable: la profundidad de la cola se convierte en el indicador adelantado de "estamos a punto de caer".
3. Compresión: per-message-deflate
La compresión WebSocket (RFC 7692) recorta entre un 60 y un 90% de los payloads JSON o texto. También es intensiva en CPU. Actívala globalmente sólo cuando tu carga sea pesada en texto Y tu CPU tenga margen. Para payloads binarios o ya comprimidos (JPEG, MP4, logs gzipados) es puro sobrecoste.
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
Fija Threshold para que los mensajes más pequeños que el sobrecoste de cabecera no se compriman. Saltarse el deflate en un heartbeat de 60 bytes ahorra más CPU que lo que cuesta.
Un gotcha sutil: per-message-deflate usa una ventana deslizante que retiene estado entre mensajes en la misma conexión. Ese estado es memoria por conexión. Con ServerMaxWindow=15 (el por defecto), cada conexión sostiene aproximadamente 32 KB de diccionario. Multiplica por 100.000 conexiones y tienes 3 GB de RAM sólo para estado de compresión. Baja ServerMaxWindow a 10 u 11 si estás limitado en memoria — pierdes un pequeño porcentaje de ratio de compresión a cambio de aproximadamente 8x menos memoria por conexión.
4. Fragmentación
Los frames grandes (>1 MB) retienen el hilo worker hasta que el mensaje completo se reensambla. Fragmenta los mensajes salientes para mantener latencias suaves y liberar workers para otros pares.
oClient.WriteOptions.FragmentEnabled := True; oClient.WriteOptions.FragmentSize := 65536; // 64 KB chunks
En el lado servidor, fija ReadOptions.MaxFrameSize a un límite superior sensato (usamos 4 MB) para protegerte contra pares maliciosos que intenten asignar buffers de gigabytes.
5. Optimización de broadcast
Enviar el mismo mensaje a todos los clientes conectados es el cuello de botella número 1 para servidores chat / trading / pub-sub. El bucle naíf for each client: client.Send(msg) serializa y comprime el mismo payload N veces. Usa el broadcast integrado que serializa una vez y reutiliza el frame codificado.
// 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);
En una caja de 16 cores, la diferencia entre el bucle naíf y BroadcastEncoded para un fan-out de 50.000 clientes es 12 segundos vs 380 ms. El mismo principio se aplica a canales — pre-codifica el frame y luego recorre la lista de suscriptores. Si tus suscriptores se reparten en muchos canales, codifica una vez por canal y haz broadcast dentro. La pesimización prematura en esta ruta de código hundirá un servidor por lo demás rápido.
6. Tuning a nivel de SO
El kernel impone límites duros mucho antes que el componente. Ajusta estos antes de culpar a la librería.
| Setting | Linux | Windows | Recomendación |
| Límite de file descriptors | ulimit -n |
HKLM — MaxUserPort |
2 × conexiones esperadas |
| Backlog TCP | net.core.somaxconn |
TcpMaxConnectResponseRetransmissions |
4096+ |
| Reutilización TIME_WAIT | tcp_tw_reuse=1 |
TcpTimedWaitDelay=30 |
Reduce agotamiento de puertos |
| SO_REUSEPORT | kernel ≥3.9 | N/D | Aceptador multi-proceso |
| Rango de puertos efímeros | net.ipv4.ip_local_port_range |
MaxUserPort |
10000–65535 |
7. Heartbeats y detección de inactivos
Los clientes móviles caen de la red continuamente. Sin heartbeats, tu servidor mantiene el socket abierto hasta que dispara el temporizador TCP keepalive (típicamente 2 horas). Configura heartbeats cortos y detección de pares muertos.
oServer.HeartBeat.Enabled := True; oServer.HeartBeat.Interval := 30; // seconds oServer.HeartBeat.Timeout := 90; // close if no pong within this
Esto captura conexiones medio abiertas en 90 segundos en lugar de dos horas, liberando miles de sockets obsoletos en un servidor ocupado.
8. Emparejamiento con load balancer
Si necesitas escalar más allá de una sola caja, empareja sgcWebSockets con nuestro TsgcWebSocketHTTPServer_LoadBalancer o un LB L7 externo (HAProxy, nginx, AWS ALB). Dos reglas:
- Usa sticky sessions — los frames WebSocket no son idempotentes y no pueden re-enrutarse a mitad de conversación.
- Reenvía las cabeceras originales
X-Forwarded-Fory de terminación TLS para que tu aplicación vea las IPs reales del cliente.
Benchmarks de referencia
Números de una sola caja AX102 (16 cores / 32 hilos, 128 GB), corriendo un servidor de eco con payloads de 100 bytes a 10 mensajes/seg/cliente.
| Clientes concurrentes | Throughput (msg/s) | Latencia p50 | Latencia p99 | Uso de 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. Terminación TLS
Los handshakes TLS son caros en CPU. Si sirves miles de conexiones nuevas por segundo, terminar TLS en el proceso Delphi puede saturar cores haciendo cripto en lugar de servir frames. Para cargas de alta rotación terminamos TLS en nginx o HAProxy delante del servidor sgcWebSockets y corremos el backend en HTTP plano. El frontend obtiene aceleración AES por hardware, reanudación de sesión y OCSP stapling gratis, y el proceso Delphi gasta el 100% de su CPU en lógica de aplicación.
Para cargas con conexiones persistentes de larga vida (un escenario típico de chat / trading), el TLS en proceso está bien porque el handshake se amortiza en horas o días. Para ráfagas conectar-desconectar-reconectar (clientes móviles en redes inestables), pon un proxy inverso delante.
10. Ajuste de NIC y red
A 1 Gbps es improbable que satures la NIC. Por encima de 10 Gbps tienes que pensar en interrupt coalescing, receive-side scaling (RSS) y anclar los hilos worker de sgcWebSockets a cores NUMA-locales. En Linux, ethtool -L y set_irq_affinity.sh son tus amigos. En Windows, fija RSS Profile a NUMAScaling en las propiedades de la NIC y verifica con Get-NetAdapterRss. Vale la pena ajustar sólo si tu monitorización te dice que el kernel está gastando tiempo real en softirq o DPCs.
Bucle de tuning guiado por profile
El tuning es iterativo. Empieza con los defaults, ejecuta una carga representativa, mira: CPU por worker, ratio de GC / allocation, latencia p99 bajo fan-out y contadores de conexión a nivel de SO. Cambia una cosa, vuelve a ejecutar, compara. Las sorpresas más comunes:
- Compresión activada en payloads ya comprimidos → picos de CPU para cero ganancia de ancho de banda.
- Llamadas síncronas a BD dentro de
OnMessage→ pool de workers saturado a <1% de CPU. - Sin batching de broadcast → head-of-line blocking durante los picos de apertura de mercado.
- Thread pool por defecto en una caja de 64 cores → serializando trabajo en 64 workers cuando 256 desbloquearían 4× el throughput.
Lectura adicional
Si todavía no has elegido la clase de servidor correcta, empieza en Qué edición. Luego salta al componente Load Balancer para escalado multi-caja. ¿Nuevo en la librería? El hub de Primeros Pasos te guía por la instalación en cinco minutos.