Ajuste de Desempenho do sgcWebSockets — Escalando para 100k conexões

· Recursos

De “funciona” para “escala”

O sgcWebSockets aguenta alguns milhares de conexões de fábrica, sem tuning. Passar de 50.000 ou 100.000 sockets concorrentes em uma única máquina também é possível — mas exige escolher a classe certa de servidor, dimensionar os pools de threads para sua carga, escolher a estratégia certa de compressão e tunar o sistema operacional. Este post percorre cada alavanca, na ordem em que você deve puxá-las, com números de benchmark de um Hetzner AX102 (Ryzen 9 7950X3D, 128 GB RAM, NIC de 1 Gbps) rodando Windows Server 2025 e Ubuntu 24.04.

Uma palavra sobre metodologia antes de começarmos. Cada número que você vê abaixo vem de uma carga sintética de echo — payloads de 100 bytes, 10 mensagens/s/cliente, sem lógica de negócio. Aplicações reais vão pousar em algum lugar entre esses números e uma ordem de magnitude mais lentos, dependendo do que seu handler OnMessage faz de verdade. Trate isso como teto, não como promessa. O ponto é mostrar o formato da curva de escala e onde estão os precipícios, não dar garantia para uma carga arbitrária.

Além disso: ajuste primeiro o que está mais próximo do usuário. Não faz sentido tirar 200 microssegundos do seu loop de broadcast quando seu handshake TLS leva 80 milissegundos. Faça profile, encontre o gargalo, conserte, repita. A lista abaixo está aproximadamente ordenada por impacto-por-esforço, mas cada carga é diferente.

1. Escolha o modelo de I/O certo

O sgcWebSockets entrega duas famílias de servidores: o TsgcWebSocketServer baseado em Indy (uma thread por conexão) e o TsgcWebSocketHTTPServer apoiado em IOCP/epoll (orientado a eventos). Acima de cerca de 5.000 conexões concorrentes, você quer o segundo. Ponto.

Servidor Modelo de I/O Ponto doce Teto rígido
TsgcWebSocketServer (Indy) Thread por conexão <5.000 ~10.000 (esgotamento de stack de threads)
TsgcWebSocketHTTPServer Win IOCP 10.000–100.000 ~250.000 (file descriptors)
TsgcWebSocketHTTPServer Lin epoll 10.000–100.000 ~1.000.000 (com tuning)
TsgcWebSocketHTTPServer Mac kqueue 10.000–50.000 ~100.000

2. Dimensione o thread pool

O servidor IOCP/epoll usa um worker pool de tamanho fixo. O padrão é CPUCount. Para cargas puras de echo / fan-out, mantenha pequeno (2–4 por core). Para cargas que tocam o banco ou chamam APIs externas dentro de OnMessage, aumente — senão uma requisição lenta bloqueia 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;

Regra de bolso: PoolSize = CPUCount + (média_bloqueante_ms / média_cpu_ms) * CPUCount. Se você vir o Application Insights / seu APM reportando profundidade de fila crescendo, dobre o pool. Há um limite superior rígido imposto pelo scheduler do kernel — dezenas de milhares de threads de SO no Windows não é divertido — mas você normalmente está bem longe disso para cargas sensatas. Acima de 256 workers, considere descarregar o trabalho bloqueante em um pool dedicado.

A melhor decisão arquitetural isolada é manter OnMessage não bloqueante e empurrar toda unidade real de trabalho para uma fila dedicada, atendida por um pool de threads separado. Isso desacopla a thread de I/O (que precisa permanecer rápida) da thread de negócio (que pode ser lenta sem consequência). Também o torna observável: a profundidade da fila vira o principal indicador de “estamos prestes a cair”.

3. Compressão: per-message-deflate

A compressão WebSocket (RFC 7692) corta 60–90% dos payloads JSON ou texto. Ela também é pesada em CPU. Ative globalmente apenas quando sua carga é texto pesado E sua CPU tem folga. Para payloads binários ou já comprimidos (JPEG, MP4, logs em gzip) é 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

Defina Threshold de forma que mensagens menores que o overhead do header não sejam comprimidas. Pular o deflate em um heartbeat de 60 bytes economiza mais CPU do que custa.

Um gotcha sutil: per-message-deflate usa uma janela deslizante que retém estado entre mensagens na mesma conexão. Esse estado é memória por conexão. Com ServerMaxWindow=15 (o padrão), cada conexão guarda cerca de 32 KB de dicionário. Multiplique por 100.000 conexões e você tem 3 GB de RAM só para estado de compressão. Reduza ServerMaxWindow para 10 ou 11 se estiver limitado por memória — você perde alguns por cento de razão de compressão em troca de aproximadamente 8x menos memória por conexão.

4. Fragmentação

Frames grandes (>1 MB) seguram a thread de worker até que a mensagem completa seja remontada. Fragmente as mensagens de saída para manter latências suaves e liberar workers para outros pares.

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

No lado servidor, defina ReadOptions.MaxFrameSize com um limite superior sensato (usamos 4 MB) para se proteger de pares maliciosos que tentam alocar buffers de gigabytes.

5. Otimização de broadcast

Enviar a mesma mensagem para cada cliente conectado é o gargalo nº 1 para servidores de chat / trading / pub-sub. O loop ingênuo for cada cliente: client.Send(msg) serializa e comprime o mesmo payload N vezes. Use o broadcast embutido, que serializa uma única vez e reusa o 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);

Em uma máquina de 16 cores, a diferença entre o loop ingênuo e BroadcastEncoded para um fan-out de 50.000 clientes é 12 segundos vs 380 ms. O mesmo princípio vale para canais — pré-codifique o frame e depois percorra a lista de assinantes. Se seus assinantes se dividem entre muitos canais, codifique uma vez por canal e faça broadcast dentro dele. Pessimismo prematuro nesse caminho de código pode derrubar um servidor que de outra forma seria rápido.

6. Tuning em nível de SO

O kernel impõe limites rígidos muito antes do componente. Ajuste isso antes de culpar a biblioteca.

Configuração Linux Windows Recomendação
Limite de file descriptors ulimit -n HKLM — MaxUserPort 2 × conexões esperadas
Backlog TCP net.core.somaxconn TcpMaxConnectResponseRetransmissions 4096+
Reuso de TIME_WAIT tcp_tw_reuse=1 TcpTimedWaitDelay=30 Reduz esgotamento de portas
SO_REUSEPORT kernel ≥3.9 N/A Acceptor multiprocesso
Faixa de portas efêmeras net.ipv4.ip_local_port_range MaxUserPort 10000–65535

7. Heartbeats e detecção de inatividade

Clientes móveis caem da rede o tempo todo. Sem heartbeats, seu servidor mantém o socket aberto até que o timer de TCP keepalive dispare (tipicamente 2 horas). Configure heartbeats curtos e detecção de par morto.

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

Isso captura conexões half-open em até 90 segundos em vez de duas horas, liberando milhares de sockets velhos em um servidor ocupado.

8. Pareamento com load balancer

Se você precisa escalar além de uma única máquina, combine o sgcWebSockets com nosso TsgcWebSocketHTTPServer_LoadBalancer ou com um LB L7 externo (HAProxy, nginx, AWS ALB). Duas regras:

Benchmarks de referência

Números de uma única máquina AX102 (16 cores / 32 threads, 128 GB), rodando um servidor de echo com payloads de 100 bytes a 10 mensagens/s/cliente.

Clientes concorrentes Throughput (msg/s) Latência p50 Latência 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. Terminação TLS

Handshakes TLS são caros em CPU. Se você atende milhares de novas conexões por segundo, terminar TLS no processo Delphi pode saturar cores fazendo cripto em vez de servir frames. Para cargas de alta rotatividade, terminamos TLS em nginx ou HAProxy na frente do servidor sgcWebSockets e rodamos o backend em HTTP puro. O frontend ganha aceleração AES em hardware, retomada de sessão e OCSP stapling de graça, e o processo Delphi consegue gastar 100% da CPU em lógica de aplicação.

Para cargas com conexões persistentes de longa duração (um cenário típico de chat / trading), TLS no processo está ótimo porque o handshake é amortizado por horas ou dias. Para rajadas de connect-disconnect-reconnect (clientes móveis em redes instáveis), coloque um reverse proxy na frente.

10. Tuning de NIC e rede

A 1 Gbps você dificilmente vai saturar a NIC. Acima de 10 Gbps você tem que pensar em interrupt coalescing, receive-side scaling (RSS) e pinar as threads de worker do sgcWebSockets em cores NUMA-locais. No Linux, ethtool -L e set_irq_affinity.sh são seus amigos. No Windows, defina RSS Profile como NUMAScaling nas propriedades da NIC e verifique com Get-NetAdapterRss. Vale ajustar apenas se seu monitoramento indicar que o kernel está gastando tempo real em softirq ou DPCs.

Loop de tuning guiado por profile

Tuning é iterativo. Comece com os padrões, rode uma carga representativa, olhe: CPU por worker, taxa de GC / alocação, latência p99 sob fan-out e contadores de conexão em nível de SO. Mude uma coisa, rode de novo, compare. As surpresas mais comuns:

Leitura adicional

Se ainda não escolheu a classe de servidor certa, comece em Which Edition. Depois pule para o componente Load Balancer para escalar entre máquinas. Novo na biblioteca? O hub Primeiros Passos guia você pela instalação em cinco minutos.