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:
-
Timers: callbacks de
setTimeoutesetIntervalcujo deadline já passou. - Pending callbacks: callbacks de I/O do iteração anterior que estavam pendentes.
- Idle/prepare: uso interno.
-
Poll: bloqueia em
epoll_wait/ equivalente, espera I/O. É aqui que o thread fica ocioso na maior parte do tempo. -
Check: callbacks de
setImmediate. - Close callbacks: limpeza de resources fechados.
-
Microtasks: queue de Promises resolvidas e
process.nextTicksã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.
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.
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.
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.
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.
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
-
Escreva um event loop primitivo do zero. Em
Python, sem usar
asyncio: implemente uma funçãorun(corotinas)que aceita uma lista de generators comyieldsimulandoawait sleep(t), mantém uma fila de timers, e itera até todas terminarem. ~80 linhas. Vai consolidar a mecânica de coroutine + loop. -
Profile uma app async em modo debug. Em Python,
asyncio.run(coro, debug=True)loga coroutines lentas. Em Node,--inspecte 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 comwrk, e identifique a maior fonte de latência. Documente o que encontrou. -
Compare epoll vs io_uring em Linux. Em Python,
rode um servidor com
asynciodefault vsuvloop, depois compare com um servidor escrito em Rust com tokio em modo io_uring. Usewrkouohapara 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
- livro Concurrency in C# Cookbook — Stephen Cleary (2ª ed., 2019).
- livro Asynchronous Programming with Python 3 — Caleb Hattingh (2020).
- livro Pattern-Oriented Software Architecture, Vol. 2 — Schmidt, Stal, Rohnert, Buschmann (2000).
- artigo What Color is Your Function? — Bob Nystrom (2015).
- artigo How Async/Await Really Works in C# — Stephen Toub (2023).
- artigo The Node.js Event Loop, Timers, and process.nextTick().
- artigo Efficient IO with io_uring — Jens Axboe (2019).
- docs libuv — design overview.
- docs Python — asyncio (3.13).
- docs Tokio — Tutorial.
- vídeo Node.js — Original Talk — Ryan Dahl (JSConf EU, 2009).
- vídeo What the heck is the event loop anyway? — Philip Roberts (JSConf EU, 2014).