Io_uring e polling: dissecando a arquitetura zero-copy em storage NVMe

      André Linhares 9 min de leitura
      Io_uring e polling: dissecando a arquitetura zero-copy em storage NVMe

      Análise técnica profunda sobre como o io_uring e o polling mode eliminam o overhead de syscalls e interrupções em SSDs NVMe, reduzindo a latência drasticamente no Linux.

      Compartilhar:

      Estamos vivendo um momento paradoxal na engenharia de sistemas. O hardware de armazenamento evoluiu exponencialmente — passamos de HDDs mecânicos com 150 IOPS para SSDs NVMe Gen5 capazes de entregar milhões de IOPS e larguras de banda superiores a 14 GB/s. No entanto, o software, especificamente a camada de I/O do kernel Linux, tornou-se o principal gargalo.

      Quando você espeta um drive NVMe de alta performance em um servidor e não vê o throughput esperado, a culpa raramente é do dispositivo. O culpado é o custo computacional de conversar com ele. Cada operação de leitura ou escrita tradicional envolve transições de contexto, cópias de memória e interrupções que, na escala de microssegundos dos dispositivos modernos, representam uma eternidade de tempo desperdiçado.

      Resumo em 30 segundos

      • O Problema: Dispositivos NVMe modernos são tão rápidos que o overhead das syscalls (chamadas de sistema) e interrupções de hardware consome mais tempo de CPU do que a própria operação de I/O.
      • A Solução: O io_uring elimina a necessidade de syscalls repetitivas usando anéis de memória compartilhada (Ring Buffers) entre o espaço do usuário e o kernel.
      • O Pulo do Gato: O modo Polling permite que a CPU verifique ativamente a conclusão de tarefas, eliminando a latência imprevisível das interrupções de hardware, ideal para latência determinística.

      O custo oculto das Syscalls e Interrupções

      Para entender por que precisamos do io_uring, precisamos dissecar o que acontece em uma operação de I/O clássica (read/write). Quando sua aplicação solicita dados do disco, ela executa uma system call. Isso força a CPU a parar o que está fazendo no espaço do usuário (User Space), salvar o contexto dos registradores, trocar para o modo privilegiado (Kernel Space), verificar permissões, mapear endereços virtuais para físicos e, finalmente, submeter o comando ao hardware.

      E isso é apenas a ida. Quando o SSD termina a tarefa, ele dispara uma interrupção (IRQ). A CPU precisa pausar novamente, rodar o manipulador de interrupção e acordar o processo original.

      Comparativo de fluxo: O custo das trocas de contexto no modelo tradicional vs. a fluidez da memória compartilhada no io_uring. Figura: Comparativo de fluxo: O custo das trocas de contexto no modelo tradicional vs. a fluidez da memória compartilhada no io_uring.

      Com as correções de segurança para vulnerabilidades especulativas (Spectre/Meltdown e suas variantes), o custo de cada transição entre usuário e kernel aumentou significativamente. Em um cenário de milhões de IOPS, a CPU passa mais tempo trocando de roupa (context switch) do que trabalhando.

      O fracasso da Libaio em escalar

      Durante anos, a libaio (Linux Asynchronous I/O) foi a resposta padrão para alta performance, especialmente em bancos de dados como Oracle e MySQL. Mas ela sempre foi uma solução incompleta.

      A libaio tem limitações arquiteturais severas:

      1. Suporte restrito: Só funciona verdadeiramente de forma assíncrona se o arquivo for aberto com O_DIRECT (sem cache de página). Qualquer tentativa de usar I/O com buffer faz com que ela se comporte de maneira síncrona (bloqueante), derrotando seu propósito.

      2. Bloqueios invisíveis: Mesmo com O_DIRECT, operações de metadados (como estender o tamanho de um arquivo durante a escrita) podem bloquear a thread de submissão.

      3. Ineficiência de Syscalls: Cada lote de submissão e cada coleta de eventos ainda exigem syscalls (io_submit e io_getevents).

      A libaio não foi desenhada para a era do NVMe, onde a latência do dispositivo é medida em dezenas de microssegundos.

      Arquitetura de Anéis: Submission e Completion Queues

      O io_uring (introduzido no kernel 5.1 por Jens Axboe) resolve isso fundamentalmente alterando a interface de comunicação. Em vez de chamar o kernel a cada operação, criamos dois anéis circulares na memória (Ring Buffers) que são mapeados tanto pelo processo do usuário quanto pelo kernel.

      1. Submission Queue (SQ): Onde a aplicação coloca os pedidos de I/O.

      2. Completion Queue (CQ): Onde o kernel coloca os resultados.

      A mágica acontece na coordenação. A aplicação pode encher o SQ com milhares de pedidos de leitura/escrita e fazer uma única syscall (io_uring_enter) para notificar o kernel. Em cenários avançados (com kernel side polling), podemos eliminar até mesmo essa única syscall.

      Estrutura interna dos anéis de submissão (SQ) e completação (CQ) operando em memória compartilhada. Figura: Estrutura interna dos anéis de submissão (SQ) e completação (CQ) operando em memória compartilhada.

      💡 Dica Pro: O design de anel único compartilhado elimina a necessidade de locks complexos entre o produtor (app) e o consumidor (kernel), permitindo uma escalabilidade linear com o número de cores da CPU.

      Zero-Copy e Fixed Buffers: Reduzindo o Overhead de Memória

      Um dos custos mais caros no kernel é o gerenciamento de memória virtual. A cada operação de I/O, o kernel precisa converter o endereço do buffer da sua aplicação em páginas físicas para que o controlador NVMe possa acessá-las (DMA). Isso envolve caminhar pela tabela de páginas e, frequentemente, fixar (pinning) essas páginas na RAM para evitar que o swap as mova.

      O io_uring introduz o conceito de Fixed Buffers.

      Você pré-registra um pool de buffers de memória no início da execução. O kernel faz o mapeamento e o pinning dessas páginas uma única vez. A partir daí, todas as operações de I/O referenciam esses buffers pré-registrados. Isso remove completamente o overhead de get_user_pages() do caminho crítico de I/O.

      Polling Mode: Trocando CPU por Latência

      Aqui entramos no território da ultra-performance. Em sistemas tradicionais, dependemos de interrupções. O disco avisa quando acabou. Mas interrupções têm um custo de latência ("latência de cauda" ou tail latency). O tempo entre o disco terminar a operação e a CPU realmente processar a interrupção pode variar dependendo da carga do sistema.

      Para storage NVMe de baixa latência, o io_uring suporta IO Polling.

      Neste modo, o kernel não coloca a thread para dormir esperando uma interrupção. Em vez disso, ele mantém a CPU ocupada girando em um loop (busy loop), verificando repetidamente o hardware para ver se a operação foi concluída.

      Gráfico de latência: O modo Polling (linha plana) oferece consistência determinística em comparação aos picos imprevisíveis das interrupções. Figura: Gráfico de latência: O modo Polling (linha plana) oferece consistência determinística em comparação aos picos imprevisíveis das interrupções.

      ⚠️ Perigo: O modo Polling vai fazer o uso da sua CPU bater 100% naquele núcleo, mesmo que o disco esteja ocioso, pois a CPU está ativamente perguntando "já acabou?". Use isso apenas quando a latência for o KPI crítico e você tiver núcleos de CPU dedicados para storage.

      Tabela Comparativa: A Evolução das Interfaces de I/O

      Característica Read/Write (Síncrono) Libaio (Assíncrono Legado) Io_uring (Moderno)
      Mecanismo Bloqueante Assíncrono (limitado) Assíncrono Real (Ring Buffer)
      Syscalls 1 por operação 1 por lote (submit) + 1 (get) 0 ou 1 por lote
      Cópia de Dados Múltiplas cópias Zero-copy (parcial) Zero-copy (Fixed Buffers)
      Buffering Buffered I/O padrão Apenas O_DIRECT Buffered e O_DIRECT
      Overhead Altíssimo (Context Switch) Médio Mínimo
      Complexidade Baixa Alta Média

      Benchmarks e o Mundo Real

      Em testes sintéticos com fio (Flexible I/O Tester), a diferença é brutal. Utilizando um SSD NVMe Gen4, operações de leitura aleatória de 4KB via io_uring em modo polling podem atingir o limite de IOPS do dispositivo (ex: 1.5M IOPS) utilizando apenas um núcleo de CPU, enquanto a libaio saturaria múltiplos núcleos para entregar o mesmo resultado — ou falharia em atingir o pico devido ao overhead de locking.

      Para engenheiros de storage, isso significa que podemos extrair mais performance do mesmo hardware, ou atingir a mesma performance utilizando CPUs mais baratas e com menos núcleos.

      Benchmark de IOPS: A superioridade do io_uring em cenários de alta carga e pequenas transferências (4KB). Figura: Benchmark de IOPS: A superioridade do io_uring em cenários de alta carga e pequenas transferências (4KB).

      O Futuro é Assíncrono

      O io_uring não é apenas uma "nova feature". É uma reescrita fundamental de como o Linux lida com I/O. Bancos de dados modernos como PostgreSQL, RocksDB e ScyllaDB já estão migrando seus backends de armazenamento para utilizar essa interface.

      Se você gerencia infraestrutura de storage de alta performance, a era de ajustar schedulers de disco e irqbalance está dando lugar à era de ajustar anéis de submissão e estratégias de polling. A latência agora é uma escolha de design, não uma fatalidade do hardware.

      Referências & Leitura Complementar


      FAQ: Perguntas Frequentes

      Qual a principal diferença entre io_uring e libaio? Enquanto o libaio suporta apenas I/O assíncrono em modo O_DIRECT e sofre com limitações de bloqueio, o io_uring utiliza anéis de memória compartilhada (ring buffers) entre usuário e kernel, permitindo submissão e completação de I/O sem a necessidade de syscalls constantes, suportando também I/O com buffer.
      Quando devo ativar o polling mode no io_uring? O polling mode é ideal para dispositivos de armazenamento extremamente rápidos (como NVMe Gen4/Gen5) onde o custo da latência de interrupção é maior que o custo de manter a CPU ocupada verificando a conclusão da tarefa. É recomendado para cenários de ultra-baixa latência onde o uso de CPU não é a restrição primária.
      O que são buffers fixos (fixed buffers) no contexto de zero-copy? São buffers de memória pré-registrados no kernel. Ao registrar esses buffers, o kernel mapeia as páginas de memória apenas uma vez, evitando o custo repetitivo de mapeamento e desmapeamento (get_user_pages) a cada operação de I/O, reduzindo drasticamente o overhead.
      #io_uring #nvme polling #linux kernel storage #zero-copy #performance tuning #latência de disco #syscall overhead
      André Linhares
      Assinatura Técnica

      André Linhares

      Engenheiro de Performance (Kernel/IO)

      "Vivo no kernel space caçando latência com eBPF. Para mim, context switches excessivos são inimigos pessoais e cada ciclo de CPU desperdiçado é uma ofensa técnica."