1 pontos por GN⁺ 3 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • O proxy reverso TinyGate melhorou o desempenho ao mudar de uma estrutura baseada em workers para epoll, mas depois encontrou limites e foi reescrito novamente com io_uring
  • O epoll é um modelo de prontidão que informa quando o I/O pode ser realizado, então após epoll_wait ainda é preciso chamar read()/write() separadamente
  • O io_uring é um modelo de conclusão orientado ao término do I/O, em que a aplicação e o kernel trocam fila de submissão e fila de conclusão por meio de um ring buffer compartilhado
  • io_uring_enter() é basicamente necessário, mas permite submeter e coletar várias operações de uma vez; IORING_SETUP_SQPOLL reduz syscalls em troca de uso de CPU
  • Se for iniciar um novo projeto em um servidor Linux moderno com kernel v5.1+, io_uring é considerado uma escolha mais adequada do que epoll

Os limites do epoll expostos pelo TinyGate

  • O TinyGate era um servidor proxy reverso criado com estudantes, e a primeira versão tinha uma estrutura simples baseada em workers
  • Funcionava como projeto educacional, mas em comparação com ferramentas como nginx ou haproxy, tinha grandes limitações de arquitetura
  • A segunda versão passou a ser baseada em epoll e teve desempenho muito melhor que a primeira
    • Ainda assim, nos benchmarks, não conseguiu superar nginx/haproxy
  • Depois, por causa das limitações do epoll, migrou para io_uring, e o projeto acabou sendo reescrito do zero

epoll: notificação de prontidão e syscalls repetidas

  • O epoll é um método antigo de gerenciamento de I/O assíncrono no Linux e entrou no kernel Linux em 2002
  • O ponto central é a notificação de prontidão, que informa quando o I/O pode ser realizado
    • O epoll informa que “é possível ler ou escrever”
    • A leitura e a escrita reais dos dados são feitas depois pela aplicação com as syscalls read() ou write()
  • No fluxo típico, o custo de syscall se repete a cada evento
    • epoll_ctl é uma syscall única para registrar file descriptors
    • Em cada evento real de I/O, são necessários epoll_wait e read()/write()
    • Como resultado, syscalls extras continuam sendo adicionadas ao processamento de eventos
  • Syscalls geram troca de contexto entre modo usuário e modo kernel, e o overhead aumenta conforme cresce o número de conexões

io_uring: modelo de conclusão e ring buffer compartilhado

  • O io_uring surgiu em 2019, cerca de 17 anos depois de o epoll entrar no kernel Linux, e é suportado a partir do kernel v5.1+
  • Diferentemente do epoll, ele opera não com base em saber se o I/O está disponível, mas sim em saber se o I/O foi concluído
  • A aplicação e o kernel usam juntos um ring buffer em memória compartilhada
    • Na fila de submissão, a aplicação coloca as operações que quer solicitar ao kernel
    • Na fila de conclusão, o kernel publica de volta os resultados concluídos
  • Na configuração padrão, é preciso chamar io_uring_enter() para que o kernel verifique a fila de submissão
    • Uma única chamada pode submeter várias operações e recolher várias conclusões
    • Não é uma estrutura que repete um par de syscalls por operação, como na combinação de epoll com read()
  • Com IORING_SETUP_SQPOLL, uma thread do kernel faz polling da fila de submissão
    • Em operação normal, isso pode praticamente eliminar syscalls
    • Como a thread do kernel continua rodando mesmo quando a fila está vazia, há consumo de CPU
    • Após sq_thread_idle, ela pode entrar em sleep, mas isso não elimina o custo

Diferença vista em exemplos de código

  • Exemplo com epoll

    • Registra o file descriptor de stdin e, quando chega um evento, chama read() separadamente
    • Cria uma instância epoll com epoll_create1
    • Registra STDIN_FILENO com epoll_ctl
    • Bloqueia com epoll_wait até que seja possível ler
    • Quando o evento chega, lê os dados com a syscall read()
    • Nesse fluxo, cada evento real de I/O exige epoll_wait e read
  • Exemplo com io_uring

    • Usa liburing
    • Inicializa o ring com io_uring_queue_init
    • Obtém uma entrada da fila de submissão com io_uring_get_sqe
    • Prepara uma operação de leitura de stdin com io_uring_prep_read
    • Submete com io_uring_submit e espera a conclusão com io_uring_wait_cqe
    • No exemplo com io_uring, não há verificação separada de estado de prontidão, e não se chama read() à parte no momento da conclusão
    • Para simplificar, os dois exemplos omitem tratamento importante de exceções
    • Se não houver dados em stdin, eles podem bloquear para sempre
    • O exemplo de io_uring não verifica o caso em que io_uring_get_sqe() retorna NULL quando a fila de submissão está cheia

Condições adicionais ao usar io_uring

  • Para usar zero-copy I/O, é preciso registrar buffers antecipadamente com io_uring_register_buffers()
    • Isso evita que o kernel precise remapear a memória a cada operação
    • Em transmissão de rede, IORING_OP_SEND_ZC no kernel 6.0+ fornece envio sem copiar o buffer para o kernel
  • IORING_SETUP_SQPOLL pode reduzir syscalls, mas o custo é o uso de CPU
    • A thread do kernel continua fazendo polling mesmo quando a fila está vazia
    • Após o timeout de idle, pode passar para sleep, mas o custo não desaparece
  • Os erros no io_uring não retornam diretamente como valor de retorno de syscall síncrona, e sim de forma assíncrona no campo res da entrada da fila de conclusão
    • O tratamento de erro deve ser feito por meio de cqe->res

Escolha em servidores Linux modernos

  • O epoll é uma forma antiga de I/O assíncrono no Linux, baseada em notificação de disponibilidade de I/O e chamadas de syscall separadas
  • O io_uring oferece, no Linux moderno, um modelo baseado em conclusão e processamento em lote para submissão e coleta de conclusões
  • Ao criar um projeto novo do zero em um servidor Linux moderno, escolher io_uring é o caminho mais natural
  • Se for possível encerrar o suporte a sistemas antigos em um momento razoável, em ambientes com kernel v5.1+ não há muitos motivos para escolher epoll

1 comentários

 
GN⁺ 3 시간 전
Comentários no Hacker News
  • Dei uma olhada bem rápida no repositório do GitHub https://github.com/sibexico/TinyGate e parece que ainda não usa afinidade de CPU
    Fixar threads e sockets de escuta a CPUs e usar sockopt SO_INCOMING_CPU pode render um pouco mais de desempenho
    Se até os sockets de saída forem alinhados à CPU, provavelmente haveria um ganho bem grande, mas, pelo que sei, não existe uma boa API para isso. O Linux tem APIs de steering de tráfego/steering de fluxo para NICs compatíveis e, se você souber qual hash a NIC usa — provavelmente Toeplitz — dá para escolher bem as portas de origem para o backend e fazer o hash bater
    O objetivo é fazer o proxy processar pacotes sem comunicação entre CPUs

    • O v0 e o v1 do repositório são implementações completamente diferentes, quase reescritas do zero, e agora estou trabalhando na terceira implementação, que provavelmente será a última. As escolhas de arquitetura também mudaram completamente
    • Eu gostaria de ver os benchmarks desse patch
  • Seria bom dar uma olhada em https://github.com/concurrencykit/ck e https://github.com/microsoft/mimalloc. Devem combinar bem com um proxy reverso com cópia zero e memória alinhada
    Se quiser adicionar mitigação de DDoS e recursos L4 mais avançados, também vale conferir https://docs.ebpf.io/ebpf-library/libxdp/libxdp/

    • O plano era passar para o alocador depois de aplicar otimizações em outras camadas. Atualmente estou estudando alocadores com alunos, e um post anterior do blog era sobre um alocador customizado feito na linguagem Zig
  • Artigo realmente muito bom
    Por causa dele, caí na toca do coelho de uring, desenvolvimento de kernel e C. Já trabalho há bastante tempo com Rust e C++, mas há uma simplicidade — e até um certo senso artístico — em programas C pequenos e de escala moderada

  • Ainda não testei buffers compartilhados em um servidor web baseado em io_uring. Isso porque envio diretamente de uma região com mmap, em vez de ler de arquivo e depois escrever
    Na verdade, eu queria usar sendfile com io_uring, mas isso ainda não é suportado
    Um texto com Rust e kTLS como palavras da moda: https://blog.habets.se/2025/04/io-uring-ktls-and-rust-for-ze...
    Também apareceu no HN: https://news.ycombinator.com/item?id=44980865

    • Só para constar, splice(2) está implementado, então dá para usar o uring de uma forma parecida com sendfile. Não é tão conveniente quanto sendfile, mas deve funcionar de forma bem próxima
  • Se fosse feito com DPDK, ficaria muito mais complexo, mas surgiria a chance de superar o nginx com folga em desempenho
    Se fosse feito para rodar em FPGA, ficaria ainda mais complexo
    A lição é que, para ter desempenho, é preciso uma postura de atravessar abstrações como uma faca quente na manteiga, mas isso também torna tudo mais difícil. A abordagem de sockets e uma thread por conexão era boa quando a rede era muito lenta em comparação com a CPU, e ainda hoje muitas vezes continua sendo a abordagem mais simples

  • Eu também sempre tive essa curiosidade, então recentemente escrevi algumas implementações de servidor de arquivos HTTP para aprender as diferenças centrais
    https://theconsensus.dev/p/2026/05/18/serving-files-three-wa...

  • No contexto de proxy, também vale mencionar o busy polling de epoll_wait. Andei olhando isso recentemente ao avaliar opções de baixa latência, e pareceu possível chegar perto de busy polling em espaço de usuário com sockets simples, sem DPDK/VMA/io_uring, e a Fastly contribuiu para isso e usa a técnica
    É de nível muito baixo, então não posso dizer que entendi tudo; entendi mais o conceito geral, por isso estou deixando links. Isso só funciona por contexto NAPI do epoll, e não dá para controlar facilmente o ID do NAPI, mas se a máquina inteira for dedicada ao proxy, dá para fazer um truque simples de atribuir sockets a um poller dedicado por ID de NAPI
    O meu caso de uso não era proxy, e sim processar dados recebidos após fazer polling de N sockets em uma única máquina. Nesse caso, isso não pareceu viável, embora talvez funcione fazendo polling dos contextos NAPI em round-robin em uma única thread. Seria ótimo se algum dia fosse fácil dizer ao kernel algo como “confia em mim, eu vou acabar fazendo polling desse socket único, então nunca use o caminho de IRQ”
    Discussão anterior no HN sobre esse recurso do kernel: https://news.ycombinator.com/item?id=43749271
    Bons slides de apresentação de um colaborador da Fastly, com diagramas que ajudam a entender a visão geral: https://netdevconf.info/0x18/docs/netdev-0x18-paper10-talk-s...
    Artigos da LWN: https://lwn.net/Articles/1008399/, https://lwn.net/Articles/997491/, https://lwn.net/Articles/959462/
    Documentação do kernel: https://docs.kernel.org/networking/napi.html#irq-mitigation

  • Se você gosta de C++ e networking assíncrono, existe o Boost.Asio

    • Recentemente troquei o Asio por um loop de eventos epoll feito por mim, e o RPS melhorou cerca de 16%. Foi um resultado em um servidor SQL de porte razoável, então é bom ter cuidado ao usar bibliotecas muito empacotadas
    • Em um servidor de banco de dados, troquei o backend epoll do Asio por io_uring, e o uso de CPU subiu bastante. Isso provavelmente varia muito dependendo de como é usado e de como foi integrado ao código de eventos
    • O Boost é inconveniente demais. São bibliotecas dinâmicas gigantes, difíceis de compilar e usar. Mesmo eu já usando CMake, o processo de instalar o Boost e deixá-lo detectável foi muito irritante. Mas isso foi no Mac
  • Lá por 2050, acho que o Linux vai ter umas 20 formas de fazer polling de sockets

    • Sim, e isso também vale dentro do io_uring. Para ficar mais rápido, surgiu o modo one-shot do io_uring, e depois apareceu até o modo multishot
  • Sim, o io_uring é definitivamente mais rápido que o epoll. No meu caso, acho que o io_uring foi cerca de 20% mais rápido em requisições por segundo
    O problema é que ele precisa ser explicitamente habilitado no kernel e, por motivos de segurança, está desativado em quase todo lugar. Parece haver compartilhamento direto de memória entre kernel e espaço do usuário, o que é bem desconfortável. Também houve vários exploits recentes mirando o io_uring
    Por isso, até projetos de engenharia que buscam o máximo de desempenho possível, como o Go, não incorporam o io_uring profundamente como um padrão razoável. Se você quiser assumir o risco, pode usá-lo diretamente na sua linguagem favorita. É mais rápido, mas o preço é a possibilidade de exploits em potencial

    • O principal motivo para ele ser desativado agora já foi resolvido. O suporte a cBPF entrou no RC mais recente, então agora dá para restringir as operações permitidas em vez de desligar tudo
    • Depende do caso. Já aconteceu de a minha emulação estilo POSIX de io_uring, feita com poll em vez de epoll, ser mais rápida que o io_uring. Mas, para buffers zero-copy grandes, o io_uring é imbatível
      O io_uring também é útil mesmo quando não se trata de I/O assíncrona. Por exemplo, dá para implementar uma cadeia de operações como mkdir seguido da abertura desse diretório como se fosse uma única operação atômica
      Se você tentar maximizar os pacotes por segundo em networking, vai bater muito rapidamente nos limites do kernel[1], e aí acaba tendo que usar recursos como GSO/GRO ou contornar completamente a stack de rede
      1: https://github.com/axboe/liburing/discussions/1346
    • O RHEL 9 e 10 agora oferecem suporte completo a io_uring por padrão. Isso é algo bem recente, mas já cobre muitos ambientes corporativos Linux. O Gemini “disse” que Ubuntu e SuSE também oferecem suporte, mas não deu links para comprovar
      https://access.redhat.com/solutions/4723221
      O Go também deveria reconsiderar o suporte. Vale a pena revisar isso
    • Em um projeto como o Go, não daria para escolher fazer a detecção de recursos de io_uring só uma vez na inicialização do runtime? Exploits não são um problema só dos programas que decidiram usar io_uring, mas do sistema operacional inteiro, certo?
    • Todo tipo de networking em modo polling — RDMA, DPDK, io_uring — acaba tendo uma natureza em que o usuário precisa assumir a responsabilidade pelo isolamento de memória
      Mas, no caso do io_uring, o ring fica dentro do kernel, então o que o usuário pode fazer é limitado
      Espero que isso melhore daqui para frente por causa dos LLMs, mas é um problema difícil de resolver. É muito difícil até para o próprio kernel lidar com isso, e muita gente também não entende direito como fazer esse tuning