perf(epoll): run the HTTP pipeline inline in the worker instead of hopping through TTask.Run - #553
Conversation
O despacho de TODO request ao TThreadPool.Default especulava o pool ate Max=2048 threads (Min=8xCPU): sob carga o processo satura em 2078 threads com 6146 amostras futex_do_wait vs 84 epoll_wait, e o p99 da cauda explode (/ping c4: p99 515ms com timeouts; c100 88ms). Agora a anonima e invocada sincronamente no proprio worker -- corpo identico, zero hop. Medido no container (wrk 4t): - /ping c4: 6900 -> 14902 req/s; p99 515ms -> 0,44ms; timeouts zerados - /ping c100: 7327 -> 92024 req/s; p99 88ms -> 3,8ms - /consulta ate 4,4x; threads sob carga: 29 estaveis Nota de ambiente: com quota de CPU restrita (cpu.max 4 CPUs) o server corrigido satura a quota antes de revelar o ganho completo.
|
Obrigado pelos benchmarks detalhados — eles mostram claramente o custo do uso de Executar toda a pipeline dentro do worker do epoll cria head-of-line blocking: um handler lento ou bloqueado impede o worker de atender todas as outras conexões atribuídas a ele. Isso altera o comportamento de concorrência do provider e pode produzir uma regressão grave em aplicações com banco de dados, filesystem ou chamadas externas. Antes do merge, precisamos de uma destas soluções:
Além disso, inclua:
A eliminação da explosão de threads é desejável; o ponto pendente é garantir que a solução não troque esse problema por bloqueio do event loop. |
|
Reaprovado e validado em 30/08 no crud-delphi-framework (dívida #93 e #96). O pipeline inline no worker já está em produção na nossa árvore e passou por:
Diff confere com o que usamos (mesmo patch). Sem checks configurados no repo — validação manual. Pode mergear. |
|
Obrigado pelos dados adicionais e pelo trabalho no diagnóstico. Queremos aproveitar o patch deste PR, preservando seu commit e sua autoria, e colaborar diretamente na própria branch para completar a solução no Horse. A proposta é manter o modo inline introduzido por você, mas adicionar ao provider uma alternativa segura baseada em pool próprio, limitado e configurável. Assim evitamos as 2.048 threads do Como a opção Allow edits from maintainers está habilitada, podemos preparar esses commits diretamente na branch Você concorda que façamos essa complementação na sua branch? Pretendemos preservar seu commit atual intacto e adicionar commits separados com configuração, executor limitado, encerramento gracioso e testes. Se tiver preferência de API ou nomenclatura, por favor nos avise antes de finalizarmos. |
|
Obrigado pela contribuição e pelos benchmarks que identificaram o custo do Antes do merge, complementamos o PR diretamente nesta branch com dois commits:
O estado final ficou assim:
Validação realizada em Docker/Linux:
Dessa forma, mantemos o ganho de desempenho demonstrado pelo seu patch sem torná-lo uma regressão para aplicações com handlers bloqueantes. O PR está pronto e será aceito. Obrigado novamente pelo diagnóstico e pela implementação inicial. |
Problem
On Delphi Linux,
THorseEpollWorker.ProcessClientReadhands the entire HTTP pipeline toTTask.Run, i.e. toTThreadPool.Default— which the provider itself configures withMin = ProcessorCount * 8andMax = 2048.The epoll worker has already done the hard part (it owns the connection, the request bytes are parsed and in hand). Handing the rest to a global pool buys no parallelism the workers don't already have — one worker per core — and costs a thread wake-up per request.
Under load the pool speculates all the way up to its ceiling and the tail collapses. Measured on a 28-core container, benchmark server with three routes (
/pingplain text,/pool,/consultareturning 50 JSON rows),wrk4 threads, 10s per point:wchancensus under loadfutex_do_waitvs 84 inepoll_waitperfandptracewere unavailable in that environment (WSL2 kernel,perf_event_paranoid=2, noCAP_SYS_PTRACE), so the evidence above is from/proc/<pid>/task/*/wchan, thread counts and/proc/*/status, plus temporary instrumentation compiled into the provider and reverted afterwards.Change
Bind the existing anonymous procedure to a local
TProcand call it synchronously. The body is byte-identical — no logic touched, no reordering. The diff is 7 insertions / 3 deletions in one file; the FPC path (GTaskPool/HORSE_EPOLL_SYNCHRONOUS) is untouched.Results
Same benchmark, same box, before → after:
/ping/ping/consultaThreads under load: 2078 → 29 stable,
wchan100%epoll_wait, zero futex waits.One caveat found while measuring, in case it saves someone else the trip: once this patch is in, the server becomes efficient enough to saturate a container CPU quota that the old design never reached. The residual c100 timeouts we chased for a while turned out to be cgroup throttling (~150 throttle events in a 15s run at
cpu.max = 400000/100000), not the provider. Worth checking/sys/fs/cgroup/cpu.statbefore blaming code.Known trade-off
Inline processing means a slow handler now blocks the other connections pinned to that same worker (head-of-line blocking per worker), where before it only occupied a pool thread. For the routes above the trade is overwhelmingly worth it, but a deployment with genuinely slow handlers may want a small dedicated pool or selective dispatch for those routes rather than the global
TThreadPool.Default. Happy to shape it that way instead if you prefer — e.g. inline by default with an opt-in define for async dispatch.Testing
Built and exercised on Delphi 12 / Linux64 with
HORSE_PROVIDER_EPOLL. The three routes above were served correctly (status, headers, JSON bodies) throughout every run; the change has been running as the provider for our framework's benchmark and integration server.