Pular para o conteúdo

Trilha de Kotlin · parte 29 de 59

Virtual threads e testes com runTest

Aula da fase 4 da trilha de Kotlin: Quando virtual threads do Java 21 são a escolha certa, e como testar coroutines com tempo virtual sem `Thread.sleep`?

avançadoPublicada em · 18 min de leitura

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

Esta aula junta dois assuntos que parecem distantes. O primeiro são as virtual threads do Java 21, uma forma de dar uma thread barata a cada tarefa bloqueante sem montar um pool. O segundo é o runTest, que roda um teste de coroutines com um relógio falso, para que um delay de dez segundos leve microssegundos de tempo real. Eles se encontram no mesmo ponto: as duas decisões tratam de como o código espera, uma na produção e outra no teste.

Há uma comparação para o teste. Verificar um lembrete que toca às sete da manhã não exige esperar até as sete. Basta adiantar o relógio até o momento certo. Mas se o teste dorme de verdade com Thread.sleep, ele espera de verdade, e o relógio não ajuda. A segunda metade da aula mostra essa diferença com saída real.

Todo código foi compilado com kotlinc 2.1.21, com kotlinx-coroutines-core-jvm 1.10.2 e kotlinx-coroutines-test-jvm 1.10.2, rodando no JDK Temurin 21.0.8.

o mapa da aula

Começamos pela definição de virtual thread, com as citações da JEP 444, e um exemplo que cria dez mil tarefas bloqueantes. Depois mostramos como uma coroutine pode rodar sobre o mesmo tipo de executor. A seção 2 compara os dois modelos sem decidir pelo leitor. A seção 3 trata do runTest: o tempo virtual, o advanceTimeBy e o advanceUntilIdle. A seção 4 mostra o que acontece quando o teste usa Thread.sleep, e a 5 reúne os erros que um revisor procura. A 6 compara as virtual threads com as goroutines de Go e o tempo virtual com o testing/synctest.

1. virtual threads do Java 21

Uma virtual thread é uma instância de java.lang.Thread que não está presa a uma thread específica do sistema operacional. Essa definição vem da JEP 444, que finalizou o recurso no JDK 21. Antes disso, o recurso passou por duas fases de prévia, nas JEPs 425 (JDK 19) e 436 (JDK 20).

A motivação está no custo de cada thread. Uma thread de plataforma é mapeada para uma thread do sistema operacional, e um servidor que atende muitas requisições bloqueantes precisa de muitas delas. Com virtual threads, o código continua escrito de forma bloqueante, linha por linha, e a JVM cuida de liberar a thread de sistema enquanto a tarefa espera. A JEP descreve as virtual threads como baratas e abundantes, e pede explicitamente que elas não sejam colocadas em pool: “Virtual threads are cheap and plentiful, and thus should never be pooled”.

O exemplo abaixo cria dez mil tarefas, cada uma com Thread.sleep(100), em um executor que dá uma virtual thread a cada tarefa. Ele também verifica se a thread atual é virtual.

@OptIn(ExperimentalCoroutinesApi::class)
fun main() {
    println("--- virtual threads (Java 21)")
    println("Thread.ofVirtual() cria thread virtual? ${Thread.ofVirtual().unstarted { }.isVirtual}")
    val concluidas = AtomicInteger()
    val inicio = System.nanoTime()
    Executors.newVirtualThreadPerTaskExecutor().use { exec ->
        repeat(10_000) {
            exec.submit {
                Thread.sleep(100)
                concluidas.incrementAndGet()
            }
        }
    }
    println("tarefas bloqueantes concluídas: ${concluidas.get()}")
    println("terminou em menos de 10 s: ${(System.nanoTime() - inicio) / 1_000_000 < 10_000}")

    println("--- coroutine sobre um executor de virtual threads")
    Executors.newVirtualThreadPerTaskExecutor().use { exec ->
        runBlocking(exec.asCoroutineDispatcher()) {
            println("dentro da coroutine, thread virtual? ${Thread.currentThread().isVirtual}")
            delay(10)
        }
    }
}

A saída real foi:

--- virtual threads (Java 21)
Thread.ofVirtual() cria thread virtual? true
tarefas bloqueantes concluídas: 10000
terminou em menos de 10 s: true
--- coroutine sobre um executor de virtual threads
dentro da coroutine, thread virtual? true

As dez mil tarefas terminaram, e o teste verificou só que o total foi concluído dentro de um limite folgado de 10 segundos. O exemplo não mede tempo nem compara com outra forma de executar, e nenhum número de desempenho sai daqui. O segundo bloco mostra que uma coroutine pode rodar sobre um executor de virtual threads, porque asCoroutineDispatcher() converte o executor em dispatcher. A coroutine então observa isVirtual como verdadeiro.

Dois pontos da JEP merecem atenção, e ambos são citados na própria página. O primeiro é o pinning: a JEP diz que a virtual thread não pode ser desmontada quando executa código dentro de um bloco synchronized, e acrescenta que isso não torna a aplicação incorreta, mas pode prejudicar a escalabilidade. A JEP também registra que o JDK pode remover essa limitação em uma versão futura, e que até lá, para trechos frequentes com operações de I/O, a recomendação é usar ReentrantLock. O segundo ponto são as thread-locals, que a JEP recomenda usar só depois de cuidadosa consideração. Esta aula não testou pinning, e o detalhe vem apenas da página da JEP.

2. quando virtual threads e coroutines se encaixam

Os dois modelos resolvem problemas vizinhos, que é manter muitas esperas em andamento sem prender recurso caro, mas por caminhos diferentes. Uma virtual thread mantém o estilo bloqueante do Java. Uma coroutine usa suspensão, funções suspend e um escopo estruturado com cancelamento. A JEP apresenta as virtual threads como uma forma de manter aplicações concorrentes de alto throughput com menos esforço de escrita e de observação, e a documentação das coroutines trata de concorrência estruturada e de suspensão. Cada modelo tem um conjunto próprio de garantias e de ferramentas.

A diferença aparece na prática. Uma base de código Java com JDBC, bibliotecas bloqueantes e synchronized pode se beneficiar de virtual threads, em princípio sem reescrita, porque o código já é bloqueante. Quanto ganho isso traria depende do código e da carga, e esta aula não mediu nada. Um serviço que precisa de cancelamento que se propaga pela hierarquia, de Flow para fluxos de eventos e de composição de várias chamadas assíncronas tem razões para usar coroutines, e essas razões não desaparecem com virtual threads. Os dois modelos podem coexistir no mesmo serviço, e o currículo da trilha assume essa convivência.

Esta aula não decide qual dos dois o leitor deve usar. A pergunta útil é outra. O código que vai ser escrito é bloqueante por natureza, como uma biblioteca JDBC antiga? Ele precisa de cancelamento estruturado entre várias chamadas? Há fluxos de dados que devem ser coletados e transformados? A biblioteca que o projeto usa já expõe funções suspend, ou só oferece API bloqueante? A resposta a essas perguntas separa os casos, e o projeto ainda precisa ser conferido. O comportamento dos dois modelos com Spring Boot não foi verificado nesta aula.

3. runTest e tempo virtual

O runTest executa o corpo de um teste dentro de uma coroutine, e nos sistemas JVM ele se comporta como um runBlocking que pula os atrasos. A documentação diz que o código executado dessa forma pula os delay, e que isso é feito com tempo virtual. Assim, um teste pode usar delay sem demorar mais do que o necessário. A página da API também informa o tempo limite padrão do corpo do teste: 60 segundos, e se o corpo não terminar nesse tempo, o teste falha com AssertionError.

O exemplo abaixo mostra o tempo virtual. Ele espera dez segundos virtuais e compara o relógio real.

runTest {
    val t0 = System.nanoTime()
    delay(10_000)
    println("tempo virtual avançou: ${testScheduler.currentTime} ms")
    println("relógio real abaixo de 1 s: ${(System.nanoTime() - t0) / 1_000_000 < 1000}")
}

A saída real foi:

tempo virtual avançou: 10000 ms
relógio real abaixo de 1 s: true

O relógio virtual foi para 10 000 ms, e o relógio real ficou abaixo de um segundo. O acesso ao tempo virtual é feito por testScheduler.currentTime. Esse acesso exige opt-in para API experimental, por isso a função main tem a anotação @OptIn(ExperimentalCoroutinesApi::class). O compilador emitiu avisos sem ela, e a versão final compilou sem aviso.

O advanceTimeBy avança o relógio virtual sem esperar, e o advanceUntilIdle executa tudo o que está pendente. O teste abaixo mostra a diferença de uma forma que se pode verificar linha por linha:

runTest {
    var contador = 0
    launch {
        delay(1_000)
        contador++
    }
    testScheduler.advanceTimeBy(999)
    println("antes de 1000 ms: $contador")
    testScheduler.advanceUntilIdle()
    println("depois de ocioso: $contador")
}

A saída real foi:

antes de 1000 ms: 0
depois de ocioso: 1

Com 999 ms de tempo virtual, o delay(1_000) ainda não terminou, e o contador continua em zero. O advanceUntilIdle levou o relógio até o fim das tarefas pendentes, e o contador chegou a um. A documentação diz que advanceTimeBy é uma função do TestCoroutineScheduler e que ela é marcada como experimental, o que bate com o aviso do compilador.

Pergunta de entrevistaVirtual threads tornam coroutines desnecessárias?

Não. Virtual threads tornam o bloqueio barato na JVM, então código imperativo escala melhor. Coroutines continuam úteis por causa da concorrência estruturada, do cancelamento, do Flow e da suspensão nativa do Kotlin. Em projetos Spring com I/O bloqueante, virtual threads podem ser a escolha mais simples. Mas com Kotlin e coroutines já adotadas, eu manteria coroutines e usaria virtual threads onde o código é bloqueante.

4. o teste que usa Thread.sleep

O último exemplo mostra o erro mais comum do assunto. Um teste que chama Thread.sleep dentro do runTest não pula o tempo, porque Thread.sleep é uma espera de relógio real. Ele bloqueia a thread, e o relógio virtual não sabe que aquela espera aconteceu.

runTest {
    val t0 = System.nanoTime()
    Thread.sleep(300)
    println("Thread.sleep levou tempo real: ${(System.nanoTime() - t0) / 1_000_000 >= 300}")
    println("tempo virtual continua em: ${testScheduler.currentTime} ms")
}

A saída real foi:

Thread.sleep levou tempo real: true
tempo virtual continua em: 0 ms

A espera de 300 ms consumiu tempo real, e o relógio virtual continuou em zero. A consequência para o teste é que, se a espera fosse de trinta segundos, a suíte levaria trinta segundos, e um teste que depende de sincronização por tempo real fica sujeito a falhas intermitentes em máquinas mais lentas. Por isso o currículo da trilha lista o Thread.sleep em teste como sinal de alerta, e a alternativa é delay dentro do runTest, com o tempo virtual avançado de forma explícita.

5. erros comuns e boas práticas

O primeiro erro é usar Thread.sleep para esperar uma coroutine em teste. O teste passa a depender do relógio real, fica lento e fica intermitente. A boa prática é trocar a espera por delay dentro de runTest e avançar o tempo com advanceTimeBy ou advanceUntilIdle.

O segundo erro é esquecer que testScheduler tem API experimental. O aviso do compilador é claro, e a solução é o opt-in explícito no lugar em que o teste usa essa API, e não um @OptIn espalhado por todo o projeto.

O terceiro erro é tratar virtual threads como uma forma de substituir coroutines. A JEP apresenta as virtual threads como um jeito de manter aplicações concorrentes com menos esforço, e a documentação das coroutines trata de suspensão e de concorrência estruturada. A boa prática é escolher o modelo pelo código que será escrito e pelo que a biblioteca do projeto oferece, e não pela moda.

O quarto erro é criar um pool de virtual threads. A JEP diz que elas não devem ser agrupadas em pool, e o newVirtualThreadPerTaskExecutor existe justamente para dar uma virtual thread por tarefa.

O quinto erro é usar synchronized em código que espera I/O dentro de virtual threads, sem saber do pinning. A JEP recomenda ReentrantLock nesses trechos frequentes, e a aula não testou o efeito em um serviço real.

6. comparação com Go

Go tem a comparação mais direta para as virtual threads, que é a goroutine. As duas se parecem no ponto que importa para esta aula: cada tarefa é barata, o código fica escrito de forma bloqueante, e o runtime, e não o programador, decide em qual thread do sistema operacional cada tarefa roda. A nota Scheduler descreve o modelo G:M:P, em que goroutines (G) são distribuídas entre threads do sistema (M) por processadores lógicos (P). A JEP 444 descreve algo parecido do lado Java: a virtual thread é montada numa “carrier”, que é uma thread de plataforma, e o escalonador é “a work-stealing ForkJoinPool that operates in FIFO mode”. As duas arquiteturas põem um escalonador próprio sobre threads do sistema, e a JEP menciona roubo de trabalho entre filas. A nota Scheduler descreve o mesmo tipo de mecanismo para Go, e a aula não o conferiu numa fonte oficial de Go.

O exemplo de Go abaixo faz a mesma tarefa da seção 1: dez mil tarefas, cada uma com um sono de 100 ms, e a contagem de conclusões. O programa foi executado com go run na versão go1.27.2.

var concluidas atomic.Int64
var wg sync.WaitGroup

inicio := time.Now()
for i := 0; i < 10_000; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		time.Sleep(100 * time.Millisecond)
		concluidas.Add(1)
	}()
}
wg.Wait()

fmt.Println("tarefas bloqueantes concluídas:", concluidas.Load())
fmt.Println("terminou em menos de 10 s:", time.Since(inicio) < 10*time.Second)

A saída foi tarefas bloqueantes concluídas: 10000 e terminou em menos de 10 s: true, a mesma da seção 1. Os dois programas têm a mesma forma: uma tarefa por unidade de escalonamento, uma espera bloqueante em cada uma, e uma contagem no fim. A diferença de escrita está na criação. Em Go, go func() { ... }() cria a goroutine, e não há pool a configurar. Em Java, a criação passa pelo executor newVirtualThreadPerTaskExecutor, que dá uma virtual thread por tarefa. Nenhum dos dois exemplos mede desempenho, e esta aula não compara custo entre eles.

Por que a virtual thread está mais perto da goroutine do que a coroutine está? A resposta está na sintaxe e no escopo. Uma função de Go pode chamar time.Sleep ou ler de um socket sem nenhuma marca especial, e a goroutine é criada com a palavra go. Uma virtual thread também aceita qualquer código bloqueante do Java sem marca especial, e a criação é uma chamada de biblioteca. Uma coroutine do Kotlin exige que a função seja suspend, o que faz a cor da função contaminar quem a chama, e exige um dispatcher ou um escopo para rodar. Por isso, a seção 2 disse que as virtual threads mantêm o estilo bloqueante, e Go é o caso em que esse estilo é a regra da linguagem.

Há três diferenças que importam. A primeira é a preempção. A JEP 444 diz que “The scheduler does not currently implement time sharing for virtual threads”, e acrescenta que elas não precisam que o código devolva o controle ao escalonador. Na prática, uma virtual thread que roda um laço de CPU sem bloquear continua no seu carrier até parar por conta própria. As notas da versão Go 1.14 descrevem o caso oposto: “Goroutines are now asynchronously preemptible. As a result, loops without function calls no longer potentially deadlock the scheduler or significantly delay garbage collection.” Go, a partir dessa versão, consegue retirar uma goroutine de um laço apertado, e Java, pela JEP, não faz fatia de tempo entre virtual threads.

A segunda é o pinning. A JEP descreve que a virtual thread não pode ser desmontada quando está dentro de um bloco synchronized, e recomenda ReentrantLock nos trechos frequentes com I/O. Esse caso é específico da implementação de Java, e os documentos de Go consultados não trazem um equivalente com o mesmo nome. A aula não testou sincronização em Go, então a comparação para aqui.

A terceira é o pool. A JEP diz que virtual threads “should never be pooled”. Em Go, o pool de workers da nota Worker Pools e Rate Limiting serve para limitar a concorrência, e não para economizar goroutines. As duas linguagens concordam que criar uma tarefa por unidade é o modo natural, e discordam apenas quando a limitação é necessária, o que não é uma diferença de custo que esta aula tenha medido.

O tempo de teste tem uma comparação direta com runTest. Em Go, o pacote testing/synctest roda uma função “in a new bubble”, e a documentação diz que, “Within a bubble, the time package uses a fake clock.” O relógio da bolha só avança “when every goroutine in the bubble is durably blocked”. O programa abaixo foi executado com go test -v em um módulo com go 1.25 no go.mod.

func TestTempoVirtual(t *testing.T) {
	inicioReal := time.Now()
	synctest.Test(t, func(t *testing.T) {
		inicio := time.Now()
		time.Sleep(2 * time.Minute)
		fmt.Println("tempo dentro da bolha:", time.Since(inicio))
	})
	fmt.Println("relógio real abaixo de 1 s:", time.Since(inicioReal) < time.Second)
}

A saída foi tempo dentro da bolha: 2m0s e relógio real abaixo de 1 s: true. A diferença de desenho está no Thread.sleep da seção 4. No Kotlin, Thread.sleep e delay são funções diferentes, e só a segunda usa o relógio virtual. Em Go, o time.Sleep é o mesmo dentro e fora da bolha, e o pacote time muda de relógio quando está dentro dela. Quem escreve o teste não precisa escolher entre duas funções de espera, mas precisa saber que, fora de synctest.Test, o time.Sleep volta a ser tempo real. Esse é o mesmo tropeço da seção 4, em outro lugar.

O advanceTimeBy da seção 3 não tem equivalente direto na documentação consultada. Em Go, o relógio avança sozinho quando todas as goroutines da bolha estão bloqueadas, e a função synctest.Wait “blocks until every goroutine within the current bubble, other than the current goroutine, is durably blocked”. Ou seja, a sincronização se faz por bloqueio, e não por um avanço manual de tempo. A documentação de synctest.Test também diz que, se as goroutines da bolha travarem em deadlock, o teste falha. A página do pacote registra Test como adicionado no go1.25.0. A página consultada não diz se o pacote é estável, e a aula não afirma isso.

Os erros mais comuns têm a mesma raiz nas duas linguagens, que é tratar o tempo como algo que o teste pode esperar de verdade. Em Kotlin, o erro é o Thread.sleep dentro do runTest, descrito na seção 4 e na seção 5. Em Go, o erro é um time.Sleep usado para esperar goroutines fora de synctest.Test, porque ali o relógio é real e o teste fica lento e intermitente. Um segundo erro, presente nas duas linguagens, é tratar a concorrência como custo que se resolve com mais threads ou goroutines. Ambas oferecem a mesma coisa para tarefas bloqueantes, e a escolha entre elas depende de preempção, de sincronização e de quais bibliotecas o projeto usa.

7. exercícios

  1. Escreva um teste com runTest que espera dois minutos virtuais com delay, verifica o tempo virtual e confirma que o relógio real ficou abaixo de um segundo. Depois troque o delay por Thread.sleep e compare as duas saídas.
  2. Crie um launch dentro de runTest que incrementa um contador em 500 ms e outro em 1 500 ms. Use advanceTimeBy para verificar o estado do contador em 600 ms, depois em 1 600 ms.
  3. Escreva uma função que executa cem tarefas bloqueantes em um executor de virtual threads e outra que faz o mesmo com Dispatchers.IO. Explique, sem números de desempenho, quais decisões de código mudam entre as duas e quais não mudam.

8. glossário

Virtual thread: instância de java.lang.Thread que não está presa a uma thread específica do sistema operacional, definida na JEP 444.

Pinning: situação em que uma virtual thread não pode ser desmontada, por exemplo dentro de um bloco synchronized, segundo a JEP 444.

runTest: função de teste que executa o corpo como coroutine e pula atrasos com tempo virtual.

Tempo virtual: relógio controlado pelo agendador de teste, que avança sem esperar o tempo real.

advanceTimeBy: avança o relógio virtual pelo tempo informado, executando as tarefas agendadas nesse intervalo.

advanceUntilIdle: executa todas as tarefas pendentes até não haver mais nenhuma agendada.

fontes

Consultadas em 20/10/2026:

A página de testes de coroutines (https://kotlinlang.org/docs/coroutines-test.html) retornou 404 na consulta, e o advanceUntilIdle não teve página própria consultada. A descrição de advanceUntilIdle vem do comportamento observado na execução, e não de uma citação da documentação.

Comparação com Go (seção 6), consultadas em 20/10/2026:

  • Pacote testing/synctest, https://pkg.go.dev/testing/synctest. Foram usados os textos sobre a bolha, o relógio falso, o avanço do tempo quando as goroutines estão bloqueadas, Test (adicionado no go1.25.0) e Wait. A página não diz se o pacote é estável.
  • Notas da versão Go 1.14, https://go.dev/doc/go1.14, seção Runtime, sobre a preempção assíncrona de goroutines.
  • Pacote sync, https://pkg.go.dev/sync, para WaitGroup com Add, Done e Wait, como usado no exemplo de dez mil goroutines.
  • JEP 444, https://openjdk.org/jeps/444, para as frases sobre o escalonador (ForkJoinPool, carrier), a ausência de fatia de tempo e a regra contra pool. Essas frases foram conferidas nesta consulta, além das já citadas na seção 1.
  • Nota Scheduler do vault, que descreve o modelo G:M:P. Essa nota não é fonte oficial, e a aula a usa só como referência de estudo para o modelo. A parte de roubo de trabalho em Go não foi conferida em documentação oficial.

Os trechos de Go foram executados com go run e go test -v na versão go1.27.2 (go version go1.27.2 darwin/arm64). A página pública do pacote mostra go1.27.3, mas a máquina tinha a 1.27.2. Não verificado: o comportamento de synctest fora de um caso simples, a estabilidade do pacote e qualquer diferença de custo entre goroutines e virtual threads, que esta aula não mediu.

Ambiente dos exemplos: kotlinc 2.1.21, kotlinx-coroutines-core-jvm 1.10.2, kotlinx-coroutines-test-jvm 1.10.2, JDK Temurin 21.0.8. Os jars foram baixados do Maven Central e os SHA-1 conferidos. A saída foi repetida duas vezes e não mudou.

Não verificado: o comportamento de pinning em um serviço real, a integração de virtual threads com Spring Boot e o uso de JUnit com runTest, porque os exemplos rodam em main e não em um framework de teste. A aula não apresenta números de desempenho.

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
?