Pular para o conteúdo

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

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

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.

Bloquear x suspender. Thread 1 → Coroutine B (retoma B)
figura 1 · Bloquear x suspender

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.

Estados de uma suspend function. Código escrito → label = 0 (compilador gera a máquina); label = 0 → label = 1 (delay suspende e guarda label)…
figura 2 · Estados de uma suspend function

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.

Pausa em Kotlin e em Go. delay(10) → Estado (guarda estado); Estado → Thread livre (devolve marcador); Thread livre → resumeWith (resume)…
figura 3 · Pausa em Kotlin e em Go

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.

aspectoKotlinGo
marcação de pausasuspend na assinaturanenhuma, qualquer função pode bloquear
quem gera a pausao compilador, com uma máquina de estadoso runtime, que move goroutines entre threads
como começalaunch, async ou runBlockinggo f()
custo visívelestado guardado na continuaçãopilha 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

  1. Crie dez coroutines dentro de um runBlocking, cada uma chamando Thread.sleep(100) no lugar de delay(100). Meça o tempo total com System.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.
  2. Compile passo com uma terceira suspensão e rode o javap -p de novo. Diga quantos estados a máquina ganhou e identifique o campo que guarda o valor de b entre a segunda pausa e o return.
  3. Escreva uma função suspend que chame outra função suspend e tente chamá-la de um fun main() comum, sem runBlocking. 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:

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 de Sleep (“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.

relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?