Em 1963, o engenheiro Melvin Conway publicou na revista Communications of the ACM um artigo aparentemente modesto: Design of a Separable Transition-Diagram Compiler. O texto tratava de organizar passes de um compilador, mas em uma seção lateral introduziu, com nome inédito, uma forma generalizada de subrotina que Conway chamou de coroutine. A ideia: duas rotinas que se chamam mutuamente, cada uma cedendo controle à outra em pontos pré-definidos, retomando depois exatamente de onde parou. Para o problema de Conway — encadear analisador léxico, parser e gerador de código sem buffer intermediário — o conceito era natural. Para o resto da computação, ficou esquecido por décadas.
Donald Knuth dedicou várias páginas a coroutines no primeiro volume de The Art of Computer Programming (1968) e fez uma observação que viraria fundamento: subrotina é caso particular de coroutine. A subrotina entra, executa e sai — transferência unidirecional de controle. A coroutine pode entrar, executar parte, ceder controle, ser retomada, executar mais um tanto, ceder de novo, e assim por diante. Na visão de Knuth, tratar subrotina como primitiva e coroutine como exceção era uma escolha pragmática da época, não uma necessidade conceitual.
Por meio século, a escolha pragmática venceu. Linguagens mainstream — Algol, Fortran, C, C++, Pascal, Java — não suportaram coroutines como construção de primeira classe. Quando a indústria precisou de concorrência, a resposta foi threads do sistema operacional. Threads são caras (já vimos, no conceito anterior, o stack de 8 MB e o context switch no kernel) e o programador toma cuidado para não criar muitas. Para a maior parte das aplicações com poucas centenas de operações simultâneas, threads bastavam.
Aí veio a web. Servidores começaram a precisar atender dez mil,
cem mil, um milhão de conexões simultâneas — todas elas
majoritariamente bloqueadas em I/O. Threads não escalavam para
essa carga. Foi nesse contexto que coroutines voltaram, e voltaram
forte: asyncio em Python (2014), async/await
em C# (2012), JavaScript (2017), Rust (2019), goroutines em Go
(2009), virtual threads em Java (2023). Cada uma é, no fundo,
uma encarnação da ideia que Conway publicou em sessenta páginas
de papel há mais de seis décadas.
A ideia central — execução suspensa
Uma subrotina tem ciclo de vida simples: é chamada, recebe parâmetros, executa, retorna um valor (ou nada), e morre. O estado dela existe apenas durante a execução; quando retorna, todas as variáveis locais são descartadas. Da próxima vez que for chamada, começa do zero. Esse é o modelo de toda função em qualquer linguagem moderna.
Uma coroutine rompe essa simetria. Em vez de executar até o fim e retornar, ela pode suspender num ponto qualquer do meio, devolvendo controle a quem a chamou (ou a outra coroutine). Quando retomada, continua exatamente da instrução seguinte ao ponto de suspensão, com todas as variáveis locais intactas, com o stack como estava, com tudo. O tempo de vida da coroutine pode ser muito mais longo que o tempo em que ela executa de fato — ela passa a maior parte do tempo suspensa, esperando ser retomada.
Suspender e retomar exigem que o estado da coroutine seja preservado entre execuções. Esse estado mínimo inclui:
- Program counter: onde estava a execução quando suspendeu, para retomar do próximo passo.
- Variáveis locais: o estado da computação que já foi feita.
- Stack de chamadas (em coroutines stackful) ou um frame alocado na heap (em stackless).
- Argumentos de retomada: a coroutine pode ser retomada com um valor (resultado de uma operação assíncrona, por exemplo) que aparece como o "valor de retorno" da suspensão anterior.
O núcleo conceitual termina aqui. O resto deste conceito — e dos treze do módulo — explora variações dessa ideia: como o estado é salvo, quem decide quando suspender, como múltiplas coroutines são coordenadas, e que primitiva sintática a linguagem expõe ao programador.
Symmetric vs asymmetric coroutines
Roberto Ierusalimschy, criador de Lua, escreveu em 2009 um artigo acadêmico chamado Revisiting Coroutines que organizou de vez o vocabulário da área. Há duas formas básicas de transferir controle entre coroutines, e a escolha tem consequências arquiteturais.
Em coroutines simétricas, qualquer coroutine
pode ceder controle para qualquer outra coroutine. Não há
hierarquia entre chamador e chamada — todas estão no mesmo nível.
yield to(outraCoroutine) é a operação típica. É o
modelo de Modula-2 e de algumas implementações acadêmicas. Tem
poder expressivo grande, mas estrutura de programa fica difícil
de raciocinar — o controle pode pular para qualquer lugar.
Em coroutines assimétricas, há relação
hierárquica entre chamador e coroutine: quem chama (resume)
e quem cede (yield) ficam em papéis distintos. A
coroutine sempre cede de volta para quem a chamou; não pode
pular para uma terceira. Esse é o modelo de Python (yield),
Lua, C# (await), JavaScript (await) — virtualmente todas as
linguagens mainstream modernas. Ierusalimschy provou no artigo
que coroutines assimétricas têm o mesmo poder expressivo das
simétricas (com pequena penalização) e estruturam código melhor.
Goroutines em Go são coroutines simétricas escondidas atrás de
channels — você não cede explicitamente, o runtime cede por
você quando o canal bloqueia. Mas a estrutura subjacente é
simétrica: qualquer goroutine pode "estar pronta" e qualquer
uma pode ser escolhida pelo scheduler. Async/await é
assimétrica explícita: a função async cede de volta
para o event loop (que é o "chamador" virtual).
Stackful vs stackless — o eixo prático
A distinção que mais importa na prática é entre coroutines com stack próprio e coroutines sem. Cada escolha tem trade-offs concretos que decidem o que você consegue (e não consegue) fazer em uma linguagem.
Stackful coroutines
Cada coroutine tem um stack próprio, alocado quando ela é criada. Quando suspende, o stack é preservado intacto. Quando retomada, basta restaurar o stack pointer e o program counter. Suspensão pode acontecer em qualquer profundidade de chamada — você pode chamar uma função normal de dentro de uma coroutine, e essa função normal pode chamar outra função normal, e qualquer delas pode suspender a coroutine inteira.
Modelos stackful: fibers do Windows, Boost.Fiber, Ruby fibers, PHP 8.1+ fibers, goroutines em Go, virtual threads em Java Loom, Erlang processes, Kotlin coroutines em modo unconfined. O custo é memória — cada coroutine reserva pelo menos alguns kilobytes de stack. Goroutines começam com 2 KB e crescem; virtual threads em Java têm overhead similar. Bem menor que threads do SO (8 MB), ainda não trivial em milhões de coroutines.
Stackless coroutines
Não há stack próprio. O estado da coroutine vive em um
frame alocado em heap, que contém apenas as variáveis
locais e o ponto de suspensão. Suspensão só acontece em pontos
explícitos da coroutine (em await, yield),
nunca dentro de funções chamadas. Se você está dentro de uma
coroutine async em Python e chama uma função síncrona pesada,
essa função executa até o fim — não há como suspender de dentro
dela.
Modelos stackless: async/await em C#, Python,
JavaScript, Rust, Kotlin (modo padrão), C++20. Generators em
Python e iterators em C#. O custo é restrição sintática (a
famosa "cor de função": coroutine só pode chamar coroutine; ou
seja, a propriedade async contamina o caller). O ganho
é eficiência: o frame na heap costuma ter algumas centenas de
bytes, e suspender é só atualizar um ponteiro.
Para o programador, a diferença mais visível é o
blocking call. Em Go ou Java Loom, você pode chamar
http.Get(url) síncrono dentro de uma goroutine: o
runtime detecta o bloqueio e suspende a goroutine
automaticamente. Em Python ou C#, chamar requests.get(url)
(síncrono) dentro de função async bloqueia a thread
inteira — você precisa usar aiohttp ou
httpx que oferecem versão async. Stackful esconde
a suspensão; stackless força você a marcar todos os pontos.
Toda forma de "thread leve" — async/await, generator, goroutine, virtual thread, fiber — é uma coroutine com um conjunto particular de escolhas: quem decide a suspensão (programador explícito ou runtime implícito), onde o estado mora (heap ou stack próprio), qual é a primitiva sintática (yield, await, nenhuma). Olhar para qualquer um desses recursos como variação do tema "coroutine" desentranha a complexidade aparente.
Como o compilador transforma async em estado
Para coroutines stackless (que dominam linguagens mainstream
modernas), o compilador faz transformação não-trivial: cada
função async vira uma state machine. Cada
await torna-se um estado, cada variável local que
precisa sobreviver entre awaits torna-se um campo da estrutura
gerada. O código que você escreve, sintático e linear, é
reescrito em algo bem diferente.
Considere uma função C# simples:
async Task<int> SomarUrlsAsync(string a, string b) {
var ra = await http.GetStringAsync(a);
var rb = await http.GetStringAsync(b);
return ra.Length + rb.Length;
}
O compilador C# (Roslyn) gera, aproximadamente, uma estrutura como esta — simplificada:
struct SomarUrlsStateMachine {
int _state; // 0, 1, 2, ...
int _resultado;
string _ra, _rb;
TaskAwaiter<string> _awaiter;
HttpClient http;
string a, b;
void MoveNext() {
switch (_state) {
case 0:
_awaiter = http.GetStringAsync(a).GetAwaiter();
if (!_awaiter.IsCompleted) {
_state = 1;
_awaiter.OnCompleted(MoveNext);
return;
}
goto case 1;
case 1:
_ra = _awaiter.GetResult();
_awaiter = http.GetStringAsync(b).GetAwaiter();
if (!_awaiter.IsCompleted) {
_state = 2;
_awaiter.OnCompleted(MoveNext);
return;
}
goto case 2;
case 2:
_rb = _awaiter.GetResult();
_resultado = _ra.Length + _rb.Length;
// sinaliza Task como completa
return;
}
}
}
Cada await virou um case. Variáveis locais
ra e rb viraram campos da struct. O
state rastreia em qual ponto a execução parou. Quando
o resultado da operação assíncrona fica disponível, o callback
registrado em OnCompleted chama MoveNext
de novo, que pula direto para o case correspondente.
O Python faz transformação análoga: async def com
await vira um objeto coroutine com método
send(); cada await é um yield disfarçado
que devolve ao event loop até o callback retomar. O JavaScript
faz parecido. O Rust expõe explicitamente: você pode escrever a
state machine à mão implementando o trait Future,
ou usar a açúcar sintático async fn que gera a
mesma estrutura. Em todos os casos, o que parece código linear
é uma máquina de estados disfarçada.
Esse insight muda como você lê código async. Cada await
é um ponto onde o runtime pode salvar tudo, voltar ao event
loop, atender outras coroutines, e retomar quando for hora.
Entre awaits, o código é estritamente sequencial — não há
preempção, não há race condition possível dentro do mesmo bloco.
Esse é um dos motivos pelos quais async/await é mais fácil de
raciocinar do que threads tradicionais para a maioria dos casos:
os pontos de concorrência são explícitos.
Generators — coroutines de um único yield
Antes de async/await, linguagens mainstream tinham generators: função que retorna sequência sob demanda, cedendo controle a cada elemento. Python tem desde a versão 2.2 (2001), C# desde 2.0 (2005), JavaScript desde ES6 (2015). É a forma mais antiga de coroutine em uso massivo, anterior a async/await por mais de uma década, e historicamente o degrau por onde async/await chegou.
# Python — generator clássico
def fibonacci():
a, b = 0, 1
while True:
yield a # suspende, devolve a, espera ser retomado
a, b = b, a + b
g = fibonacci()
print(next(g)) # 0
print(next(g)) # 1
print(next(g)) # 1
print(next(g)) # 2
# A cada next(), a função retoma exatamente após o yield anterior.
O yield aqui é a primitiva primária de suspensão.
Variáveis locais a e b sobrevivem
entre chamadas de next() porque o estado da função
foi preservado — exatamente o comportamento de coroutine
assimétrica. Generators viraram base de async em Python: em
versões 3.4 e 3.5, asyncio era construído sobre
yield from, antes da palavra-chave async
ser introduzida formalmente.
Em C#, yield return tem propósito semelhante para
iterators — uma forma de gerar IEnumerable<T>
preguiçoso. O insight de Anders Hejlsberg, arquiteto-chefe de
C#, em conversas de design para C# 5.0 (2012), foi notar que
iterators e async funcionam com mesma máquina conceitual: cada
yield ou await é um ponto de
suspensão. A implementação de async/await em C# reusou
diretamente a infraestrutura de iterators.
Fibers — coroutines no nível do SO
Em paralelo com a evolução das coroutines de linguagem, sistemas
operacionais expuseram coroutines no nível baixo: fibers.
Windows tem fibers desde NT 3.51 (1995): você cria uma fiber
via CreateFiber, troca para ela com
SwitchToFiber. POSIX teve ucontext_t
(makecontext, swapcontext) — equivalente
portável, marcado como obsolete em POSIX.1-2008 mas ainda
presente na maioria das libcs.
Fibers são stackful e cooperativas: você decide quando trocar.
O sistema operacional não preempta uma fiber — ela só sai de
execução quando explicitamente cede. Para implementadores de
runtimes de linguagens (e jogos, e green-thread libraries),
fibers são a primitiva de mais baixo nível para construir
coroutines acima. Boost.Fiber em C++ usa ucontext_t
onde existe, escreve assembly para mudar de stack onde não
existe.
PHP introduziu fibers como construção da linguagem em 8.1 (2021),
depois de anos sem nada melhor que generator com
yield. ReactPHP e AmPHP, frameworks PHP populares para I/O
concorrente, agora usam fibers para esconder yield/await da
API pública — código fica parecido com síncrono mas executa
cooperativo.
O mesmo padrão nas três linguagens
Para concretizar a ideia abstrata de "execução suspensa", veja a forma mais simples de coroutine — um gerador de números — em cada linguagem. Note como cada uma marca o ponto de suspensão de forma diferente, mas o conceito é idêntico.
// Gerador de Fibonacci — preguiçoso, gera sob demanda.
IEnumerable<long> Fibonacci() {
long a = 0, b = 1;
while (true) {
yield return a; // suspende; retomará após este ponto
(a, b) = (b, a + b);
}
}
// Uso — cada MoveNext() retoma a função do ponto onde parou.
var primeiros = Fibonacci().Take(10).ToList();
// O compilador gera uma state machine subjacente a Fibonacci();
// variáveis a e b viram campos preservados entre yields.
C# transforma este método em uma classe que implementa
IEnumerator<long>. Cada yield
return é um caso da state machine. Variáveis locais
viram campos. async/await, introduzido em C# 5.0,
reusou exatamente essa máquina de transformação — só
trocou IEnumerator por Task.
# Generator clássico — base histórica do asyncio.
def fibonacci():
a, b = 0, 1
while True:
yield a # suspende; estado preservado
a, b = b, a + b
g = fibonacci()
primeiros = [next(g) for _ in range(10)]
# Variáveis a e b sobrevivem entre chamadas de next().
# Python 3.5+ permite escrever async/await — máquina parecida,
# apenas com semântica adicional de "cedo controle ao event loop".
A função fibonacci() retorna um objeto generator,
não um valor. Cada next() executa até o próximo
yield. Em Python 3.4–3.5, asyncio
inicial usava yield from exatamente assim para
implementar await; depois ganhou sintaxe própria, mas a
mecânica é a mesma.
package main
import "fmt"
// Cada goroutine é uma coroutine stackful escalonada pelo runtime.
// Aqui, fibonacci roda concorrente e cede controle ao escrever no canal.
func fibonacci(out chan<- int64) {
var a, b int64 = 0, 1
for {
out <- a // bloqueia; runtime suspende a goroutine
a, b = b, a+b
}
}
func main() {
ch := make(chan int64)
go fibonacci(ch)
for i := 0; i < 10; i++ {
fmt.Println(<-ch) // recebe; runtime retoma fibonacci
}
}
Em Go a suspensão não é marcada explicitamente — o runtime detecta operações bloqueantes (envio em channel cheio, recepção em vazio, syscall, sleep) e suspende a goroutine, transferindo a kernel thread para outra goroutine pronta. O programador escreve código que parece síncrono; a coroutine acontece debaixo dos panos.
Por que a ideia voltou nos anos 2010
Coroutines existiram nos anos 1960, foram esquecidas na carona do sucesso de threads kernel-level nos anos 1980-90, e ressurgiram nos anos 2010 por motivos econômicos e técnicos precisos. Vale entender o porquê — não como curiosidade histórica, mas porque os mesmos motivos continuam operando.
O primeiro motivo foi a explosão de I/O concorrente. Servidores web em 2005 atendiam talvez algumas centenas de conexões simultâneas; em 2015, dezenas a centenas de milhares. Cada conexão passa a maior parte do tempo esperando — read da rede, query no banco, chamada para outro serviço. Com threads do SO, cada conexão custava 8 MB de stack e overhead de scheduler; com 100.000 conexões, a conta não fechava.
O segundo foi a popularização do modelo single-thread event loop por Node.js (2009). Ryan Dahl mostrou que JavaScript com event loop não-bloqueante (libuv) podia atender dezenas de milhares de conexões em uma máquina modesta. O modelo era coroutines disfarçadas — cada callback é uma "continuação" de uma operação suspensa. Mas o código ficou ilegível (callback hell), e isso motivou o passo seguinte.
O terceiro foi C# 5.0 (2012). Anders Hejlsberg e Mads Torgersen
argumentaram que código async não precisa parecer com callback
— pode parecer síncrono, com pontos de suspensão marcados por
await. A construção pegou e virou padrão. Python
3.5 (2015), ECMAScript 2017, Rust 1.39 (2019), Kotlin coroutines
(2018), Swift 5.5 (2021) — todos adotaram async/await na esteira.
O quarto foi Go (2009). Rob Pike e Ken Thompson levaram a outra
direção: em vez de async/await stackless explícito, goroutines
stackful escalonadas pelo runtime. Código parece síncrono sem
await; o runtime esconde a suspensão. Go provou
que esse modelo escala — e Java Loom (JDK 21, 2023) trouxe o
mesmo modelo para Java após dez anos de desenvolvimento. Em
2026, JVMs modernas têm virtual threads como cidadão de
primeira classe.
Misturar código síncrono bloqueante dentro de função
async é a armadilha mais comum em sistemas que
adotaram async/await. Em Python, chamar requests.get()
(síncrono) dentro de função async bloqueia a
thread inteira do event loop; todas as outras corotinas
param. O modelo cooperativo só funciona se todos cooperarem.
Quando integrar com biblioteca síncrona legada, use
run_in_executor (Python) ou
Task.Run (C#) para mandá-la a um pool de threads,
ou — em runtime stackful como Go ou Loom — não tem esse
problema, porque o runtime detecta o bloqueio.
Onde a abstração escala — e onde quebra
Coroutines escalam absurdamente bem em I/O-bound: milhões de conexões TCP em uma máquina, centenas de milhares de requisições HTTP em paralelo, pipelines de processamento com muitos estágios. O custo por coroutine é baixo (centenas de bytes a poucos kilobytes), e suspender em I/O é trivial. Sistemas como WhatsApp (Erlang BEAM), Discord (Elixir), Cloudflare (Rust async), Mercado Livre (Go) operam nessa escala graças a coroutines de alguma variante.
Coroutines não escalam em CPU-bound: se uma coroutine não
cede, o event loop trava (em modelos cooperativos) ou domina
a kernel thread (em modelos preemptivos como Go). Para
paralelismo de CPU, você precisa de threads do SO ou
multiprocessing — mesmo dentro de programa que usa coroutines
como modelo principal. Sistemas reais combinam: corutines para
I/O, thread pools para CPU. C# faz isso com
Task.Run; Python com concurrent.futures
junto a asyncio; Go com goroutines distribuindo
CPU em GOMAXPROCS threads. A pureza é raramente
viável em produção.
Como praticar
-
Implemente coroutines de zero em Python. Use
apenas
yieldesend(não importeasyncio). Construa um event loop primitivo de ~50 linhas que mantém uma fila de coroutines pendentes, chamasend()em cada uma, suspende quando elas cedem, retoma quando algum "evento" (timer simulado) está pronto. Você vai entender por dentro como o asyncio funciona. -
Decompile uma função async em C# ou Python.
Em C#, use ILSpy ou dotPeek em uma função async simples e
olhe a state machine gerada. Em Python, examine o atributo
__code__de uma corotina e suas variáveis capturadas. Compare com o que você esperava — frequentemente é mais simples do que parece. -
Compare custo de memória entre threads e coroutines.
Crie um programa que cria 100.000 unidades dormindo 60s.
Versão A: threads do SO em qualquer linguagem (vai falhar
ou consumir muita memória). Versão B: goroutines em Go.
Versão C: tarefas asyncio em Python. Meça memória residente
(RSS) com
ps -o rss,vszou Task Manager. Os números convencem mais que qualquer texto.
Referências para aprofundar
- livro The Art of Computer Programming, Vol. 1 — Donald E. Knuth (1968, 3ª ed. 1997).
- livro Programming in Lua — Roberto Ierusalimschy (4ª ed., 2016).
- livro Concurrency in Go — Katherine Cox-Buday (2017).
- livro Kotlin Coroutines: Deep Dive — Marcin Moskała (2022).
- paper Design of a Separable Transition-Diagram Compiler — Melvin E. Conway (CACM, 1963).
- paper Revisiting Coroutines — Ana Lúcia de Moura & Roberto Ierusalimschy (TOPLAS, 2009).
- artigo What Color is Your Function? — Bob Nystrom (2015).
- artigo How Async/Await Really Works in C# — Stephen Toub (2023).
- artigo Notes on Structured Concurrency, or: Go statement considered harmful — Nathaniel J. Smith (2018).
- docs PEP 492 — Coroutines with async and await syntax.
- docs State of the Loom — Ron Pressler (2024).
- vídeo Async/Await — The Complete Guide — Anders Hejlsberg, Channel 9.