Skip to content

Event loop e I/O assíncrono

O documento sobre green threads apresentou a primeira resposta ao custo das threads do sistema operacional: tarefas leves escalonadas por um runtime. O event loop é a segunda resposta, e ela parte de uma constatação quase provocadora sobre o perfil da maioria dos servidores. Um servidor de rede típico passa a esmagadora maioria do tempo esperando: esperando um pacote chegar, esperando o banco responder, esperando o disco. Enquanto espera, não usa CPU. Se o trabalho é quase todo espera, por que gastar uma thread do SO inteira, com sua pilha de megabytes e seu custo de troca, para cada conexão que na maior parte do tempo só aguarda?

A resposta do event loop é radical: não gaste. Use uma única thread e, em vez de bloqueá-la esperando cada operação, registre interesse em muitas operações de uma vez e reaja a elas conforme completam. É concorrência sem paralelismo levada ao extremo, no sentido exato do panorama: uma thread, nenhuma execução simultânea, e ainda assim dezenas de milhares de conexões vivas ao mesmo tempo.

O nome histórico dessa dor é o problema C10k: como atender dez mil conexões concorrentes numa mesma máquina. Com o modelo de uma thread do SO por conexão, dez mil threads afundam a máquina em pilhas e trocas de contexto. A saída passou por duas peças de tecnologia que se encaixam.

A primeira é o I/O não-bloqueante. Numa leitura de socket bloqueante, a chamada só retorna quando há dados, e enquanto isso a thread dorme. Numa leitura não-bloqueante, a chamada retorna na hora: com os dados, se houver, ou com um aviso de “ainda não há nada” que não bloqueia ninguém. Isso permite a uma única thread tentar muitas operações sem ficar presa em nenhuma.

A segunda peça é o que evita o desperdício de ficar perguntando “já tem?” em laço para milhares de sockets. Os sistemas operacionais oferecem uma chamada de multiplexação, historicamente select e poll, hoje epoll no Linux, kqueue no BSD e macOS, IOCP no Windows, que faz o inverso: você entrega ao kernel uma lista de milhares de sockets e diz “me avise quando qualquer um deles tiver algo”. A chamada bloqueia uma vez só, por todos, e retorna a lista dos que ficaram prontos. Uma thread, uma espera, milhares de conexões monitoradas. Essa é a fundação sobre a qual o event loop inteiro se ergue.

Junte as duas peças e você tem o padrão reactor, que é a anatomia de todo event loop. No centro há um laço, o loop, que faz sempre a mesma coisa: pergunta ao kernel, via epoll ou equivalente, quais operações completaram; para cada uma, chama o pedaço de código que estava registrado para reagir àquele evento, o handler; e volta ao começo. O código do usuário nunca espera diretamente por I/O. Em vez disso, ele diz “quando esta leitura completar, execute este handler” e devolve o controle ao loop, que segue cuidando de todos os outros eventos.

A consequência dessa arquitetura é a regra de ouro do event loop, e é dela que decorre todo o resto: nunca bloqueie o loop. Como há uma única thread girando o laço, qualquer handler que demore, seja por uma chamada bloqueante, seja por um cálculo pesado que prende a CPU, paralisa o loop inteiro. Enquanto esse handler não retorna, nenhum outro evento é processado, e todas as milhares de conexões congelam junto. No modelo de threads, uma tarefa lenta atrasa só a si mesma; no event loop, ela atrasa todo mundo. É a fragilidade estrutural do modelo, o preço de ter uma thread só, e é por isso que trabalho CPU-bound é o inimigo natural do event loop e precisa ser empurrado para fora dele, para um pool de threads ou outro processo.

O event loop precisa de uma forma de o código dizer “quando isto terminar, faça aquilo”. A forma original foi o callback: você passa para a operação de I/O uma função a ser chamada quando ela completar. Funciona, e é o que Node.js usou por anos, mas escala mal na legibilidade. Quando uma operação depende do resultado da anterior, que depende de outra, os callbacks aninham-se em níveis crescentes de indentação, o famoso “callback hell”, e o tratamento de erro, que precisa ser repetido em cada nível, vira um emaranhado. O problema dos callbacks nunca foi de desempenho, era de que eles invertem o fluxo de leitura do código: a sequência lógica das operações fica escondida no aninhamento em vez de aparecer de cima para baixo.

A promessa (promise, ou future) foi o primeiro passo para desentortar isso. Em vez de passar um callback, a operação assíncrona devolve na hora um objeto que representa “um valor que ainda não chegou”, e você anexa a ele o que fazer quando chegar. Promises encadeiam em vez de aninhar, o que já achata a pirâmide, mas o salto de verdade veio com async/await, que é açúcar sintático sobre promises: você marca uma função como async e, dentro dela, escreve await na frente de cada operação assíncrona. O await suspende a função naquele ponto, devolve o controle ao event loop, e retoma exatamente dali quando o valor chega, com o resultado em mãos.

O efeito é notável: o código assíncrono volta a parecer sequential. Você lê de cima para baixo, com try/catch normal para erros, como se cada await bloqueasse, quando na verdade nenhum bloqueia, cada um solta a thread para o loop cuidar de outras tarefas. Isto conecta o event loop diretamente às green threads stackless: uma função async é, por baixo, uma corrotina stackless que o compilador transforma numa máquina de estados, e cada await é um ponto de suspensão. O event loop é o escalonador N:1 dessas corrotinas; o async/await é a sintaxe que as torna legíveis. São a mesma coisa vista de dois ângulos.

Há uma diferença de implementação entre linguagens que parece detalhe interno mas explica comportamentos observáveis, então vale nomeá-la. A pergunta é: quando uma operação assíncrona completa, o que faz a tarefa suspensa retomar?

No modelo push-based, o de JavaScript, a operação que completa empurra o resultado: quando o I/O termina, ela mesma agenda a continuação na fila do event loop. A future, ali, é um objeto ativo que dispara sua continuação. No modelo poll-based, o de Rust, a future é passiva: ela não faz nada sozinha. Um executor a interroga (poll) perguntando “já terminou?”, e a future responde “pronto, aqui está o valor” ou “ainda não, e vou te avisar quando valer a pena perguntar de novo”. A future de Rust é uma máquina de estados inerte que só avança quando alguém a interroga.

A escolha de Rust por poll-based não é gratuita: ela permite que as futures sejam alocadas sem custo de heap obrigatório, componham-se em árvores sem alocações intermediárias, e possam ser canceladas simplesmente deixando de interrogá-las. É coerente com a obsessão de Rust por “zero-cost abstractions”. O custo é uma complexidade maior para quem implementa runtimes e para quem escreve código async de baixo nível, onde conceitos como Pin e Waker afloram. Para quem só consome async/await, a diferença é quase invisível; para quem investiga por que uma future “não faz nada até ser awaited” em Rust, ela é a explicação inteira.

Node.js é o event loop que popularizou o modelo. Seu motor de I/O assíncrono é a biblioteca libuv, escrita em C, que abstrai as diferenças entre epoll, kqueue e IOCP sob uma interface única e roda o loop de eventos. JavaScript é single-threaded por natureza, e Node.js abraçou isso: seu modelo é N:1, uma thread rodando o event loop, o que casa perfeitamente com a ausência de threads na linguagem. Para o I/O que o SO não sabe fazer de forma não-bloqueante, como certas operações de arquivo e DNS, a libuv mantém um pool de threads interno que executa essas chamadas fora do loop e reporta o resultado de volta, um detalhe que mostra que mesmo o event loop mais “single-threaded” recorre a threads nos bastidores quando não tem escolha. Para paralelismo de CPU de verdade, Node.js oferece os worker threads, que são processos de trabalho separados, cada um com seu próprio loop.

Python trouxe o mesmo modelo para dentro da linguagem com o asyncio e as palavras async/await. O detalhe importante, que reconecta ao nível de sistema, é que o asyncio combina especialmente bem com o GIL de Python: como o event loop já é single-threaded por design, a serialização que o GIL impõe deixa de ser uma limitação, porque não havia paralelismo a perder de qualquer forma. Para cargas I/O-bound, que são a maioria em servidores web, o asyncio entrega alta concorrência sem esbarrar no GIL, e é por isso que ele se tornou a espinha dorsal dos frameworks web assíncronos de Python.

Rust, como o documento sobre green threads adiantou, oferece a sintaxe async/await e o tipo Future, mas deixa o runtime a cargo de bibliotecas. O tokio é o executor dominante, e ele não é um event loop N:1 puro: combina o event loop reactor sobre epoll com um escalonador M:N de trabalho roubado, unindo as duas respostas ao custo das threads numa só. Você escreve async fn e usa await, e o tokio decide sobre qual das suas threads do SO cada tarefa roda, dando a Rust tanto a concorrência do event loop quanto o paralelismo das green threads.

Zig faz uma aposta que merece atenção justamente por contrariar a tendência. A ambição declarada da linguagem foi ter funções “sem cor” (colorless): a mesma função poderia ser chamada de forma síncrona ou assíncrona conforme o contexto, sem a divisão entre função normal e função async que marca as outras linguagens. Se realizada, isso resolveria pela raiz o problema das funções coloridas descrito a seguir. O suporte a async de Zig passou por remoções e reformulações profundas ao longo das versões, e seu estado exato é um alvo móvel, mas a intenção é o que importa aqui: mostrar que a divisão entre código síncrono e assíncrono, que as outras linguagens tratam como inevitável, é uma escolha de design, e há quem tente evitá-la.

Fica para o fim o incômodo mais discutido do modelo async/await, porque ele é a chave para entender por que Go e Elixir, que não têm async/await, são às vezes descritos como tendo “resolvido” a concorrência de um jeito que Node.js e Rust não resolveram.

A ideia, de um ensaio conhecido de Bob Nystrom, é que async/await pinta as funções de duas cores. Há funções normais (síncronas) e funções async, e as regras de chamada entre elas são assimétricas de um jeito contagioso. Uma função async só pode ser awaited de dentro de outra função async. Logo, se uma função no fundo da sua pilha de chamadas se torna async, todas as funções que a chamam, direta ou indiretamente, precisam virar async também, e o async sobe contaminando a base de código inteira até o topo. As duas metades do código, a síncrona e a assíncrona, não se misturam livremente, e integrar uma biblioteca síncrona num contexto async, ou o contrário, gera atrito real.

A raiz técnica disso é o que o documento sobre green threads chamou de stackless. Como as funções async são stackless, elas só podem ser suspensas em pontos marcados (await), e essa marcação precisa aparecer na assinatura, daí a cor. As green threads stackful de Go não têm esse problema: como uma goroutine pode ser suspensa em qualquer ponto, sem marcação, não há duas cores de função, todo código é “da mesma cor” e a concorrência não contamina as assinaturas. Você escreve uma chamada de rede em Go exatamente como escreveria a versão bloqueante, e o runtime suspende a goroutine por baixo. É a mesma razão pela qual os atores de Elixir, que o próximo documento detalha, também escapam do problema.

Não há aqui um vencedor limpo, e é importante resistir à conclusão fácil de que “sem cor é melhor”. As funções coloridas são o preço que se paga pela eficiência e pelo controle das corrotinas stackless: você vê exatamente onde o código pode suspender, o que ajuda a raciocinar sobre pontos de cancelamento e sobre o que roda entre um await e o próximo, e paga o custo mínimo de memória por tarefa. As green threads stackful compram a ausência de cor ao preço de um runtime mais pesado e da suspensão que pode acontecer em pontos que você não vê. É o mesmo trade-off entre explícito e conveniente que atravessa todo este tema, aqui na sua forma mais concentrada: a cor da função é a concorrência tornada visível na assinatura, e se isso é um defeito ou uma qualidade depende, honestamente, de quanto você quer enxergar.

Conceitos: event loop, I/O não-bloqueante, padrão reactor, callback, callback hell, promise, future, async/await, corrotina stackless, poll-based, push-based, funções coloridas Sistema: C10k, epoll, kqueue, IOCP, select, poll, multiplexação de I/O Node.js: libuv, single-threaded, worker threads, pool de threads Linguagens: Node.js, JavaScript, Python, asyncio, Rust, tokio, Future, Pin, Waker, Zig, funções colorless Referências: Bob Nystrom, “What Color is Your Function?”