MÓDULO 04 · CONCEITO 04 DE 14

Async/await e o event loop

O modelo cooperativo single-thread que escala 100 mil conexões. Reactor pattern, libuv, state machines, e por que async é viral.

Tempo de leitura ~22 min Pré-requisito Conceitos 01, 02 e 03 Próximo Structured concurrency

Em novembro de 2009, na conferência JSConf EU em Berlim, Ryan Dahl apresentou um runtime experimental que ele chamou de Node.js. A palestra mostrava um servidor HTTP em quatro linhas de JavaScript atendendo milhares de conexões em uma única thread, em uma máquina modesta. Cinquenta minutos de demonstração que viraram pedra angular do desenvolvimento web da década seguinte. A premissa de Dahl, defendida no palco, era simples: para servidores que passam a maior parte do tempo esperando I/O, threads do sistema operacional são desperdício. Um único thread executando um event loop não-bloqueante atende mais conexões com menos memória e menos context switches.

A ideia não era nova — event loops já dominavam GUIs desde os anos 1980 (Win32 message loop, X11, Cocoa) e estavam no coração de servers como nginx (2004) e do Twisted em Python (2002). Mas Dahl empacotou o modelo em uma linguagem com sintaxe limpa e comunidade hambrienta, e popularizou em escala que nenhum predecessor havia conseguido. De Node, a ideia migrou para Python (asyncio em 2014, async/await em 2015), C# (async/await em 2012, antes de Node mas em outro nicho), JavaScript no browser (ES2017), Rust (1.39, 2019), Kotlin coroutines (2018), Swift 5.5 (2021). Em 2026, async/await é sintaxe de primeira classe em virtualmente toda linguagem mainstream.

Este conceito mostra como o event loop funciona por dentro, conectando o que você escreve (await foo()) com o que o runtime faz (epoll, callback, retomada da state machine). Compreender essa mecânica muda como você lê código async, depura problemas de performance e decide quando usar cada modelo.

Pré-requisito mental: o conceito 03 explicou que async é uma coroutine stackless — função que vira state machine em compilação. Aqui, o foco é o contexto dessa coroutine: o event loop que decide quem roda, quem espera, e como I/O assíncrono converte em retomada da execução suspensa.

O laço primordial — o que é um event loop

Em sua forma mais simples, um event loop é um while True que faz três coisas em sequência: pega o próximo evento pronto, despacha o callback associado, repete. O pseudocódigo cabe em meia página:

while True:
    eventos_prontos = aguardar_eventos()       # bloqueia se ninguém pronto
    for evento in eventos_prontos:
        callback = registry[evento.fd]
        callback(evento.data)
    if fila_de_tarefas_curtas_pendentes():
        processar_tarefas_curtas()              # microtasks

A genialidade desse modelo está em três decisões. Primeira: uma única thread atende todos os eventos, então não há sincronização necessária entre callbacks — eles rodam em sequência, sem concorrência interna. Segunda: aguardar_eventos() bloqueia eficientemente esperando o kernel notificar — o thread vira ocioso, não consome CPU. Terceira: cada callback é responsável por não tomar muito tempo, sob pena de atrasar todos os outros callbacks. Cooperação é a regra fundamental.

Em uma implementação real (libuv, asyncio, tokio), o loop tem mais fases — timers programados, callbacks pendentes, I/O polling, microtasks, close handlers. A ordem exata varia por runtime, mas a estrutura é a mesma. A documentação oficial do Node.js descreve seis fases distintas; asyncio em Python tem organização parecida, ainda que menos pública.

Reactor pattern — Schmidt, 1995

Em 1995, Douglas C. Schmidt formalizou o Reactor pattern em um capítulo de livro que viraria parte central de POSA2 (Pattern-Oriented Software Architecture, vol. 2, 2000). O Reactor é a estrutura arquitetural por baixo de qualquer event loop moderno. A ideia central: uma rotina central (o reactor) demultiplexa eventos vindos de múltiplas fontes (sockets, timers, sinais) e despacha para handlers registrados.

Há duas variações fundamentais que importam:

Reactor — "I/O está pronto, você faz a operação"

O kernel notifica que um file descriptor está pronto para read ou write; a aplicação executa a chamada de fato. Linux epoll, BSD kqueue, antiquíssimo select — todos são reactors. Quando seu código async em Python ou Node chama await sock.recv(), por baixo o runtime registra interesse no socket via epoll, suspende a coroutine, e quando epoll retornar o socket entre os prontos, retoma a coroutine para executar o read síncrono em si.

Proactor — "você pediu, eu fiz; aqui está o resultado"

A aplicação inicia uma operação assíncrona via syscall; o kernel a executa enquanto a aplicação faz outra coisa; quando termina, o kernel entrega o resultado. Windows IOCP (I/O Completion Ports) desde NT 4.0 (1996) é o exemplo canônico. Linux ganhou um proactor moderno em 2019 com io_uring, introduzido no kernel 5.1 por Jens Axboe — uma das mudanças mais celebradas em I/O Linux nos últimos vinte anos. O modelo proactor é teoricamente mais eficiente (menos syscalls, menos copia de memória) mas mais difícil de programar. tokio em Rust e libxev mais recente em Zig dão suporte a ambos os modelos.

Para a maior parte do código de aplicação que você escreve, a diferença entre reactor e proactor é invisível — abstrações acima encapsulam a primitiva. Mas quando você for investigar performance no kernel, profilar latência de tail, ou otimizar I/O extremo, a distinção volta a importar.

Primitivas de I/O multiplexing — uma escada de eficiência

Event loops dependem de primitivas do SO que dizem "destes 1.000 file descriptors, quais estão prontos agora?". A história dessas primitivas é uma escada — cada degrau motivado por gargalos do anterior.

select (1983)

Introduzido em 4.2BSD por Bill Joy. Aceita três conjuntos de fds (read, write, except) e bloqueia até qualquer um ficar pronto. O problema: FD_SETSIZE é 1024 por default. Servidor atendendo mais de mil conexões simultâneas não cabe no select. E mesmo quando cabe, o kernel percorre todos os fds em cada chamada — O(n) por syscall.

poll (1986)

Eliminou o limite de FD_SETSIZE — pode receber array arbitrário de fds. Mas continua O(n): a cada chamada o kernel percorre tudo. Em servidor com 100.000 fds, poll fica visível em profile.

epoll (Linux 2.6, 2002) e kqueue (FreeBSD 4.1, 2000)

Mudança fundamental: você registra fds de interesse uma vez via epoll_ctl, e epoll_wait retorna apenas os que estão prontos — O(1) amortizado. Suporte a edge-triggered (notifica apenas quando estado muda) e level-triggered (notifica enquanto estiver pronto). Toda biblioteca de event loop séria em Linux usa epoll por baixo desde meados dos 2000s.

IOCP (Windows NT) e io_uring (Linux 5.1, 2019)

Proactors verdadeiros. io_uring em particular introduziu uma interface ring buffer entre user-space e kernel — você submete operações em uma fila, kernel as executa, retorna resultados em outra fila, com mínimas syscalls. Em workloads de I/O extremo (databases, log shippers), io_uring é estado da arte em 2026. Runtimes async modernos como tokio em Rust suportam io_uring opcionalmente; asyncio em Python tem suporte experimental via uvloop.

Anatomia: libuv como exemplo concreto

libuv é a biblioteca C que serve de fundação ao Node.js, e virou referência de implementação de event loop. Cuvre Linux (epoll), BSD/macOS (kqueue), Windows (IOCP), com API uniforme. Vale conhecer sua arquitetura porque ela aparece, em variações, em virtualmente toda runtime async moderna.

Cada iteração do loop em libuv passa por sete fases bem definidas, nesta ordem:

  1. Timers: callbacks de setTimeout e setInterval cujo deadline já passou.
  2. Pending callbacks: callbacks de I/O do iteração anterior que estavam pendentes.
  3. Idle/prepare: uso interno.
  4. Poll: bloqueia em epoll_wait / equivalente, espera I/O. É aqui que o thread fica ocioso na maior parte do tempo.
  5. Check: callbacks de setImmediate.
  6. Close callbacks: limpeza de resources fechados.
  7. Microtasks: queue de Promises resolvidas e process.nextTick são processadas entre todas as fases acima — não na sequência fixa, mas a cada transição.

O ponto sutil em (7) — microtasks vs macrotasks — vale guardar. Microtasks (Promises continuations, await retomada) são processadas com prioridade alta entre fases; macrotasks (timers, I/O callbacks) entram nas fases acima. Isso significa que entre dois await da sua função, vários callbacks de outras Promises podem rodar — mas timers só rodam quando o loop chega na fase de timers. Bug clássico em Node: usar setTimeout(fn, 0) esperando que execute imediatamente — não vai, microtasks têm prioridade.

O modelo: 1 thread, N coroutines suspensas

O modelo conceitual de async/await é radicalmente simples e por isso surpreendentemente poderoso. Há uma única thread executando o event loop. Há N coroutines (asyncio.Task em Python, Task em C#, Promise em JS) em estados diversos: rodando (uma por vez, sempre uma — afinal só há uma thread), suspensas esperando I/O, ou na fila pronta para retomar.

Quando uma coroutine encontra await, três coisas podem acontecer. Se a operação já está pronta (cache hit, I/O completou enquanto outra coroutine rodava), o await passa direto sem suspender. Se está em andamento mas não terminou, a coroutine se suspende, registra um callback no event loop para ser notificada quando o resultado chegar, e o controle volta ao loop. Se é uma operação que dispara I/O nova, idem — registra interesse no fd, suspende.

O event loop processa o que estiver pronto: timers que dispararam, callbacks de I/O, retomadas de coroutines. Cada uma roda até o próximo await ou até o fim. Como há só uma thread, não há preempção entre dois awaits — o código entre pontos de suspensão executa atomicamente do ponto de vista de outras coroutines. Isso é uma das vantagens conceituais grandes: você não precisa de mutex para proteger seções pequenas, basta garantir que não há await dentro delas.

# Python — atomicidade entre awaits
async def transferir(de, para, valor):
    # Esta seção inteira executa sem interrupção de outras corotinas.
    # Entre as duas linhas abaixo, nenhuma outra task roda.
    de.saldo -= valor
    para.saldo += valor

async def transferir_com_log(de, para, valor):
    de.saldo -= valor
    await log_transferencia(de, para, valor)   # ← AQUI o controle pode trocar
    para.saldo += valor                         # ← outra task pode ter modificado contas
    # Este código tem race condition concorrente, ainda que single-thread!

O segundo exemplo mostra a sutileza: async é cooperativo, mas não imune a races concorrentes. Cada await é uma janela onde outra coroutine pode rodar e modificar estado compartilhado. A diferença em relação a threads tradicionais é que essas janelas são marcadas sintaticamente — você sempre sabe onde elas estão. Em threads, qualquer instrução pode ser preempted. Em async, só os awaits são pontos de troca.

princípio orientador

Em async, o que está entre dois await é atômico do ponto de vista de concorrência. Se você precisa atomicidade, evite await na seção crítica. Se precisa await, esteja consciente de que estado compartilhado pode mudar entre o antes e o depois — leia de novo se for usar.

"Cor de função" — o argumento de Bob Nystrom

Em fevereiro de 2015, Bob Nystrom (engenheiro do Google, designer de Dart, autor de Crafting Interpreters) publicou no blog pessoal um ensaio intitulado What Color is Your Function?. O argumento, formulado de forma provocativa, virou referência canônica nas críticas a async/await stackless: funções em uma linguagem com async têm "cor" — função "vermelha" (async) e função "azul" (síncrona). E essas cores não interoperam livremente.

Especificamente: função síncrona não pode chamar async direto (precisa await, que só pode ser usado em função async). Função async pode chamar síncrona, mas chamar síncrona pesada bloqueia o event loop. Resultado: async é viral. Se uma função no fundo da call stack vira async, todas as funções acima dela precisam virar async também — propaga até o topo. Bibliotecas precisam manter versões duplicadas (síncrona e async); APIs ficam com sufixo Async; código duplica.

O custo prático aparece em todo lugar. Em Python: requests (síncrono) e aiohttp (async). Em C#: HttpClient.GetString e HttpClient.GetStringAsync. Em Node: literalmente todas as APIs do file system têm versão sync (readFileSync) e async (readFile). Frameworks tentam atenuar com pontes (asyncio.run_in_executor, Task.Run), mas a divisão fundamental persiste.

Nystrom argumenta que linguagens com coroutines stackful (Go, Erlang, Java Loom) não sofrem desse problema. Em Go, qualquer função pode ser chamada de qualquer outra — não há cor. Quando uma função síncrona em Go faz http.Get(url), o runtime detecta o bloqueio e cede a goroutine. Não há await, não há marcação. O preço é que goroutines são stackful (mais memória) e o runtime precisa hooks no scheduler para detectar bloqueios. Trade-off real, com vencedores diferentes em situações diferentes.

Vale ler o ensaio inteiro — Nystrom é didático e provocativo, e o argumento se aplica diretamente à decisão arquitetural "vou adotar async/await ou ir para Go/Loom?" que muitas equipes enfrentam em 2026.

O mesmo programa nas três linguagens

Para concretizar como async/await materializa o event loop, veja um padrão comum: buscar várias URLs em paralelo, com timeout individual e cancelamento ao primeiro erro. As três linguagens expressam de formas distintas, mas o evento underlying é o mesmo.

C# — Task.WhenAll com timeout e cancelamento
using System.Net.Http;
using System.Threading;

async Task<Dictionary<string, int>> BuscarTodasAsync(
    IEnumerable<string> urls, TimeSpan timeoutPorUrl) {

    using var http = new HttpClient();
    using var cts = new CancellationTokenSource();

    var tarefas = urls.Select(async url => {
        using var indiv = new CancellationTokenSource(timeoutPorUrl);
        using var combinado = CancellationTokenSource
            .CreateLinkedTokenSource(cts.Token, indiv.Token);
        try {
            var resp = await http.GetStringAsync(url, combinado.Token);
            return (url, resp.Length);
        } catch (OperationCanceledException) {
            return (url, -1);   // sinaliza timeout
        }
    });

    var resultados = await Task.WhenAll(tarefas);
    return resultados.ToDictionary(r => r.url, r => r.Item2);
}

HttpClient usa SocketsHttpHandler por baixo, que registra interesse no socket via epoll/IOCP. CancellationTokenSource é a primitiva canônica de cancelamento em .NET (vamos aprofundar no conceito 13). Task.WhenAll agrega — a thread vai para outra coisa enquanto qualquer requisição não terminou.

Python — asyncio.gather + wait_for
import asyncio
import aiohttp

async def buscar_todas(urls: list[str], timeout: float) -> dict[str, int]:
    async with aiohttp.ClientSession() as session:
        async def buscar(url: str):
            try:
                async with asyncio.timeout(timeout):
                    async with session.get(url) as resp:
                        body = await resp.text()
                        return url, len(body)
            except asyncio.TimeoutError:
                return url, -1

        resultados = await asyncio.gather(
            *(buscar(u) for u in urls),
            return_exceptions=False
        )
        return dict(resultados)

# Em Python 3.13, asyncio.timeout() é o context manager moderno;
# substitui o antigo asyncio.wait_for em código novo.

aiohttp usa o event loop do asyncio (que por baixo usa epoll em Linux, kqueue em macOS, IOCP em Windows). asyncio.gather orquestra — uma única thread atende todas as conexões. Para acelerar significativamente, troque asyncio default por uvloop, que reimplementa em libuv.

Go — goroutines + select (sem async/await)
package main

import (
    "context"
    "io"
    "net/http"
    "sync"
    "time"
)

func buscarTodas(urls []string, timeout time.Duration) map[string]int {
    resultados := make(map[string]int)
    var mu sync.Mutex
    var wg sync.WaitGroup

    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            ctx, cancel := context.WithTimeout(context.Background(), timeout)
            defer cancel()
            req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
            resp, err := http.DefaultClient.Do(req)
            if err != nil {
                mu.Lock(); resultados[u] = -1; mu.Unlock()
                return
            }
            defer resp.Body.Close()
            body, _ := io.ReadAll(resp.Body)
            mu.Lock(); resultados[u] = len(body); mu.Unlock()
        }(url)
    }
    wg.Wait()
    return resultados
}

Em Go não há await. Cada goroutine parece síncrona, mas o runtime usa um event loop interno (M:N scheduler com epoll/kqueue/IOCP por baixo) que detecta bloqueios em syscalls e suspende a goroutine. Resultado: o mesmo modelo do asyncio acontece, sem a "cor" sintática. Mutex aqui protege o map; em Go idiomático, channels seriam mais comuns — vamos ver no conceito 06.

Pitfalls clássicos do event loop

Async/await é poderoso e simples na superfície, mas tem armadilhas que produzem bugs sutis e outages reais. Vale conhecê-las antes de encontrar.

Bloquear o event loop

Toda função async eventualmente cede de volta ao loop em algum await. Se você fizer trabalho CPU-bound entre dois awaits — calcular hash de arquivo grande, comprimir uma imagem, iterar um milhão de elementos — a única thread fica ocupada e todas as outras coroutines param. Latência tail vai pelos ares. Solução: mande trabalho CPU-bound para um pool de threads (asyncio.run_in_executor em Python, Task.Run em C#, worker_threads em Node).

Chamar bibliotecas síncronas legadas

Mesmo problema, com gatilho mais sutil: chama requests.get() em vez de aiohttp.get(); chama time.sleep(5) em vez de asyncio.sleep(5); chama um driver de banco síncrono em vez do equivalente async. Cada chamada bloqueia o loop por todo o tempo da operação. Linters como flake8-async em Python ajudam a encontrar; em C#, o analizador AsyncFixer ataca o mesmo. Mas a regra mental para escrever código novo é simples: dentro de função async, todas as I/O e sleeps devem ser async.

Tasks órfãs (fire-and-forget)

Iniciar uma coroutine sem aguardar e sem registrá-la em nenhum lugar — asyncio.create_task(coro) sem guardar a referência. A task pode ser garbage-collected antes de terminar, ou suas exceções somem silenciosamente. É a fonte de bugs "alguma coisa não está acontecendo e não tem erro no log". O conceito 05 (structured concurrency) trata exatamente desse problema com TaskGroup e errgroup.

await em loop sequencial quando deveria ser paralelo

for url in urls: await fetch(url) faz uma de cada vez. await asyncio.gather(*(fetch(u) for u in urls)) faz todas concorrentemente. Diferença de uma linha; diferença de ordem de magnitude no tempo total. É um dos erros mais comuns em código async escrito por quem está aprendendo.

Esquecer o await

resultado = busca_async() em vez de resultado = await busca_async() retorna a coroutine, não o resultado. O código segue executando como se tivesse o valor; bugs aparecem mais tarde, quando você tenta usar uma coroutine como string. Linters detectam, mas a forma mais segura é configurar IDE para destacar funções async não-awaited.

armadilha em produção

Servidor async em Python ou Node de repente fica "lento", e métricas mostram CPU em 100% num único core. Sintomático de operação CPU-bound bloqueando o loop. A correção raramente é "otimizar a operação"; é mover para um worker pool. Profilar com asyncio.run(coro, debug=True) em Python (loga coroutines que demoram >100ms entre awaits) ou --inspect no Node leva direto à fonte.

Quando async/await ganha — e quando perde

Async/await é a escolha certa quando: o trabalho é majoritariamente I/O (espera por rede, banco, disco); há muitas operações concorrentes (centenas a milhões); cada operação é leve em CPU mas demora em wall clock; código linear é importante para legibilidade. Web servers, scrapers, ETL pipelines de I/O, proxies, gateways — todos casos onde async brilha.

Async/await é a escolha errada quando: o trabalho é CPU-bound (cálculo numérico, encoding, criptografia pesada); há poucas operações simultâneas (digamos, dezenas) e cada uma é demorada em CPU; bibliotecas-chave do ecossistema são síncronas (e ponte via thread pool elimina os ganhos); o domínio exige paralelismo de cores reais (ML training, simulação física).

Para casos mistos — server que faz mostly I/O mas ocasionalmente precisa de compressão pesada, por exemplo — o padrão prático é async para o I/O e thread pool para o pedaço CPU-bound. Stephen Cleary, autoridade em async em .NET, tem um livro inteiro dedicado a esses padrões mistos. Vale referência.

Como praticar

  1. Escreva um event loop primitivo do zero. Em Python, sem usar asyncio: implemente uma função run(corotinas) que aceita uma lista de generators com yield simulando await sleep(t), mantém uma fila de timers, e itera até todas terminarem. ~80 linhas. Vai consolidar a mecânica de coroutine + loop.
  2. Profile uma app async em modo debug. Em Python, asyncio.run(coro, debug=True) loga coroutines lentas. Em Node, --inspect e o Chrome DevTools mostram timeline. Em C#, o profiler do Visual Studio tem modo "async". Pegue um servidor web pequeno seu (ou construa um simples), induza carga com wrk, e identifique a maior fonte de latência. Documente o que encontrou.
  3. Compare epoll vs io_uring em Linux. Em Python, rode um servidor com asyncio default vs uvloop, depois compare com um servidor escrito em Rust com tokio em modo io_uring. Use wrk ou oha para medir requests/s e P99 de latência. Para entender em que faixa de carga as primitivas começam a diferir.

Referências para aprofundar

  1. livro Concurrency in C# Cookbook — Stephen Cleary (2ª ed., 2019). Receituário denso de padrões async/await em .NET. Cleary é a autoridade do domínio; cada padrão vem com armadilha equivalente. Leitura obrigatória se você programa C# com async.
  2. livro Asynchronous Programming with Python 3 — Caleb Hattingh (2020). Cobre asyncio, aiohttp, e o ecossistema async em Python com profundidade. O cap. sobre o event loop por dentro é especialmente bom.
  3. livro Pattern-Oriented Software Architecture, Vol. 2 — Schmidt, Stal, Rohnert, Buschmann (2000). POSA2. Caps. sobre Reactor e Proactor são as fontes formais. Datado em estilo, ainda atual em conteúdo.
  4. artigo What Color is Your Function? — Bob Nystrom (2015). journal.stuffwithstuff.com — Argumento canônico contra async stackless. Quinze minutos de leitura, mudança de perspectiva permanente.
  5. artigo How Async/Await Really Works in C# — Stephen Toub (2023). devblogs.microsoft.com/dotnet/how-async-await-really-works — Toub é arquiteto-chefe da BCL. Mostra a transformação em state machine com nível de detalhe que nenhum livro alcança.
  6. artigo The Node.js Event Loop, Timers, and process.nextTick(). nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick — Documentação oficial sobre as fases do loop em Node. Concisa e precisa.
  7. artigo Efficient IO with io_uring — Jens Axboe (2019). kernel.dk/io_uring.pdf — Paper técnico do criador da interface. Arquitetura ring buffer e benchmarks contra epoll. Para quem quer ir fundo em I/O moderno.
  8. docs libuv — design overview. docs.libuv.org/en/v1.x/design.html — Arquitetura da libreria por baixo do Node, com diagramas das fases do loop. Boa referência sempre que você precisa entender o que Node está fazendo.
  9. docs Python — asyncio (3.13). docs.python.org/3/library/asyncio.html — Documentação oficial. A seção "Developing with asyncio" lista a maioria dos pitfalls deste conceito.
  10. docs Tokio — Tutorial. tokio.rs/tokio/tutorial — Runtime async dominante em Rust. O tutorial é ótima referência mesmo para quem programa outras linguagens, porque expõe primitivas sem mágica.
  11. vídeo Node.js — Original Talk — Ryan Dahl (JSConf EU, 2009). YouTube. Os 50 minutos que iniciaram a popularização do event loop em servers. Vale por contexto histórico e clareza pedagógica.
  12. vídeo What the heck is the event loop anyway? — Philip Roberts (JSConf EU, 2014). YouTube. Demonstração visual didática (loupe.latentflip.com), referência canônica para entender event loop em JavaScript. 25 minutos transformadores.