2 pontos por GN⁺ 2025-08-23 | 1 comentários | Compartilhar no WhatsApp
  • 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 ulimit do 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

 
GN⁺ 2025-08-23
Comentários do Hacker News
  • Ao usar io_uring para submeter operações de escrita, é preciso garantir que a posição de memória não seja liberada nem sobrescrita, mas a API da crate io-uring aparentemente não recebe ajuda do borrow checker do Rust nessa parte e também não faz checagens em tempo de execução
    Já 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_uring
    També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 epoll era o padrão, e quase não levou IOCP em consideração
      Syscalls 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 Rust
      Mas, 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 do io_uring, e migrar para io_uring não é tão difícil
      Mas a API atual de tokio::net não é compatível com APIs de buffer baseadas em io_uring, então dá para fazer verificação de readiness, mas suporte completo é difícil

    • Para 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 escrita

    • Não é obrigatório expressar tudo com borrows
      Se usar uma estrutura de dados como Slab, dá para tornar isso cancel-safe
      Referê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-bin ao tentar montar um banco de dados, e só agora percebo que aquilo criava um processo novo para cada requisição
    sendfile foi 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 ali
    Links 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 sendfile
      Virou 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.) desativa io_uring por padrão
    Se isso não melhorar, io_uring deve continuar sendo uma tecnologia muito limitada por enquanto

    • Fico me perguntando por que eles desativam io_uring

    • Se 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 ktls e io_uring podiam ser usados de tantas formas diferentes assim

  • O estado atual do processamento assíncrono é mais ou menos este
    Rust: é preciso entender vários conceitos como futures, Pin, Waker, runtime async, bounds Send/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 Pin resolve
      Isso 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/Sync ainda fazem sentido em outras linguagens, e sem elas fica mais fácil escrever código sutilmente incorreto

    • Se 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 com io_uring, com a ideia de usar uma thread por núcleo de CPU
      Parece 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

  • kTLS certamente é um avanço
    Eu 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_uring evoluiu em um ritmo realmente impressionante nos últimos anos

  • Este 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 kTLS
    Perguntei 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 kTLS no kernel/OpenSSL desde a versão 13, e é possível alternar em tempo de execução com sysctl (kern.ipc.tls.enable=1)
      No FreeBSD-15, isso mudará para ativado por padrão, e a Netflix já usa kTLS para criptografar tráfego há quase 10 anos

    • kTLS, no geral, parece uma má ideia

  • Fico 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 nproc threads é arriscado

    • Em io_uring, não parece uma escolha ruim ter apenas uma thread de usuário por núcleo
      Porque 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