MÓDULO 04 · CONCEITO 03 DE 14

Coroutines, fibers e execução suspensa

Conway, Knuth, e a ideia teórica de 1963 que ressurgiu sessenta anos depois como base de async, generators, goroutines e virtual threads.

Tempo de leitura ~22 min Pré-requisito Conceitos 01 e 02 Próximo Async/await e o event loop

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:

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.

princípio orientador

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.

C# — IEnumerable com yield (coroutine stackless)
// 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.

Python — generator (coroutine stackless)
# 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.

Go — goroutine + channel (coroutine stackful, simétrica)
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.

armadilha de modelo

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

  1. Implemente coroutines de zero em Python. Use apenas yield e send (não importe asyncio). Construa um event loop primitivo de ~50 linhas que mantém uma fila de coroutines pendentes, chama send() em cada uma, suspende quando elas cedem, retoma quando algum "evento" (timer simulado) está pronto. Você vai entender por dentro como o asyncio funciona.
  2. 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.
  3. 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,vsz ou Task Manager. Os números convencem mais que qualquer texto.

Referências para aprofundar

  1. livro The Art of Computer Programming, Vol. 1 — Donald E. Knuth (1968, 3ª ed. 1997). Seção 1.4.2 trata coroutines como caso geral de subrotina. A discussão é a melhor introdução teórica em livro impresso.
  2. livro Programming in Lua — Roberto Ierusalimschy (4ª ed., 2016). Capítulos 24 e 26 apresentam coroutines em Lua com clareza didática rara. Lua tem o modelo mais limpo de coroutine assimétrica em uso prático.
  3. livro Concurrency in Go — Katherine Cox-Buday (2017). Cap. 1 contextualiza goroutines como coroutines. Útil para conectar a teoria deste conceito com o conceito 06.
  4. livro Kotlin Coroutines: Deep Dive — Marcin Moskała (2022). Kotlin coroutines são bem desenhadas. Este livro explica a transformação de continuation-passing por dentro, útil mesmo se você não programa Kotlin.
  5. paper Design of a Separable Transition-Diagram Compiler — Melvin E. Conway (CACM, 1963). dl.acm.org/doi/10.1145/366663.366704 — O paper que cunhou o termo coroutine. A motivação prática (compilador) ainda ressoa.
  6. paper Revisiting Coroutines — Ana Lúcia de Moura & Roberto Ierusalimschy (TOPLAS, 2009). dl.acm.org/doi/10.1145/1462166.1462167 — Tratamento formal moderno: simétrica vs assimétrica, stackful vs stackless, e provas de equivalência. Indispensável para entender vocabulário.
  7. artigo What Color is Your Function? — Bob Nystrom (2015). journal.stuffwithstuff.com — Ensaio canônico sobre o problema de "cor" em async/await: por que função async contamina o caller. Crítica útil de stackless coroutines.
  8. artigo How Async/Await Really Works in C# — Stephen Toub (2023). devblogs.microsoft.com/dotnet/how-async-await-really-works — Stephen Toub é arquiteto da BCL. Mostra a state machine gerada com detalhe. Leitura essencial para quem usa C#.
  9. artigo Notes on Structured Concurrency, or: Go statement considered harmful — Nathaniel J. Smith (2018). vorpus.org — Argumento sobre como coroutines não-estruturadas causam mesmos problemas de goto. Deu origem ao TaskGroup em Python.
  10. docs PEP 492 — Coroutines with async and await syntax. peps.python.org/pep-0492 — Documento de design do async/await em Python 3.5. Lê-se em meia hora e explica decisões arquiteturais.
  11. docs State of the Loom — Ron Pressler (2024). cr.openjdk.org/~rpressler/loom/loom/sol1_part1.html — Justificativa e estado atual de virtual threads em Java. Conecta diretamente com este conceito: virtual thread = stackful coroutine escalonada pelo runtime da JVM.
  12. vídeo Async/Await — The Complete Guide — Anders Hejlsberg, Channel 9. YouTube. Hejlsberg explica a motivação e o design de async/await em C# 5.0. Vale assistir antes de qualquer leitura sobre o assunto.