Comparação entre epoll e io_uring no Linux
(sibexi.co)- 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_waitainda é preciso chamarread()/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_SQPOLLreduz 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()ouwrite()
- 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_waiteread()/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
stdine, quando chega um evento, chamaread()separadamente - Cria uma instância epoll com
epoll_create1 - Registra
STDIN_FILENOcomepoll_ctl - Bloqueia com
epoll_waitaté 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_waiteread
- Registra o file descriptor de
-
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
stdincomio_uring_prep_read - Submete com
io_uring_submite espera a conclusão comio_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()retornaNULLquando a fila de submissão está cheia
- Usa
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_ZCno kernel 6.0+ fornece envio sem copiar o buffer para o kernel
IORING_SETUP_SQPOLLpode 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
resda entrada da fila de conclusão- O tratamento de erro deve ser feito por meio de
cqe->res
- O tratamento de erro deve ser feito por meio de
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
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_CPUpode render um pouco mais de desempenhoSe 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
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/
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 moderadaAinda não testei buffers compartilhados em um servidor web baseado em
io_uring. Isso porque envio diretamente de uma região commmap, em vez de ler de arquivo e depois escreverNa verdade, eu queria usar
sendfilecomio_uring, mas isso ainda não é suportadoUm 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
splice(2)está implementado, então dá para usar ouringde uma forma parecida com sendfile. Não é tão conveniente quantosendfile, mas deve funcionar de forma bem próximaSe 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 NAPIO 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
epollfeito 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 empacotadasepolldo Asio porio_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 eventosLá por 2050, acho que o Linux vai ter umas 20 formas de fazer polling de sockets
io_uring. Para ficar mais rápido, surgiu o modo one-shot doio_uring, e depois apareceu até o modo multishotSim, o
io_uringé definitivamente mais rápido que oepoll. No meu caso, acho que oio_uringfoi cerca de 20% mais rápido em requisições por segundoO 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_uringPor isso, até projetos de engenharia que buscam o máximo de desempenho possível, como o Go, não incorporam o
io_uringprofundamente 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 potencialio_uring, feita compollem vez deepoll, ser mais rápida que oio_uring. Mas, para buffers zero-copy grandes, oio_uringé imbatívelO
io_uringtambém é útil mesmo quando não se trata de I/O assíncrona. Por exemplo, dá para implementar uma cadeia de operações comomkdirseguido da abertura desse diretório como se fosse uma única operação atômicaSe 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
io_uringpor 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 comprovarhttps://access.redhat.com/solutions/4723221
O Go também deveria reconsiderar o suporte. Vale a pena revisar isso
io_uringsó uma vez na inicialização do runtime? Exploits não são um problema só dos programas que decidiram usario_uring, mas do sistema operacional inteiro, certo?io_uring— acaba tendo uma natureza em que o usuário precisa assumir a responsabilidade pelo isolamento de memóriaMas, no caso do
io_uring, o ring fica dentro do kernel, então o que o usuário pode fazer é limitadoEspero 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