MÓDULO 01 · CONCEITO 03 DE 8

I/O e syscalls — bloqueante vs não-bloqueante

O que acontece embaixo de um read(), e por que isso define o teto de escala do seu servidor.

Tempo de leitura ~22 min Pré-requisito Memória & processos Próximo Rede aplicada

Quando seu programa lê um arquivo ou recebe dados de rede, ele não está "lendo" no sentido literal. O que está acontecendo é uma chamada ao kernel pedindo permissão e ajuda: o programa diz "preciso desses bytes, ative os mecanismos de hardware para trazê-los, me avise quando estiverem prontos". Essa é a system call, ou syscall — a fronteira entre código de usuário (seu programa) e código de kernel (sistema operacional). Toda vez que dados saem ou entram do seu processo — disco, rede, terminal, qualquer coisa — uma syscall foi feita. Entender o que acontece nessa fronteira é entender o teto de escala do seu sistema.

A relevância disso é que os modelos de I/O — bloqueante, não-bloqueante, multiplexado, assíncrono — são o que separa um servidor que aguenta cem conexões de um que aguenta cem mil. Não é "qual linguagem", não é "qual framework"; é qual modelo de I/O ela escolhe, e como o kernel coopera. Os mecanismos modernos (epoll em Linux, kqueue em BSD, IOCP em Windows, io_uring como nova fronteira) são as respostas a um problema que a indústria veio descobrindo desde os anos 90: como atender muitos clientes com poucas threads.

O que é uma syscall

Em arquiteturas modernas, código de usuário roda em "ring 3" (privilégio baixo) e código de kernel roda em "ring 0" (privilégio alto). Essa divisão existe por segurança: o kernel pode acessar hardware, gerenciar memória, escalonar processos; usuário não. A única forma do código de usuário pedir algo que requer privilégio é fazer uma syscall — uma instrução especial (syscall em x86_64, svc em ARM) que transfere controle para o kernel, num ponto pré-definido.

O kernel inspeciona o que foi pedido (via números de syscall: 0 para read, 1 para write, 41 para socket, etc), valida argumentos, executa, e retorna o resultado. Esse "ida e volta" tem custo: alguns centenas de nanossegundos em hardware moderno. Para operações pesadas (ler 1MB de disco) é irrelevante; para operações microscópicas (ler 4 bytes) é dominante. Apps performance-críticas evitam syscalls excessivas — daí buffering em I/O, batching de operações, readv/writev em vez de muitos read/write.

Você raramente vai chamar syscalls diretamente. Linguagens modernas as escondem atrás de bibliotecas (libc, runtime). Mas elas estão sempre lá — você pode observá-las com strace em qualquer programa, e o que aparece é uma sequência de read, write, open, close, mmap, epoll_wait... essa é a "linguagem" que seu programa fala com o sistema.

I/O bloqueante — o modelo tradicional

O modelo mais simples e mais antigo: você pede read(fd, buffer, size), e o kernel não retorna até ter algo para te dar (ou até erro). Se for um arquivo local, retorna em microssegundos. Se for um socket de rede esperando dados do cliente, pode levar segundos, minutos, ou nunca chegar. Durante esse tempo, sua thread está bloqueada — não executando nada, só esperando. O escalonador do SO sabe disso e simplesmente não a coloca para rodar.

Para servidores simples, esse modelo funciona via "thread por conexão": a cada cliente que conecta, você cria uma thread, ela lê e escreve bloqueantemente, e quando o cliente desconecta, a thread morre. Apache HTTP Server clássico operava assim. O problema é a escala: cada thread custa ~8MB de stack e ~10ms para criar. Cem clientes simultâneos? Tudo bem. Dez mil? 80GB de stack só, ignorando o overhead do escalonador trocando entre elas. Esse é o famoso problema C10K de Dan Kegel: como atender dez mil conexões simultâneas?

I/O não-bloqueante — primeiro passo

A primeira melhoria: você marca o file descriptor como não-bloqueante (com fcntl(fd, F_SETFL, O_NONBLOCK)), e a partir daí read() nunca bloqueia — se não houver dados, retorna imediatamente com erro EAGAIN. Você fica responsável por chamar de novo mais tarde.

Isso resolve uma parte do problema: sua thread não fica presa. Mas cria outro: como saber quando chamar de novo? A resposta ingênua é "tente periodicamente" — chamado busy polling. Funciona, mas desperdiça CPU absurdamente: uma thread checando mil conexões a 1ms de intervalo é um milhão de syscalls por segundo, sendo a maioria EAGAIN retornado de imediato.

A solução real veio dos mecanismos de I/O multiplexado.

I/O multiplexado — select, poll, epoll

A ideia: um único syscall que pergunta ao kernel "destes mil file descriptors, quais estão prontos para I/O agora?", e bloqueia até pelo menos um estar pronto. Aí sua thread acorda, processa só os prontos, e volta a perguntar.

Os três mecanismos históricos:

epoll é o que torna possíveis os servidores modernos. Nginx, Node.js, Go runtime, .NET sockets, asyncio em Python — todos usam epoll em Linux por baixo. As variantes em outros SOs: kqueue em BSD/macOS (similar, antecedente intelectual de epoll), IOCP em Windows (modelo diferente, mais próximo de I/O assíncrono real), io_uring como evolução em Linux moderno (kernel 5.1+).

I/O assíncrono — o futuro

O passo seguinte é mover ainda mais trabalho para o kernel. Em I/O multiplexado clássico, você é notificado quando o FD está pronto — você faz read. Em I/O assíncrono real (POSIX AIO, Windows IOCP, Linux io_uring), você submete a operação inteira ao kernel: "leia X bytes, escreva no buffer Y, me avise quando pronto" — e o kernel faz tudo, devolve apenas o resultado finalizado.

io_uring, introduzido em Linux 5.1 (2019), é a apostas atual da indústria. Funciona via dois ring buffers em memória compartilhada (submission e completion), permitindo dezenas de milhares de operações por segundo sem nenhuma syscall em alguns casos. Está sendo adotado em runtimes modernos (Tokio em Rust, parte do .NET Sockets, alguns drivers em Go via bibliotecas externas), e é a base de várias abordagens de "zero-syscall" para alta performance.

Como cada linguagem da formação se relaciona com isso

O ponto interessante é que as três linguagens usam mecanismos similares por baixo, mas expõem APIs muito diferentes. Reconhecer isso ajuda a escrever código que escala.

C# — async I/O sobre IOCP/epoll
// O runtime do .NET usa epoll em Linux, IOCP em Windows.
// Toda Stream.ReadAsync, Socket.ReceiveAsync, etc, vai por baixo.

using var listener = new TcpListener(IPAddress.Any, 8080);
listener.Start();

while (true) {
    var client = await listener.AcceptTcpClientAsync();
    // Cada conexão tratada sem thread dedicada — usa pool
    _ = HandleClientAsync(client);
}

async Task HandleClientAsync(TcpClient client) {
    using var stream = client.GetStream();
    var buffer = new byte[4096];
    while (true) {
        int bytesRead = await stream.ReadAsync(buffer);
        if (bytesRead == 0) break;
        await stream.WriteAsync(buffer.AsMemory(0, bytesRead));
    }
}

O runtime do .NET tem um event loop interno (SocketsHttpHandler, System.IO.Pipelines) que usa epoll/IOCP. Você escreve código aparentemente sequencial; multiplexação acontece embaixo.

Python — asyncio sobre selectors
# asyncio usa um Selector que internamente usa epoll/kqueue/IOCP.
# Tudo num único thread (cooperativo), exceto se você usar
# ProcessPoolExecutor explicitamente.

import asyncio

async def handle(reader, writer):
    while True:
        data = await reader.read(4096)
        if not data:
            break
        writer.write(data)
        await writer.drain()
    writer.close()

async def main():
    server = await asyncio.start_server(handle, '0.0.0.0', 8080)
    async with server:
        await server.serve_forever()

asyncio.run(main())

Olhar o strace de um servidor asyncio mostra epoll_wait em loop. Funciona excelente para I/O massivo; mas atenção: qualquer chamada bloqueante síncrona dentro de uma corotina trava o event loop inteiro.

Go — netpoller invisível
// Go esconde a complexidade quase totalmente.
// Cada goroutine "parece" usar I/O bloqueante, mas o runtime
// (netpoller) traduz em epoll por baixo.

import "net"

func main() {
    listener, _ := net.Listen("tcp", ":8080")
    for {
        conn, _ := listener.Accept()
        go handle(conn)   // uma goroutine por conexão
    }
}

func handle(conn net.Conn) {
    defer conn.Close()
    buf := make([]byte, 4096)
    for {
        n, err := conn.Read(buf)
        if err != nil {
            return
        }
        conn.Write(buf[:n])
    }
}

O modelo "goroutine por conexão" é ingenuamente parecido com "thread por conexão" — mas goroutines são leves (~2KB) e o netpoller multiplexa milhões delas sobre poucas threads OS via epoll. Modelo mais elegante.

Note os três pontos. C# usa async/await explícito sobre um event loop interno. Python usa async/await explícito sobre asyncio (semelhante). Go esconde a multiplexação completamente atrás de goroutines — você programa como se fossem threads, mas o custo é dezenas de vezes menor. Cada um tem ergonomia diferente; nenhum é estritamente "melhor", mas saber o modelo ajuda a evitar antipatterns. Em Python, por exemplo, chamar requests.get() (síncrono) dentro de uma corotina destrói toda a vantagem do asyncio — é o erro número um de quem está aprendendo.

Buffered vs unbuffered — onde os bytes esperam

Outro detalhe operacional importante: I/O em alto nível costuma ser buffered. Quando você chama print("hello") em Python, os bytes não vão imediatamente ao terminal — vão para um buffer em memória do processo, que é "flushado" quando enche, quando você chama flush() explicitamente, ou quando o programa termina. Isso reduz drasticamente o número de syscalls (uma por buffer cheio, não uma por print), e por isso é o default em quase tudo.

O preço aparece em três situações. Logs em containers: muitas vezes o log do app é buffered, e quando o container é morto abruptamente, perde-se as últimas linhas — cruciais para diagnóstico. A solução: line-buffered (PYTHONUNBUFFERED=1 em Python; setar os.Stderr sem buffer em Go). Pipes interativos: um app que escreve em pipe e nunca flusha pode "parecer travado" para o próximo programa na pipeline. Crashes: dados em buffer que não foram para disco somem se o processo morre.

armadilha em produção

Apps Python em containers Kubernetes frequentemente "perdem logs" no momento de OOMKill ou crash. A causa quase sempre é stdout/stderr buffered. Setar PYTHONUNBUFFERED=1 no env do container resolve. O mesmo se aplica a outros runtimes — verifique o default antes de assumir que funciona.

Zero-copy — quando bytes não precisam viajar

Operações como "ler de um socket e escrever em outro" são comuns em proxies, load balancers, file servers. A versão ingênua envolve dois buffers em espaço de usuário: kernel copia para buffer 1 (read), seu código copia para buffer 2 (manipula), kernel copia de buffer 2 para o destino (write). Três cópias, três contextos.

Mecanismos zero-copy (sendfile, splice em Linux) pulam o espaço de usuário inteiro: dados vão direto do file descriptor de origem para o de destino, dentro do kernel. Para servidores que fazem forwarding de muitos bytes (Nginx servindo arquivos estáticos), a diferença é dramática — pode dobrar throughput. Linguagens modernas expõem isso via bibliotecas (file.SendFile em Go, os.sendfile em Python, FileStream.CopyToAsync em .NET internamente).

strace e o que ele revela

A ferramenta para observar syscalls é strace. Rodando strace -p <pid> em um processo, você vê em tempo real cada syscall que ele faz. É invasivo (escraviza o processo, pode desacelerar 10-100x) e caro em produção; mas em desenvolvimento e diagnóstico é poderosíssimo.

Casos onde strace resolve o problema em segundos:

Em macOS, o equivalente é dtruss (que é um wrapper sobre DTrace e exige permissões); em FreeBSD, truss; em Windows, Process Monitor da Sysinternals. O conceito é o mesmo: observar a conversa entre seu programa e o kernel.

Como praticar

  1. Veja o strace de um curl. Rode strace -e network curl https://example.com. Observe socket, connect, sendto, recvfrom. Cada syscall conta uma parte da história.
  2. Compare buffered vs line-buffered. Em Python, escreva um loop que faz print(i) dormindo 1s. Observe num terminal: prints aparecem imediatamente. Redirecione para arquivo: python script.py > out.txt e veja em tempo real (tail -f out.txt). Note como ficam acumulados, só aparecem em batch. Adicione flush=True ou PYTHONUNBUFFERED=1; veja a diferença.
  3. Compare epoll vs threads em servidor. Implemente um echo server em duas versões: thread-per-connection com I/O bloqueante, e single-thread com asyncio/goroutines. Use wrk ou hey para gerar mil conexões simultâneas. Compare uso de memória e latência. A diferença vai ser educativa.

Referências para aprofundar

  1. livro The Linux Programming Interface — Michael Kerrisk. Capítulos 4 (file I/O), 13 (buffering), 63 (alternative I/O models — select, poll, epoll, signal-driven). Referência exaustiva.
  2. livro UNIX Network Programming, Vol. 1 — W. Richard Stevens. Capítulo 6 (I/O Multiplexing) é o tratado canônico. Stevens é o autor de referência sobre redes Unix.
  3. artigo The C10K problem — Dan Kegel. kegel.com/c10k.html — texto histórico que descreveu por que precisávamos sair do thread-per-connection. Releitura obrigatória.
  4. artigo Efficient IO with io_uring — Jens Axboe (kernel.dk). kernel.dk/io_uring.pdf — o paper de quem inventou o io_uring. Para entender o futuro do I/O em Linux.
  5. artigo The method to epoll's madness — Cindy Sridharan. copyconstruct.medium.com — explicação acessível de epoll com diagramas. Ótimo primeiro contato.
  6. artigo Async I/O on Linux: select, poll, and epoll — Julia Evans. jvns.ca — Julia Evans escreve com clareza ímpar sobre internals de Linux. Cartilhas curtas, valiosíssimas.
  7. docs Linux man pages — epoll(7). man7.org/linux/man-pages/man7/epoll.7.html — referência oficial. Veja a seção EXAMPLES.
  8. docs strace tutorial. strace.io — projeto oficial. Tutorial e referência de filtros (-e trace=network, -e openat, etc).
  9. docs Go net package — netpoller. github.com/golang/go/blob/master/src/runtime/netpoll.go — código real do netpoller. Comentários explicam o design.
  10. docs asyncio internals. docs.python.org/3/library/asyncio-eventloop.html — fonte primária para entender o event loop e seus selectors.
  11. vídeo How does Linux really handle WiFi? — Brendan Gregg. YouTube. Brendan Gregg é referência em performance Linux. Esta palestra fala de I/O via networking. Inspiradora.
  12. vídeo The State of io_uring — Jens Axboe (Kernel Recipes). YouTube. Atualização anual do criador. Para acompanhar a evolução de I/O assíncrono moderno.