O que é Job em Kotlin?

O Job é o elemento do contexto de uma coroutine que representa seu ciclo de vida. Ele permite controlar a execução da coroutine: verificar se esta ativa, esperar sua conclusão ou cancela-la. Todo launch retorna um Job, e todo async retorna um Deferred (que é um subtipo de Job com resultado).

Pense no Job como a “alca” que você segura para controlar uma coroutine que esta rodando em background.

Sintaxe básica

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        delay(2000)
        println("Coroutine finalizada")
    }

    println("Job ativo? ${job.isActive}")     // true
    println("Job completo? ${job.isCompleted}") // false

    job.join() // Espera a coroutine terminar

    println("Job ativo? ${job.isActive}")     // false
    println("Job completo? ${job.isCompleted}") // true
}

O método join() suspende a coroutine atual até que o job termine. Diferente de Thread.join(), ele não bloqueia a thread.

Estados do Job

Um Job passa por vários estados durante sua vida:

New -> Active -> Completing -> Completed
                     |
                     v
               Cancelling -> Cancelled
fun main() = runBlocking {
    val job = launch(start = CoroutineStart.LAZY) {
        println("Executando...")
        delay(1000)
        println("Finalizado")
    }

    println("Novo: isActive=${job.isActive}")         // false
    job.start()
    println("Ativo: isActive=${job.isActive}")        // true
    job.join()
    println("Completo: isCompleted=${job.isCompleted}") // true
}

Com CoroutineStart.LAZY, o job começa no estado “New” e só entra em “Active” quando você chama start() ou join().

Cancelamento de Jobs

O cancelamento e cooperativo em Kotlin. Chamar cancel() não mata a coroutine instantaneamente – ela precisa verificar o cancelamento:

fun main() = runBlocking {
    val job = launch {
        repeat(1000) { i ->
            println("Trabalhando... $i")
            delay(100) // Ponto de suspensao: verifica cancelamento
        }
    }

    delay(500)
    println("Cancelando...")
    job.cancel()
    job.join() // Espera a coroutine finalizar o cancelamento
    // Ou: job.cancelAndJoin() // Combina cancel() + join()
    println("Cancelado")
}

Funções como delay, yield e withContext verificam o cancelamento automaticamente. Se sua coroutine faz trabalho intensivo de CPU sem pontos de suspensao, use isActive ou ensureActive():

val job = launch(Dispatchers.Default) {
    var i = 0
    while (isActive) { // Verifica cancelamento manualmente
        i++
        // Trabalho intensivo de CPU
    }
    println("Parou em $i")
}

Hierarquia de Jobs (Structured Concurrency)

Jobs formam uma hierarquia pai-filho. Quando uma coroutine lanca outra, o job filho e registrado no job pai:

fun main() = runBlocking {
    val jobPai = launch {
        val jobFilho1 = launch {
            delay(2000)
            println("Filho 1 finalizado")
        }
        val jobFilho2 = launch {
            delay(1000)
            println("Filho 2 finalizado")
        }
        println("Filhos lancados")
    }

    delay(500)
    jobPai.cancel() // Cancela pai E todos os filhos
    jobPai.join()
    println("Tudo cancelado")
}

Regras da hierarquia:

  • Cancelar o pai cancela todos os filhos.
  • Uma exceção não tratada em um filho cancela o pai e todos os irmaos.
  • O pai só e considerado completo quando todos os filhos terminam.

Job vs Deferred

Deferred<T> e um Job que retorna um resultado:

fun main() = runBlocking {
    val deferred: Deferred<Int> = async {
        delay(1000)
        42
    }

    // Deferred tem todas as funcionalidades de Job
    println("Ativo: ${deferred.isActive}")

    // Mais: pode obter o resultado
    val resultado = deferred.await()
    println("Resultado: $resultado")
}

Use launch (Job) quando não precisa de resultado; use async (Deferred) quando precisa.

SupervisorJob

O SupervisorJob modifica o comportamento de propagacao de erros: a falha de um filho não cancela os irmaos:

fun main() = runBlocking {
    val supervisor = SupervisorJob()

    val scope = CoroutineScope(coroutineContext + supervisor)

    val job1 = scope.launch {
        delay(1000)
        throw RuntimeException("Erro no job 1")
    }

    val job2 = scope.launch {
        delay(2000)
        println("Job 2 finalizado normalmente") // Executa mesmo com erro no job1
    }

    delay(3000)
    supervisor.cancel()
}

SupervisorJob e essencial em cenários onde tarefas são independentes e a falha de uma não deve afetar as outras.

Completable Job

CompletableJob e um Job que pode ser completado manualmente:

fun main() = runBlocking {
    val job = Job() // Cria um CompletableJob

    launch(job) {
        delay(1000)
        println("Tarefa executada")
    }

    job.complete() // Marca como completando (nao aceita mais filhos)
    job.join()     // Espera filhos existentes terminarem
    println("Tudo feito")
}

Quando usar Job diretamente

  • Cancelamento controlado: quando você precisa cancelar uma operação específica em resposta a uma acao do usuário.
  • Esperar conclusão: quando uma parte do código depende de outra coroutine terminar antes de continuar.
  • Monitorar estado: quando você precisa saber se uma coroutine ainda esta rodando.
  • Escopo customizado: criar CoroutineScopes com Jobs específicos para controlar grupos de coroutines.

Casos de Uso no Mundo Real

  1. Cancelamento de requisicoes de rede em Android: Quando o usuário navega para outra tela, o Job associado ao CoroutineScope da tela anterior e cancelado, abortando automaticamente todas as chamadas de API em andamento. Isso evita vazamentos de memória e atualizações de UI em telas que já não existem.

  2. Processamento paralelo com controle de progresso: Em aplicações que processam lotes de dados (como upload de múltiplas imagens), cada tarefa e lancada como um Job filho. O Job pai permite monitorar o progresso geral, cancelar tudo se necessário, e garantir que o processo só e considerado completo quando todas as imagens foram enviadas.

  3. Tarefas de background com timeout: Servicos que consultam APIs externas utilizam Jobs com withTimeout para garantir que uma requisicao lenta não bloqueie o sistema indefinidamente. O Job e cancelado automaticamente apos o tempo limite, liberando recursos.

  4. Workers independentes com SupervisorJob: Em servidores backend com Ktor ou Spring, cada requisicao HTTP e tratada por um Job independente sob um SupervisorJob. Se uma requisicao falha com exceção, as demais continuam operando normalmente sem serem afetadas.

Boas Praticas

  • Use cancelAndJoin() em vez de chamar cancel() e join() separadamente para garantir que o cancelamento seja concluido antes de prosseguir.
  • Sempre verifique isActive ou chame ensureActive() em loops de CPU intensivo para que o cancelamento cooperativo funcione corretamente.
  • Prefira SupervisorJob quando as coroutines filhas sao independentes e a falha de uma não deve afetar as demais.
  • Nunca capture CancellationException em blocos catch genericos. Se precisar capturar Exception, relance CancellationException explicitamente.
  • Estruture suas coroutines com escopos adequados (como viewModelScope ou lifecycleScope no Android) em vez de usar GlobalScope, para que os Jobs sejam gerenciados automaticamente pelo ciclo de vida.

Perguntas Frequentes

P: Qual a diferenca entre Job e Deferred? R: Job representa uma coroutine que executa uma tarefa sem retornar resultado, criado com launch. Deferred e um subtipo de Job que carrega um valor de retorno, criado com async. Voce obtém o resultado de um Deferred chamando await(), enquanto com Job você apenas espera a conclusao com join().

P: O que acontece quando um Job filho lanca uma exceção? R: Com um Job normal, a exceção propaga para o Job pai, que cancela todos os outros filhos e a si mesmo. Com SupervisorJob, a exceção fica isolada no filho que falhou, e os demais continuam executando normalmente.

P: Por que o cancelamento de coroutines e cooperativo? R: O cancelamento cooperativo e uma decisao de design que evita problemas como corrupcao de dados e vazamento de recursos. Se uma coroutine fosse interrompida abruptamente, ela poderia deixar arquivos abertos ou transacoes incompletas. O modelo cooperativo permite que a coroutine finalize suas operações de limpeza antes de parar.

P: Posso reutilizar um Job depois de cancelado? R: Nao. Um Job cancelado ou completado entra em um estado terminal e não pode ser reiniciado. Se você precisa executar a tarefa novamente, crie um novo Job com um novo launch ou async.

Erros comuns

  1. Nao chamar join apos cancel: cancel() apenas sinaliza o cancelamento. Sem join(), a coroutine pode continuar executando por um tempo. Use cancelAndJoin().

  2. Ignorar a cooperatividade do cancelamento: loops de CPU sem verificação de isActive ou pontos de suspensao não serao cancelados.

  3. Usar Job() como pai sem entender a propagacao: um Job criado manualmente com Job() segue as regras normais de propagacao de erro. Para isolamento, use SupervisorJob().

  4. Esquecer o tratamento de CancellationException: quando um job e cancelado, CancellationException e lancada. Ela deve ser propagada, não capturada em catch generico.

  5. Nao estruturar coroutines: lancar coroutines com GlobalScope em vez de usar escopos estruturados perde todo o beneficio de hierarquia de Jobs.

Termos relacionados

  • Coroutine: a unidade de execução cujo ciclo de vida o Job representa.
  • Deferred: subtipo de Job que carrega um resultado, retornado por async.
  • SupervisorJob: job especial onde falha de filhos não propaga para irmaos.
  • CoroutineScope: escopo que contém um Job e define o ciclo de vida das coroutines.
  • Dispatcher: trabalha junto com o Job no contexto da coroutine, determinando onde ela executa.
  • Structured Concurrency: o princípio de que coroutines formam hierarquias através de Jobs.

O Job e a peça central do controle de ciclo de vida em Kotlin Coroutines. Dominar Jobs, cancelamento e hierarquia e fundamental para escrever código concorrente que seja robusto, previsivel e livre de vazamentos de recursos.