Coroutines no Kotlin: launch, async e scope
1. Visão geral
Coroutines é a forma como Kotlin lida com código assíncrono sem precisar de callback hell ou bloquear threads. Este artigo explica launch, async, suspend fun e CoroutineScope funcionam, quando usar cada um. Após ler, você será capaz de escrever código concorrente limpo, saber como controlar o ciclo de vida das coroutines e testá-lo com JUnit 5.
2. Dependências necessárias
Coroutines não está incluído na biblioteca padrão do Kotlin, você precisa adicionar kotlinx-coroutines-core.
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core</artifactId>
<version>1.8.1</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-test</artifactId>
<version>1.8.1</version>
<scope>test</scope>
</dependency>
Se você usar Spring Boot, o módulo spring-boot-starter-webflux ou spring-boot-starter já inclui extensões Kotlin com suporte para suspend fun em controllers.
3. suspend fun: unidade básica da coroutine
suspend é a palavra-chave que marca uma função que pode ser suspensa sem bloquear a thread.
suspend fun fetchOrder(orderId: Long): Order {
delay(200) // simula uma chamada I/O, sem bloquear a thread de verdade
return Order(orderId, "PENDING")
}
data class Order(val id: Long, val status: String)
delay() é uma função suspend fornecida, diferente de Thread.sleep() porque não mantém a thread enquanto aguarda. suspend fun só pode ser chamado a partir de outra suspend fun ou dentro de um coroutine builder (launch, async, runBlocking).
Atenção: chamar
suspend fundiretamente de uma funçãofunregular (não suspend) causará um erro de compilação. Este é o mecanismo do Kotlin para forçá-lo a declarar explicitamente o contexto assíncrono.
4. launch: coroutine fire-and-forget
launch cria uma nova coroutine, retorna um Job, não retorna o resultado da computação — use quando você só precisa “executar e esquecer”.
fun main() = runBlocking {
val job: Job = launch {
println("Começando a processar pedido na thread: ${Thread.currentThread().name}")
val order = fetchOrder(1L)
println("Status do pedido: ${order.status}")
}
println("launch retornou, a coroutine está rodando em paralelo")
job.join() // aguarda a coroutine ser concluída
}
launch retornou, a coroutine está rodando em paralelo
Começando a processar pedido na thread: main
Status do pedido: PENDING
runBlocking cria um escopo para executar a coroutine dentro de main (use apenas para demonstração/teste, não use em código de produção real). launch não bloqueia a thread atual — ele agenda a coroutine e retorna imediatamente. job.join() é o ponto onde você aguarda a coroutine ser concluída.
5. async: coroutine com resultado de retorno
async é semelhante a launch mas retorna Deferred<T> — uma “promise” que você chama .await() para obter o resultado.
suspend fun fetchCustomer(customerId: Long): String {
delay(150)
return "Cliente-$customerId"
}
suspend fun loadOrderDetail(orderId: Long, customerId: Long): String = coroutineScope {
val orderDeferred = async { fetchOrder(orderId) }
val customerDeferred = async { fetchCustomer(customerId) }
val order = orderDeferred.await()
val customer = customerDeferred.await()
"Pedido ${order.id} (${order.status}) de $customer"
}
Ambos os comandos async executam em paralelo (concorrente), não sequencialmente. O tempo total de espera aqui é ~200ms (máximo de 200ms e 150ms) e não 350ms. .await() é o ponto onde você suspende para obter o resultado — se você chamar await() imediatamente após cada async em vez de separar, o código executará sequencialmente e perderá a vantagem do paralelismo.
// Errado: executa sequencialmente, perde o propósito do async
val order = async { fetchOrder(orderId) }.await()
val customer = async { fetchCustomer(customerId) }.await()
Atenção: o código acima não tem erro de sintaxe mas destrói o propósito de usar
async— cadaawait()bloqueia imediatamente após criar, portanto as duas coroutines nunca serão executadas simultaneamente.
6. CoroutineScope: quem gerencia o ciclo de vida da coroutine
Toda coroutine deve pertencer a um CoroutineScope. O escopo determina: quanto tempo a coroutine vive, quando cancelá-la, como a exceção se propaga.
class OrderService {
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
fun processOrderAsync(orderId: Long) {
serviceScope.launch {
val order = fetchOrder(orderId)
println("Pedido ${order.id} processado")
}
}
fun shutdown() {
serviceScope.cancel() // cancela todas as coroutines filhas rodando neste escopo
}
}
serviceScope é mantido como um campo da classe — esta é a forma de evitar coroutines “órfãs” rodando indefinidamente depois que o objeto não está mais em uso. SupervisorJob() garante que uma coroutine filho falhando não cancele os outros filhos (diferente de Job() regular, onde um filho falhando cancela todos os irmãos).
6.1. coroutineScope vs CoroutineScope
Não confunda coroutineScope { ... } (função suspend, minúscula) com CoroutineScope(...) (construtor que cria um novo escopo, maiúscula).
suspend fun loadDashboard(): String = coroutineScope {
// coroutineScope aguarda que TODAS as coroutines filhas dentro sejam concluídas
// e depois retorna, e usa o contexto da coroutine pai
val a = async { fetchOrder(1L) }
val b = async { fetchCustomer(2L) }
"${a.await()} - ${b.await()}"
}
coroutineScope é um builder de função suspend usado para agrupar múltiplas coroutines filhas, garantindo concurrent estruturado: a função só retorna quando todo async/launch interno está concluído (ou um deles falha e tudo é cancelado). Ele não cria um novo escopo independente mas herda o contexto atual — diferente de CoroutineScope(...) da seção 6, que cria um escopo independente que vive sozinho, não é automaticamente cancelado quando a função termina.
7. Dispatcher: em qual thread a coroutine executa
Dispatcher determina onde a coroutine será executada — semelhante a escolher um Executor em Java.
suspend fun readConfigFile(path: String): String = withContext(Dispatchers.IO) {
// Dispatchers.IO é usado para I/O blocking (ler arquivo, chamar JDBC...)
File(path).readText()
}
suspend fun heavyCompute(data: List<Int>): Long = withContext(Dispatchers.Default) {
// Dispatchers.Default é usado para trabalho CPU-bound
data.sumOf { it.toLong() * it }
}
withContext muda a coroutine atual para executar em um dispatcher diferente e depois retorna automaticamente ao dispatcher anterior quando termina — muito útil quando você precisa chamar código blocking (JDBC, I/O de arquivo) sem querer ocupar a thread principal de Dispatchers.Default ou Dispatchers.Main (no Android).
| Dispatcher | Usado para |
|---|---|
Dispatchers.Default |
Computação CPU-bound (parse, sort, compute) |
Dispatchers.IO |
I/O blocking (arquivo, JDBC, rede blocking) |
Dispatchers.Main |
Atualizar UI (apenas no Android/JavaFX) |
Dispatchers.Unconfined |
Raramente usado, executa na thread que fez a chamada original |
8. Tratando exceções em coroutines
Exceções em launch e async se propagam de formas diferentes — essa é a fonte mais comum de bugs.
fun main() = runBlocking {
// launch: exceção é lançada imediatamente, pode travar o escopo se não capturada
val job = launch {
try {
throw IllegalStateException("Pedido não existe")
} catch (e: IllegalStateException) {
println("Capturada exceção em launch: ${e.message}")
}
}
job.join()
// async: exceção é mantida em Deferred, só é lançada quando você chama await()
val deferred = async {
throw IllegalStateException("Cliente não existe")
}
try {
deferred.await()
} catch (e: IllegalStateException) {
println("Capturada exceção em async: ${e.message}")
}
}
Capturada exceção em launch: Pedido não existe
Capturada exceção em async: Cliente não existe
Com async, se você esquecer de chamar .await(), a exceção será silenciosamente “engolida” até que Deferred seja garbage-collected ou você chame await() em algum lugar — muito fácil causar bugs difíceis de debugar em produção.
9. Escrevendo testes para coroutines
Use kotlinx-coroutines-test com runTest para testar funções suspend sem precisar aguardar delay de verdade.
class OrderServiceTest {
@Test
fun `fetchOrder retorna status PENDING`() = runTest {
val order = fetchOrder(1L)
assertThat(order.id).isEqualTo(1L)
assertThat(order.status).isEqualTo("PENDING")
}
@Test
fun `loadOrderDetail executa as duas coroutines em paralelo`() = runTest {
val result = loadOrderDetail(orderId = 1L, customerId = 2L)
assertThat(result).contains("Pedido 1")
assertThat(result).contains("Cliente-2")
}
}
runTest usa automaticamente tempo virtual — delay(200) dentro de fetchOrder não faz o teste executar 200ms lentamente, ele pula direto para o ponto de retomada da lógica. Esta é a razão pela qual você não deve testar coroutines com runBlocking em testes unitários: ele aguarda tempo real, fazendo a suite de testes ficar cada vez mais lenta conforme o número de testes aumenta.
10. Erros comuns
- Usar
GlobalScope: a coroutine vive por toda a vida da aplicação, não é cancelada de acordo com nenhum ciclo de vida — causa vazamento de memória. Sempre crie um escopo vinculado a uma classe/componente específico como na seção 6. - Chamar
runBlockingem código de produção: ele bloqueia a thread atual de verdade, destrói o propósito das coroutines. Use apenas emmain(), testes, ou onde for necessário conectar código blocking com código suspend. - Esquecer
await()emasync: exceção é engolida, coroutine executa sem ninguém aguardar o resultado — desperdiçador e difícil de rastrear erros. - Misturar
Dispatchers.IOpara trabalho CPU-bound:Dispatchers.IOtem um pool de threads muito maior (otimizado para I/O de espera), usá-lo para computação pesada reduz a eficiência do agendamento.
Atenção: se seu serviço não precisa de concorrência real (apenas chama sequencialmente alguns I/O), coroutines pode ser over-engineering. Às vezes uma simples função
suspend funchamando sequencialmente, sem precisar deasync/launch, é clara o suficiente e mais fácil de manter.
11. Conclusão
suspend fun é a unidade fundamental, launch é usado quando você não precisa do resultado de retorno, async é usado quando você precisa executar em paralelo e obter o resultado via await(). CoroutineScope determina o ciclo de vida — sempre vincule o escopo a um componente específico, evite GlobalScope, prefira coroutineScope/SupervisorJob para controlar concorrência estruturada. Dominar esses três conceitos é suficiente para escrever a maioria do código assíncrono em Kotlin de forma segura e fácil de testar.