- Para criar um servidor web de alto desempenho, tradicionalmente eram usados vários modelos baseados em eventos, como select(), poll() e epoll
- Porém, devido aos limites de desempenho dessas chamadas de sistema, surgiu o io_uring, que introduz uma abordagem em que as requisições são colocadas em uma fila para o kernel processá-las de forma assíncrona
- O kTLS coloca o kernel como responsável pelo processamento da criptografia TLS, permitindo otimizações adicionais como uso de sendfile() e offloading por hardware
- A introdução de Descriptorless files oferece uma forma de acesso otimizada para o io_uring sem repassar diretamente descritores de arquivo
- Por meio do projeto open source tarweb, que combina Rust, io_uring e kTLS, é possível oferecer HTTPS sem chamadas de sistema adicionais por requisição, além de discutir questões de segurança e gerenciamento de memória
A evolução da arquitetura de servidores web de alto desempenho
- Desde o início dos anos 2000, cresceu a demanda por servidores web de alta capacidade
- No começo, era comum criar um novo processo para cada requisição, mas, devido ao alto custo, surgiu a técnica de preforking
- Depois vieram as threads e o uso de select() e poll(), evoluindo para uma forma de reduzir o custo de troca de contexto
- Ainda assim, select() e poll() têm limites de escalabilidade, porque, à medida que o número de conexões cresce, é necessário passar com frequência grandes arrays ao kernel
O surgimento do epoll
- No ambiente Linux, o epoll foi introduzido para permitir um tratamento de múltiplas conexões mais eficiente do que os métodos anteriores
- O epoll processa apenas as mudanças (delta), reduzindo o consumo desnecessário de recursos
- Nem todas as chamadas de sistema desaparecem completamente, mas o custo cai de forma significativa
Visão geral do io_uring
- O io_uring adiciona requisições a filas em memória para que o kernel possa processá-las de forma assíncrona, em vez de fazer uma chamada de sistema a cada solicitação
- Por exemplo, se
accept()for colocado na fila, o kernel processa a operação e devolve o resultado na fila de conclusão - O servidor web funciona adicionando solicitações à fila e verificando os resultados em uma região de memória separada
- Para evitar um busy loop, quando não há mudanças na fila, tanto o servidor web quanto o kernel chamam chamadas de sistema apenas quando necessário, economizando energia
- Com bibliotecas adequadas, um servidor ativo pode operar sem chamadas de sistema adicionais durante o processamento das requisições
Ambiente multicore e NUMA
- Considerando o ambiente multicore dos CPUs modernos, uma estratégia eficaz é executar uma única thread por núcleo e minimizar o compartilhamento de estruturas de dados
- Em ambientes NUMA, cada thread pode ser otimizada para acessar apenas a memória do seu nó local
- O balanceamento perfeito da distribuição de requisições ainda exige mais pesquisa
Alocação de memória
- A alocação de memória continua existindo tanto no kernel quanto no servidor web, e as alocações no espaço do usuário acabam se conectando a chamadas de sistema
- Do lado do servidor web, blocos de memória de tamanho fixo podem ser pré-alocados por conexão para evitar fragmentação e falta de memória
- Do lado do kernel, também são necessários buffers de entrada e saída por conexão, com algum ajuste possível por meio de opções de socket
- Quando ocorre falta de memória, isso pode levar a falhas graves
Introdução ao kTLS (TLS no kernel)
- O kTLS é um recurso em que o kernel Linux assume as operações de criptografia e descriptografia
- O handshake é tratado pela aplicação, mas, depois disso, o kernel passa a tratar a transmissão dos dados como se fossem texto puro
- Isso permite usar
sendfile(), reduzindo cópias de memória entre o espaço do usuário e o do kernel - Se a placa de rede der suporte, há ainda a vantagem de fazer offloading das operações de criptografia para o hardware
Descriptorless Files
- Essa abordagem surgiu para reduzir o overhead gerado ao repassar diretamente descritores de arquivo do espaço do usuário para o espaço do kernel
- Com
register_files, usa-se um número de arquivo separado, válido apenas para o io_uring, que não aparece em/proc/pid/fd - O limite de
ulimitdo sistema ainda se aplica
Introdução ao projeto tarweb
- O tarweb é um projeto open source de servidor web que aplica todas as tecnologias acima como exemplo
- Ele serve o conteúdo de um único arquivo tar, combinando tecnologias modernas de alto desempenho como Rust, io_uring e kTLS
- No uso prático, houve problemas de compatibilidade entre io_uring e kTLS (como falta de suporte a
setsockopt), e algumas dessas questões foram resolvidas por meio de Pull Requests - O projeto ainda está incompleto, e a biblioteca rustls do Rust pode fazer alocação de memória durante o processo de handshake
- O ponto principal é que é possível oferecer serviço HTTPS sem chamadas de sistema adicionais por requisição
Benchmark e medição de desempenho
- O autor ainda não realizou benchmarks suficientes e pretende testar o desempenho depois de organizar melhor o código
Problemas de segurança em io_uring e Rust
- Diferentemente das chamadas de sistema síncronas, no io_uring os buffers de memória não podem ser liberados antes do evento de conclusão
- O crate io-uring não garante a segurança em tempo de compilação do Rust, e as verificações em tempo de execução também são insuficientes
- Se usado incorretamente, isso pode causar problemas graves semelhantes aos de C++, enfraquecendo a segurança inerente do Rust
- É necessário um crate separado, como o safer-ring, que aproveite ativamente pinning e o borrow checker
- Esse tema já está sendo discutido na comunidade
Referências e links adicionais
- Este conteúdo é um post discutido no HackerNews em 2025-08-22
1 comentários
Comentários do Hacker News
Ao usar
io_uringpara submeter operações de escrita, é preciso garantir que a posição de memória não seja liberada nem sobrescrita, mas a API da crateio-uringaparentemente não recebe ajuda do borrow checker do Rust nessa parte e também não faz checagens em tempo de execuçãoJá li textos e comentários sobre essa situação, e no fim a impressão é que é realmente muito difícil criar uma biblioteca assíncrona segura em Rust que encapsule
io_uringTambém lembro de a Alice, da equipe do tokio, mencionar recentemente que não há tanto interesse em superar esse problema
Porque, no momento, o desempenho já está em um nível “bom o suficiente”
Referência: https://boats.gitlab.io/blog/post/io-uring/
Tenho várias frustrações com o async do Rust, e essa é uma delas
O async do Rust foi projetado numa época em que
epollera o padrão, e quase não levou IOCP em consideraçãoSyscalls síncronas não têm esse problema porque, numa chamada
read, você passa uma referência mutável do buffer ao kernel, e isso combina bem com o modelo nativo de ownership/borrow do RustMas, para I/O baseado em completion, para encaixar corretamente no modelo de ownership, seria preciso garantir que o código do usuário não continue executando até a operação terminar, e isso não dá para fazer com uma estrutura de polling por máquina de estados
Um modelo com threads ou green threads se encaixa perfeitamente aqui
Acho que teria sido melhor se Rust tivesse adicionado um “alvo dedicado a async”
A equipe do Rust apostou bastante no modelo assíncrono stackless baseado em polling, e agora estamos vendo no que isso vai dar
Acho que existe um modelo de ownership que o borrow checker do Rust não consegue representar direito
Chamo isso provisoriamente de “ownership da batata quente”: você entrega o buffer por um momento e depois o recebe de volta
Tentar escrever esse padrão com segurança em Rust é bem difícil e o código acaba ficando bagunçado
Ao contrário do que a Alice da equipe do tokio disse, há interesse no lado de I/O de arquivos
I/O de arquivos já é implementado com
spawn_blocking, então já sofre do mesmo problema de buffer doio_uring, e migrar paraio_uringnão é tão difícilMas a API atual de
tokio::netnão é compatível com APIs de buffer baseadas emio_uring, então dá para fazer verificação de readiness, mas suporte completo é difícilPara criar uma interface segura para
io_uring, acho que o mais adequado é receber buffers pertencentes ao ring para usar e devolvê-los ao iniciar a escritaNão é obrigatório expressar tudo com borrows
Se usar uma estrutura de dados como
Slab, dá para tornar isso cancel-safeReferência: https://github.com/steelcake/io2
Gostei muito de ler este artigo
Estou curioso pelos testes de desempenho, mas me marcou o fato de o autor dizer que quer primeiro organizar o código antes de fazer benchmarks
Numa época em que só se fala de benchmark, é revigorante ver alguém pensando assim
Quando eu tinha uns 11 anos, tive contato com
cgi-binao tentar montar um banco de dados, e só agora percebo que aquilo criava um processo novo para cada requisiçãosendfilefoi um divisor de águas quando grandes fóruns de jogos precisavam servir downloads de demos simultaneamente, e ao ver resultados como a redução de 40 ms da Netflix ou os 70% de redução no tempo de carregamento do GTA 5, sinto que existe uma engenharia ainda mais impactante escondida aliLinks relacionados: Common Gateway Interface, caso dos 40 ms da Netflix, redução de carregamento do GTA Online
Não era só CGI: no passado, sessões HTTP da linhagem CERN e Apache também funcionavam forkeando o servidor inteiro
Com o tempo isso melhorou, mas a forma de configuração do Apache acabou ajudando servidores leves, como o nginx, que já nasceram com I/O orientado a eventos, a ganharem enorme popularidade
Sou cético quanto à eficiência do
sendfileVirou moda no fim dos anos 90, mas acho que, na prática, o ganho de desempenho é pequeno
A maioria dos orquestradores de workloads em nuvem (
CloudRun,GKE,EKS, Docker local etc.) desativaio_uringpor padrãoSe isso não melhorar,
io_uringdeve continuar sendo uma tecnologia muito limitada por enquantoFico me perguntando por que eles desativam
io_uringSe a situação é essa, então temos que voltar para self-hosting
Foi realmente muito interessante de ler
Vou esperar pelos benchmarks, então pode ir com calma, e a postura do autor de priorizar a arrumação do código antes dos benchmarks me impressionou demais
Hoje em dia há muitos projetos obcecados por pontuação de benchmark, então esse jeito de pensar é realmente refrescante e admirável
Eu não sabia que
ktlseio_uringpodiam ser usados de tantas formas diferentes assimO estado atual do processamento assíncrono é mais ou menos este
Rust: é preciso entender vários conceitos como futures,
Pin,Waker, runtime async, boundsSend/Sync, objetos de trait async etc.C++20: coroutines
Go: goroutines
Java21+: threads virtuais
Corrotinas de C++ usam alocação em heap para evitar o problema que
PinresolveIsso se afasta bastante do princípio de “zero overhead” que o C++ defende
O motivo de o Rust também ter demorado tanto para introduzir async traits no futuro é que Rust não aloca futures na heap
O trade-off entre desempenho/portabilidade e complexidade pode ter valores diferentes conforme o projeto
As restrições relacionadas a
Send/Syncainda fazem sentido em outras linguagens, e sem elas fica mais fácil escrever código sutilmente incorretoSe você escrever código Rust em um nível “bom o suficiente” e usar primitivas de nível intermediário feitas por outras pessoas, não precisa necessariamente entender todos esses conceitos
Rust força você a entender esses conceitos, senão o código nem compila
Em Go, goroutine não é a mesma coisa que assíncrono, e, se você não entende canais, também não entende goroutines
A implementação de canais em Go é peculiar, então o comportamento em casos de borda não é algo que dê para prever intuitivamente
Como em Go dá para programar sem entender isso a fundo, há vantagens e desvantagens
“Threads baratas” não são a mesma coisa que assíncrono
O
tarweb(o servidor do post) tem estrutura single-thread baseada em um event loop comio_uring, com a ideia de usar uma thread por núcleo de CPUParece mais correto falar em “estado atual das threads baratas” do que em “estado atual da concorrência em massa”
A maior diferença entre threads baratas e um loop async é que raciocinar sobre elas é mais fácil
Também há desvantagens: cada thread, por mais leve que seja, precisa de espaço de stack
kTLScertamente é um avançoEu mesmo criei há alguns anos um servidor com literalmente 0 syscall por requisição e escrevi um post sobre isso (https://wjwh.eu/posts/2021-10-01-no-syscall-server-iouring.html)
Mas há a desvantagem de precisar fazer busy-looping o tempo todo
io_uringevoluiu em um ritmo realmente impressionante nos últimos anosEste projeto é muito legal, e eu vinha imaginando algo parecido há muito tempo, então fico feliz de ver alguém implementando
Se você quiser escrever BPF em Rust, recomendo o Aya
Github do projeto Aya
Fico curioso sobre o estado atual do
kTLSPerguntei há pouco tempo a um desenvolvedor do Cilium, e o Thomas Graf disse que está animado, mas que, na prática, muitas distribuições Linux ainda têm pouco suporte no kernel, então ativar isso por padrão ainda parece distante
É uma pena, mas também fico curioso sobre o quão difícil é habilitar isso
Precisa customizar o kernel ou dá para ligar diretamente em tempo de execução?
O FreeBSD tem
kTLSno kernel/OpenSSL desde a versão 13, e é possível alternar em tempo de execução comsysctl(kern.ipc.tls.enable=1)No FreeBSD-15, isso mudará para ativado por padrão, e a Netflix já usa
kTLSpara criptografar tráfego há quase 10 anoskTLS, no geral, parece uma má ideiaFico em dúvida se a estrutura de uma thread por núcleo faz sentido em sistemas baseados em time slice
Pela minha experiência, o modelo de “oversubscription” (ter mais threads do que núcleos) traz ganhos no tempo real de relógio
Acho que uma thread por núcleo faz mais sentido quando não há escalonamento preemptivo
Claro, nesse caso não estaríamos falando de Unix
Se você quer baixa latência e alta vazão, isolar núcleos e fixar threads neles é uma abordagem eficaz
Isso funciona bem em Linux e é muito usado, por exemplo, em sistemas de trading, mesmo aceitando alguma ineficiência
Na maior parte do tempo os núcleos ficam ociosos rodando em spin sem trabalho real, mas em latência e throughput isso é ótimo
A armadilha da estrutura thread-per-core é achar que dá para “aproveitar só as partes convenientes”
Na prática, ou você adota de verdade ou não usa
Uma implementação pela metade não traz eficiência nenhuma
Mas, se for bem projetada, ela é altamente eficiente em quase qualquer cenário
São poucos os desenvolvedores que realmente conhecem as técnicas de projeto de TPC, como balanceamento de carga entre núcleos
Em thread-per-core, isso só é eficiente quando a carga é “CPU-bound”
Quando, como neste projeto de servidor, a maior parte do trabalho é assíncrona e orientada a eventos, o servidor praticamente passa para a próxima requisição sem esperar por I/O ou syscall, então, em teoria, uma thread por núcleo é exatamente a estrutura correta
Mas é importante lembrar que, no mundo real, essa situação ideal quase nunca existe, então limitar tudo cegamente a
nprocthreads é arriscadoEm
io_uring, não parece uma escolha ruim ter apenas uma thread de usuário por núcleoPorque o kernel atua por trás como um pool de threads
Eu também gostaria de ver um estilo como o do DPDK, que ignora completamente o kernel
Link do artigo: https://www.usenix.org/system/files/atc23-zhu-lingjun.pdf