Trilha de Kotlin · parte 23 de 59
suspend e a máquina de estados
Aula da fase 4 da trilha de Kotlin: O que o compilador gera a partir de uma função `suspend`, e por que ela não cria thread por si só?
intermediárioPublicada em · 19 min de leitura
Imagine um garçom que anota o pedido, entrega à cozinha e volta para atender outra mesa enquanto o prato fica pronto. Ele não passa vinte minutos parado na porta da cozinha esperando o bolo assar. Uma coroutine funciona de modo parecido: é um trabalho que pode pausar no meio, liberar a thread que o executava e retomar depois, do ponto onde parou. A palavra-chave suspend marca as funções que podem pausar desse jeito, e o compilador do Kotlin transforma cada uma delas em uma máquina de estados. Esta aula explica por que essa transformação existe, como ela aparece no bytecode e o que ela não faz. A parte que mais confunde quem chega do Java é a ideia de que uma função suspend não cria thread por conta própria. Vamos testar isso.
Todo código desta aula foi compilado com o kotlinc 2.1.21 e executado com kotlinx-coroutines-core-jvm 1.10.2 (o detalhe do ambiente está na seção de fontes).
o mapa da aula
Começamos pelo problema que motivou as coroutines: uma thread que espera I/O fica presa, e o número de threads limita quantas esperas acontecem ao mesmo tempo. Depois vemos o que suspend significa, com um exemplo que roda 10 000 coroutines sobre uma única thread. Na seção 3 abrimos o que o compilador gera, com o javap mostrando os campos da máquina de estados. A seção 4 compara com goroutines e threads, e a 5 mostra as bordas do modelo. A seção 6 reúne os erros que um revisor procura nesse assunto, e a 7 compara a mesma ideia com Go, com código rodado dos dois lados.
1. o problema que as coroutines resolvem
Um servidor Java tradicional costuma atribuir uma thread a cada requisição. Enquanto a requisição espera uma resposta do banco ou de outro serviço, a thread fica parada, sem fazer trabalho útil, mas ocupando memória e um lugar no pool. Pense numa conta hipotética, feita só para ilustrar: se o pool tem duzentas threads e cada requisição espera uma chamada externa, o servidor atende no máximo duzentas esperas ao mesmo tempo, mesmo que a CPU esteja quase ociosa.
A documentação oficial descreve o problema com precisão: threads são entidades pesadas do sistema operacional, e criar muitas delas causa problemas de desempenho. A coroutine é apresentada como uma alternativa mais leve, que pode suspender sem ocupar recursos do sistema enquanto espera. A página de visão geral diz, em inglês, que coroutines “can suspend without blocking system resources”, ou seja, suspender não bloqueia os recursos do sistema.
O ganho não vem de execução mais rápida de cada passo. Vem de não prender uma thread durante a espera. Quando a espera termina, o trabalho volta a rodar numa thread disponível, possivelmente outra.
2. o que suspend significa
Uma função marcada com suspend pode chamar outras funções suspend, incluindo delay, que pausa a coroutine sem bloquear a thread. A palavra-chave não faz a função rodar em outra thread, e não a torna paralela. Ela avisa duas coisas: a função pode pausar, e só pode ser chamada de dentro de uma coroutine ou de outra função suspend.
Veja um exemplo que roda 10 000 coroutines, cada uma esperando 100 ms:
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
suspend fun buscaSimulada(id: Int): String {
delay(100)
return "dado-$id"
}
fun main() = runBlocking {
val threads = ConcurrentHashMap.newKeySet<String>()
val jobs = (1..10_000).map { id ->
launch {
buscaSimulada(id)
threads.add(Thread.currentThread().name)
}
}
jobs.joinAll()
println("coroutines concluídas: ${jobs.size}")
println("threads usadas depois do delay: ${threads.size} ${threads}")
}
A saída real foi:
coroutines concluídas: 10000
threads usadas depois do delay: 1 [main]
Dez mil trabalhos passaram por um atraso de 100 ms, e todos voltaram a rodar na thread main, que é a thread do runBlocking. Durante os 100 ms de cada delay, a thread estava livre para executar outras coroutines. Se cada um desses trabalhos fosse uma thread bloqueada com Thread.sleep, o mesmo cenário exigiria dez mil threads simultâneas, ou um pool muito maior do que o normal. A figura compara as duas situações.
A figura tem dois grupos. No de cima, a thread 1 fica parada durante a espera, e a tarefa B precisa esperar por ela, marcada em rosa. No de baixo, a coroutine A suspende, a thread 1 fica livre, e a seta com pacotes mostra a thread entregando a vaga para a coroutine B, que roda nela. A cor verde marca a thread que continua útil. A diferença que a figura tenta mostrar está entre as duas caixas de thread: uma fica presa, a outra segue disponível.
3. o que o compilador gera
Uma suspend fun tem um custo de implementação, e o compilador o paga por nós. A explicação do próprio projeto de design das coroutines, no documento KEEP do Kotlin, diz que funções suspensas são implementadas com continuation-passing style (CPS). Cada função suspend recebe um parâmetro extra do tipo Continuation, que representa o resto da execução depois do ponto de pausa. Quando a função precisa pausar, ela devolve um valor especial, o marcador COROUTINE_SUSPENDED, e não o resultado final.
Para que a continuação funcione, o compilador divide o corpo da função em estados. Cada ponto de suspensão vira um estado, e um campo label guarda em qual estado a execução deve retomar. Os valores locais que precisam sobreviver à pausa são guardados em campos da continuação. O próprio KEEP resume esse mecanismo dizendo que a função é compilada para uma máquina de estados, em que os estados correspondem aos pontos de suspensão, e que retomar a execução é chamar resumeWith de novo.
Para ver isso no bytecode, criamos uma função com dois delay e compilamos:
import kotlinx.coroutines.*
suspend fun passo(entrada: Int): Int {
val a = entrada + 1
delay(10)
val b = a * 2
delay(10)
return b
}
fun main() = runBlocking {
println(passo(1))
}
A saída de execução é 4. Inspecionando a classe com javap -p, a assinatura de passo aparece assim:
public static final java.lang.Object passo(int, kotlin.coroutines.Continuation<? super java.lang.Integer>);
O parâmetro Continuation está lá, e o retorno é Object, porque o valor pode ser o resultado ou o marcador de suspensão. Já a classe gerada para a continuação aparece assim:
final class Aula41bKt$passo$1 extends kotlin.coroutines.jvm.internal.ContinuationImpl {
int I$0;
java.lang.Object result;
int label;
...
public final java.lang.Object invokeSuspend(java.lang.Object);
}
O campo label é o estado atual da máquina. O campo result guarda o valor devolvido pelo delay quando a coroutine retoma. O campo I$0 guarda um inteiro que precisa sobreviver à primeira pausa. Pelo código, esse inteiro é o a, o único valor calculado antes do primeiro delay e usado depois dele. O nome I$0 sugere isso pelo tipo e pela posição, mas o javap não mostra o nome do local, então a correspondência com a é uma inferência, e não uma leitura direta do bytecode.
A figura abaixo organiza esses estados.
O fluxo começa no código escrito, em laranja, que o compilador transforma em três estados. O estado 0 roda até o primeiro delay e salva o label. O estado 1 é retomado com o label já atualizado, restaura o valor de a e roda até o segundo delay. O estado 2 calcula b e devolve o resultado. As linhas pontilhadas mostram o que é guardado na continuação entre uma pausa e outra. Quem lê a figura deve pensar que a máquina é o jeito de o compilador transformar um código sequencial em algo que pode parar e continuar.
Pergunta de entrevistaUma função suspend cria uma thread?
Não. suspend é uma marca que diz ao compilador que a função pode pausar. Ele transforma a função numa máquina de estados, com uma continuation que guarda onde a execução parou. Quando a função espera, a thread é liberada para outras coroutines. Quando a execução retoma, pode ser em outra thread, porque quem decide é o dispatcher. Coroutine é a tarefa, e thread é o recurso que a executa. Por isso milhares de coroutines podem existir sobre poucas threads.
4. coroutine, goroutine e thread
Quem conhece Go costuma comparar coroutines com goroutines, e a comparação ajuda até certo ponto. Nos dois casos existe um agendador que faz muitas tarefas leves compartilharem poucas threads do sistema operacional. Mas a diferença de implementação importa. Em Go, a goroutine é um recurso do runtime da linguagem, criado com go f(). Em Kotlin, a coroutine é construída pelo compilador, que gera a máquina de estados da seção 3, e executada pela biblioteca kotlinx.coroutines, que decide em qual thread cada pedaço roda.
A consequência prática aparece no primeiro contato. Em Go, a chamada go f() basta para iniciar algo concorrente. Em Kotlin, uma função suspend sozinha não roda em lugar nenhum: ela só executa quando alguém a chama dentro de um construtor de coroutine, como launch, async ou runBlocking, ou dentro de outra coroutine. Essa exigência existe porque é o construtor que cria o contexto, o escopo e o dispatcher onde a coroutine vai rodar. As próximas aulas da fase, sobre escopos e dispatchers, tratam disso com detalhe.
Também vale comparar com threads do Java. Uma thread é uma unidade do sistema operacional, e o Java a usa diretamente. Uma coroutine é uma unidade de biblioteca, criada e controlada dentro do processo. Por isso, uma coroutine suspensa é apenas um objeto de continuação na memória, enquanto uma thread parada ocupa uma pilha. A seção 2 mostrou esse efeito com dez mil coroutines.
A aula Virtual threads e testes com runTest trata das virtual threads do Java 21, que resolvem parte do mesmo problema por outro caminho.
5. bordas e limites
O primeiro limite é que suspend não significa assíncrono. Uma função suspend que chama Thread.sleep continua bloqueando a thread em que está rodando, porque o sleep não é uma suspensão do Kotlin. O compilador não verifica isso, e o nome da palavra-chave pode enganar quem lê. A regra é simples: só delay e outras funções suspend da biblioteca liberam a thread. Quando a função chama código bloqueante, a thread fica presa, mesmo dentro de uma coroutine. A aula Dispatchers e bloqueio mostra o efeito disso num pool de threads.
O segundo limite é que uma função suspend não pode ser chamada de uma função comum. O código de um main ou de um método de controller tradicional precisa de um ponto de entrada, como runBlocking, para chegar até ela. O runBlocking bloqueia a thread atual até a coroutine terminar, e a própria documentação diz que ele foi pensado para main, testes e pontes entre código bloqueante e código suspend. Chamá-lo de dentro de um handler HTTP coloca a thread do servidor para esperar, o que anula o ganho descrito na seção 1. A documentação também diz que chamar runBlocking de uma função suspend é redundante e pode causar fome de threads. Dentro de uma função suspend, chame a função suspend diretamente.
O terceiro limite é que a continuação não é de graça. Uma chamada que suspende usa um objeto de continuação para guardar o estado, e a máquina de estados ocupa espaço no bytecode. Para uma função pequena e um número moderado de pausas, esse custo é pequeno, mas o texto desta aula não mede esse custo. Nenhuma medição foi feita, então não há número de desempenho aqui. Se o custo importar num caso concreto, ele precisa de benchmark próprio.
6. erros comuns e boas práticas
O primeiro erro é tratar suspend como sinônimo de paralelo. Uma função suspend roda no ritmo de quem a chamou, a menos que o código a lance em outra coroutine com async ou launch. O motivo do erro costuma ser a expectativa trazida de outras linguagens, onde marcar uma função como assíncrona já a coloca em outro lugar. A boa prática é perguntar, para cada chamada, onde ela roda e quem espera por ela.
O segundo erro é o runBlocking dentro de um handler HTTP. Como a seção 5 explicou, ele serve para main e testes. Dentro de um controller, ele prende a thread da requisição até o fim da coroutine, e essa thread deixa de atender outras requisições enquanto espera. Um revisor percebe esse erro logo de cara, porque ele anula a razão de existir da coroutine.
O terceiro erro é esconder um bloqueio dentro de uma função suspend. Um Thread.sleep, uma consulta JDBC ou uma leitura de arquivo bloqueante dentro dela continuam prendendo a thread. A boa prática é trocar o bloqueio por uma chamada suspend quando ela existir, ou mover o bloqueio para um dispatcher próprio para isso, assunto da aula Dispatchers e bloqueio.
7. comparação com Go
Na seção 4 a comparação com goroutines ficou no nível da ideia. Aqui ela vai até o código, porque a diferença mais útil para quem vem de Go está em quem declara a possibilidade de pausa. Em Kotlin, a função declara isso com suspend. Em Go, a assinatura não diz nada sobre pausa, e é o corpo da função, ou a documentação da chamada, que revela se ela bloqueia.
Todo o código desta seção foi compilado e executado com go run usando o Go 1.27.2 (go1.27.2 darwin/arm64). O programa abaixo é o que rodou, e a saída é a real.
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
"time"
)
// Não há marcador de suspensão: qualquer função pode dormir ou bloquear.
func buscaSimulada(id int) string {
time.Sleep(100 * time.Millisecond)
return fmt.Sprintf("dado-%d", id)
}
// Chamada direta, sem construtor de coroutine nem runBlocking.
func passo(entrada int) int {
a := entrada + 1
time.Sleep(10 * time.Millisecond)
b := a * 2
time.Sleep(10 * time.Millisecond)
return b
}
func main() {
fmt.Println("passo(1):", passo(1))
const n = 10_000
var wg sync.WaitGroup
var concluidas atomic.Int64
wg.Add(n)
for id := 1; id <= n; id++ {
go func() {
defer wg.Done()
buscaSimulada(id)
concluidas.Add(1)
}()
}
time.Sleep(10 * time.Millisecond)
fmt.Println("mais de 1000 goroutines vivas durante a espera:", runtime.NumGoroutine() > 1000)
wg.Wait()
fmt.Println("goroutines concluídas:", concluidas.Load())
}
passo(1): 4
mais de 1000 goroutines vivas durante a espera: true
goroutines concluídas: 10000
O primeiro ponto a notar é a ausência de marcador. A função passo tem assinatura comum, func passo(entrada int) int, e pode ser chamada direto do main, sem runBlocking e sem nenhum construtor de coroutine. A documentação de time.Sleep diz que a função “pauses the current goroutine for at least the duration d”, ou seja, pausa a goroutine atual por pelo menos a duração d. O sujeito da frase é a goroutine. A documentação não menciona thread, e isso não é descuido: a unidade que pausa é a unidade de trabalho do Go, e o destino da thread do sistema operacional fica por conta do runtime.
Os 10 000 time.Sleep do programa mostram o outro lado. Cada goroutine dorme 100 ms, e durante a espera havia mais de mil goroutines vivas ao mesmo tempo. Nada no código pediu um pool, uma thread por goroutine ou uma máquina de estados. Basta o go. A FAQ oficial do Go descreve o mecanismo com uma frase que conversa com a seção 1 desta aula: “When a coroutine blocks, such as by calling a blocking system call, the run-time automatically moves other coroutines on the same operating system thread to a different, runnable thread so they won’t be blocked.” Em português, quando uma coroutine bloqueia, por exemplo numa chamada de sistema bloqueante, o runtime move automaticamente as outras coroutines da mesma thread do sistema operacional para outra thread executável, para que não fiquem bloqueadas. A FAQ usa a palavra coroutine no sentido de goroutine, herança do desenho inicial da linguagem. Logo em seguida ela diz que o programador não percebe nada disso, “which is the point”, e esse é justamente o objetivo.
A figura põe as duas pausas lado a lado. No grupo do Kotlin, o delay guarda o estado, devolve o marcador COROUTINE_SUSPENDED e libera a thread para outra coroutine, até que o resumeWith retome do ponto salvo. No grupo do Go, o time.Sleep deixa a pilha da goroutine guardada na memória, o runtime troca a thread, e o timer devolve a goroutine ao mesmo ponto. A pergunta que a figura responde é quem decide a troca de thread, e a resposta é diferente nos dois lados.
Aqui termina o que a documentação pública permite afirmar. A FAQ descreve o efeito visível, mas não o mecanismo interno de como uma goroutine pausada guarda o seu estado. O Kotlin, na seção 3, mostrou esse mecanismo porque o compilador gera o código: o campo label, os valores salvos na continuação e o retorno de COROUTINE_SUSPENDED. Com a documentação consultada, não dá para dizer se o Go usa uma transformação parecida por dentro. As notas do vault sobre o scheduler descrevem o modelo G:M:P, mas essas notas não foram conferidas nesta aula, então o modelo interno fica como hipótese de estudo.
A consequência prática é a coloração de funções. No Kotlin, suspend separa o código em dois grupos, porque uma função suspend só pode ser chamada de dentro de outra suspend ou de um construtor de coroutine. No Go essa separação não existe: uma função que bloqueia pode ser chamada de qualquer lugar, inclusive do main. Isso simplifica o ponto de entrada, mas tira da assinatura uma informação útil. Ao ler uma função Go, não dá para saber só pela declaração se ela pode pausar. No Kotlin, a assinatura diz isso e o compilador cobra a regra. Cada modelo transfere o trabalho de conferência para um lugar diferente.
Quem vem de Go também tende a supor que time.Sleep e Thread.sleep se comportam da mesma forma, e não se comportam. A primeira pausa uma goroutine. A segunda prende a thread, como mostrou a seção 5. Por isso o caso do Thread.sleep dentro de suspend não tem equivalente direto em Go, e a comparação de bloqueio com um pool de threads aparece na aula Dispatchers e bloqueio, onde o assunto é o dispatcher.
| aspecto | Kotlin | Go |
|---|---|---|
| marcação de pausa | suspend na assinatura | nenhuma, qualquer função pode bloquear |
| quem gera a pausa | o compilador, com uma máquina de estados | o runtime, que move goroutines entre threads |
| como começa | launch, async ou runBlocking | go f() |
| custo visível | estado guardado na continuação | pilha da goroutine, que a FAQ descreve como “just a few kilobytes” |
Esta seção não mediu desempenho. A frase sobre mais de mil goroutines vivas é um teste de existência, e não uma medida de custo. Nenhum tempo do programa Go foi comparado com o do Kotlin.
Na prática, o erro de quem vem de Go é supor que uma suspend fun roda em paralelo só porque ela parece uma goroutine chamada de outro jeito. Ela não roda. Quem vem de Kotlin costuma errar no sentido inverso: espera que uma função comum dentro de uma coroutine seja pausada pelo runtime, e chama Thread.sleep ou uma API bloqueante sem aviso do compilador. Em Kotlin, a verificação está na assinatura. Em Go, ela está no corpo da função e na documentação de cada chamada.
8. exercícios
- Crie dez coroutines dentro de um
runBlocking, cada uma chamandoThread.sleep(100)no lugar dedelay(100). Meça o tempo total comSystem.nanoTime(), verifique que ele passa de 1 segundo e explique por que as esperas se somam. A pista está em qual thread executa as coroutines. - Compile
passocom uma terceira suspensão e rode ojavap -pde novo. Diga quantos estados a máquina ganhou e identifique o campo que guarda o valor debentre a segunda pausa e oreturn. - Escreva uma função
suspendque chame outra funçãosuspende tente chamá-la de umfun main()comum, semrunBlocking. Leia a mensagem do compilador e explique o motivo com suas palavras.
9. glossário
Coroutine: trabalho que pode pausar e retomar, sem prender a thread durante a espera.
Suspend: modificador de função que permite pausar a execução, usando apenas funções suspend ou bibliotecas que oferecem suspensão.
Continuation: objeto que representa o resto da execução de uma coroutine depois de um ponto de suspensão.
Máquina de estados: forma em que o compilador organiza uma função suspend, com um estado por ponto de suspensão e um campo label que indica o estado atual.
CPS (continuation-passing style): estilo em que cada chamada recebe uma continuação, usado para implementar suspend.
Dispatcher: componente que decide em qual thread uma coroutine roda, assunto da aula Dispatchers e bloqueio.
fontes
Consultadas em 20/10/2026:
- Visão geral das coroutines, https://kotlinlang.org/docs/coroutines-overview.html. Foram usadas as frases sobre coroutines como alternativa leve às threads, sobre suspensão sem bloquear a thread e sobre o custo das threads.
- Documento de design das coroutines do Kotlin (KEEP), https://github.com/Kotlin/KEEP/blob/master/proposals/coroutines.md. Foram usadas as seções sobre CPS, o parâmetro
Continuation, o marcadorCOROUTINE_SUSPENDEDe a máquina de estados comlabel. A página tem mais de 100 mil caracteres, e a consulta leu até o limite de 100 mil. O restante não foi lido. - Documentação de
runBlocking, https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/run-blocking.html. Usada para a descrição de bloqueio da thread e para o aviso sobre chamarrunBlockingdentro de uma funçãosuspend.
Go, consultadas em 20/10/2026, para a seção 7:
- Perguntas frequentes do Go, https://go.dev/doc/faq. Foram usadas as frases sobre a multiplexação de coroutines em threads, sobre a chamada bloqueante e sobre o custo da pilha da goroutine (“just a few kilobytes”).
- Documentação do pacote
time, https://pkg.go.dev/time. Foi usada a descrição deSleep(“pauses the current goroutine for at least the duration d”).
Versão do Go usada nos testes: go1.27.2 darwin/arm64, com go run sobre o código da seção 7.
Ambiente dos exemplos: kotlinc 2.1.21 do IntelliJ IDEA, kotlinx-coroutines-core-jvm 1.10.2 (SHA-1 conferido com o Maven Central), JDK Temurin 21.0.8. Os exemplos foram compilados um a um e executados com o classpath desse jar. A saída de javap -p foi obtida com o JDK 21.
Não verificado: o comportamento de custo em memória e tempo de cada suspensão. A aula não mede esse custo. Também não verificado: o mecanismo interno de pausa das goroutines do Go, porque a documentação pública consultada descreve só o efeito visível. A descrição de goroutines de Go na seção 4 é conhecimento geral da linguagem e não foi conferida em documentação nesta aula.