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 fun diretamente de uma função fun regular (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 — cada await() 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 runBlocking em código de produção: ele bloqueia a thread atual de verdade, destrói o propósito das coroutines. Use apenas em main(), testes, ou onde for necessário conectar código blocking com código suspend.
  • Esquecer await() em async: exceção é engolida, coroutine executa sem ninguém aguardar o resultado — desperdiçador e difícil de rastrear erros.
  • Misturar Dispatchers.IO para trabalho CPU-bound: Dispatchers.IO tem 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 fun chamando sequencialmente, sem precisar de async/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.

kotlincoroutinesconcurrencyandroidspring-boot