CodeGym /Cursos /Kotlin SELF /CoroutineScope e Job — hierarquia de tarefas e gerenciame...

CoroutineScope e Job — hierarquia de tarefas e gerenciamento do ciclo de vida

Kotlin SELF
Nível 53, Lição 0
Disponível

1. Introducao

Quando voce comeca a escrever corrotinas, rapidamente surge na cabeca o pensamento: “Ah, que legal! Agora vou paralelizar tudo!” — e essa e uma fase normal. Mas bem cedo aparece uma pergunta que parece chata, mas salva seus nervos: quem responde por essas tarefas e quando elas devem terminar?

Imagine que seu programa e uma cozinha. main() e voce, e as corrotinas sao cozinheiros-assistentes. Se voce apenas gritou “Ei, alguem pique os legumes!” e saiu para o mercado, ao voltar voce nao sabe: os legumes ja foram picados, o assistente se cansou e foi embora, ou ele ainda esta cortando um tomate sozinho e chorando. Structured concurrency e a regra “os cozinheiros trabalham dentro do turno”: se o turno (a operacao) acabou, todos os assistentes devem concluir seu trabalho, e a cozinha deve passar para um estado compreensivel.

Em Kotlin, essa regra e implementada por meio da ideia: corrotinas sao iniciadas dentro de CoroutineScope, que define o ciclo de vida delas, e a ligacao entre tarefas forma uma hierarquia “pai → filhos”.

Para comecar, vamos fixar que hoje nao vamos discutir troca de threads e Dispatchers, e tambem nao vamos entrar em temas de cancelamento e tratamento de erros. Nosso objetivo e aprender a empacotar corrotinas em limites gerenciaveis e saber esperar a conclusao delas.

2. CoroutineScope: o “container” onde as corrotinas vivem

Falando bem “gente como a gente”, CoroutineScope e a area dentro da qual voce tem o direito de iniciar corrotinas e que define para elas um ciclo de vida em comum. Tecnicamente, launch() e async() sao extension functions justamente de CoroutineScope; ou seja, sem um scope, “educadamente nao da para iniciar” essas corrotinas.

Do ponto de vista de quem esta comecando, ajuda manter na cabeca uma formula simples:

“Scope = lugar onde as corrotinas sao iniciadas e onde alguem responde por elas.”

runBlocking { ... } ja cria um scope

Em aplicacoes de console, muitas vezes comecamos o mundo das corrotinas com runBlocking. E e importante entender: runBlocking nao apenas “permite chamar delay”; ele tambem cria um scope e aguarda a conclusao de tudo o que foi iniciado dentro dele (nos limites desse bloco). Isso e uma parte fundamental da “estruturacao”.

Um mini-exemplo para sentir esse comportamento:


import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    launch {
        delay(100)
        println("worker done") // worker done
    }
    println("main in runBlocking") // main in runBlocking
}

Aqui o programa nao vai terminar cedo demais: runBlocking nao vai “liberar” a main ate que as corrotinas filhas iniciadas dentro do bloco terminem.

Um pequeno esquema: scope como fronteira

Para nao transformar essa ideia em nebulosa, aqui vai um fluxograma simples:

flowchart TD
    A["main()"] --> B["runBlocking { ... } cria um scope"]
    B --> C["launch { ... } corrotina-filha"]
    B --> D["launch { ... } mais uma filha"]
    C --> E["finalizou"]
    D --> F["finalizou"]
    E --> G["runBlocking pode finalizar"]
    F --> G

A ideia-chave: scope nao e uma “abstracao pela abstracao”, mas sim uma fronteira na qual e possivel dizer com honestidade: o trabalho terminou.

3. Job: o “controle” do ciclo de vida de uma tarefa

Quando voce chama launch { ... }, o Kotlin retorna um objeto do tipo Job. E aqui os iniciantes frequentemente se confundem: parece que, por ser “o que launch retornou”, deveria haver um resultado ali. Mas nao: launch e uma “corrotina para acao”, e Job nao e resultado, e sim uma descricao do ciclo de vida.

A documentacao descreve Job diretamente como um elemento do contexto que acompanha o ciclo de vida da corrotina e ajuda a estruturar a execucao.

A acao mais pratica com Job: join()

Job.join() significa: “espere ate a tarefa terminar”.

Isso nao e “magica de sincronizacao”; e, literalmente, um comando bem honesto: quero continuar a execucao apenas depois que esta corrotina terminar.

import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val job: Job = launch {
        delay(100)
        println("task finished") // task finished
    }

    job.join()
    println("after join") // after join
}

Repare num habito util: se pela logica for importante aguardar uma corrotina, faca isso explicitamente. Sim, as vezes da para confiar que runBlocking de qualquer forma vai esperar todos os filhos, mas join() deixa a dependencia visivel no codigo; e legibilidade e uma superforca subestimada.

Job vs Deferred: nao confundir “vida” e “resultado”

Aqui vamos consolidar com cuidado a diferenca (isso apareceu no dia anterior, mas hoje e critico para manter a clareza).

  • Job — “a tarefa existe / terminou”.
  • Deferred<T> — “a tarefa existe / terminou e ainda trouxe um resultado do tipo T”.

Voce pode manter uma cola em forma de tabela:

O que voce inicia Com o que inicia O que voce obtem Com o que espera
Acao sem resultado
launch
Job
join()
Calculo com resultado
async
Deferred<T>
await()

Importante: Deferred<T> tambem e uma tarefa, mas hoje nos interessa especificamente a logica “quem responde por quem” e “onde esta a fronteira de conclusao”. Por isso, o foco esta em Job e join().

4. Hierarquia de tarefas: pai e filhos

Uma das ideias mais fortes de structured concurrency em Kotlin e esta: corrotinas aninhadas formam uma hierarquia. Se voce inicia um launch dentro de outra corrotina, ele vira “filho” daquela corrotina em que foi criado. O contexto (incluindo Job) por padrao e herdado do pai, e assim se construi uma arvore de execucao.

Isso e bom porque a estrutura do codigo comeca a refletir a estrutura da vida das tarefas: “esta subtarefa faz parte desta operacao”.

Exemplo: o pai espera o filho automaticamente

Vamos ver o comportamento sem um join() explicito no filho:

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val parent = launch {
        launch {
            delay(80)
            println("child done") // child done
        }

        delay(20)
        println("parent work") // parent work
    }

    parent.join()
    println("all finished") // all finished
}

O que e importante sentir aqui: fazemos parent.join(), e nao child.join(). Mas parent.join() nao vai terminar enquanto a corrotina filha nao terminar. Ou seja: o pai responde pelos filhos.

“Arvore de tarefas” como imagem

flowchart TD
    P["parent Job"] --> C1["child Job #1"]
    P --> C2["child Job #2"]

Se voce esta lendo um codigo e ve “launch dentro de launch”, voce deveria automaticamente pensar: “ok, isso e parte da operacao do pai”. Esse e o pensamento pratico de structured concurrency.

5. coroutineScope { ... }: scope dentro de uma funcao suspend

Um problema muito comum de quem esta comecando: “Eu quero, dentro de uma suspend fun, iniciar algumas corrotinas e esperar elas terminarem. Eu preciso escrever runBlocking de novo?” Nao; e, mais do que isso, escrever runBlocking dentro de suspend quase sempre e uma ma ideia, porque voce comeca a bloquear a thread onde daria para trabalhar de forma assincrona.

Para esses casos existe coroutineScope { ... }. Esse builder cria um novo scope dentro de uma funcao suspend e so termina quando todas as corrotinas iniciadas dentro dele terminarem. Nos exemplos de Kotlin isso e enfatizado de forma explicita: a execucao continua depois de coroutineScope quando todas as corrotinas dentro tiverem terminado.

Um mini-exemplo que da para memorizar literalmente como um modelo:

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun doWorkTogether() = coroutineScope {
    launch { delay(30); println("A") } // A
    launch { delay(10); println("B") } // B
}

E agora — a chamada a partir de main:

import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    doWorkTogether()
    println("after doWorkTogether") // after doWorkTogether
}

O sentido-chave de coroutineScope: ele e um “container estruturado” para trabalho paralelo dentro de uma funcao suspend. Ele ajuda a nao espalhar launch pelo codigo todo e manter a paralelizacao num unico lugar — onde voce explicitamente decidiu “agora havera varias tarefas relacionadas”.

6. Expense Tracker: um relatorio a partir de etapas paralelas

Para que os exemplos nao vivam uma vida separada “no vacuo”, vamos continuar a ideia pratica de um app de console. Suponha que temos um rastreador de despesas que consegue armazenar despesas em memoria e montar relatorios. Nao vamos mexer com arquivos nem rede (isso e para outros dias do curso), entao vamos simular “operacoes longas” com delay(...).

O objetivo de hoje dentro do app e aprender a montar um relatorio de modo que ele seja composto por varias partes independentes, mas ainda seja uma unica operacao com uma fronteira de conclusao clara.

Base do modelo

Vamos fazer um modelo simples de despesa e dados de teste. E uma base pequena para ter o que “analisar”:

data class Expense(val category: String, val amount: Int)

fun demoExpenses(): List<Expense> = listOf(
    Expense("food", 1200),
    Expense("transport", 300),
    Expense("food", 450),
)

Subtarefas do relatorio como funcoes suspend

Suponha que o relatorio tenha duas partes: soma total e quantidade de registros. Cada parte e “demorada” (condicionalmente), entao usamos delay.

import kotlinx.coroutines.delay

suspend fun calcTotal(expenses: List<Expense>): Int {
    delay(80)
    return expenses.sumOf { it.amount }
}

suspend fun calcCount(expenses: List<Expense>): Int {
    delay(50)
    return expenses.size
}

Agora, atencao: nos nao as executamos em paralelo. Apenas criamos duas operacoes separadas que depois poderemos unir em um scope estruturado.

Montamos uma operacao a partir de varias corrotinas dentro de coroutineScope

Aqui esta o ponto-chave da aula: queremos iniciar duas partes do relatorio em paralelo, mas de modo que a funcao “montar relatorio” nao retorne cedo demais. Esse e um caso direto para coroutineScope {}.

Vamos fazer uma versao em que usamos launch e gravamos os resultados em var. Sim, e estado mutavel — mas no nosso exemplo didatico isso e seguro, porque vamos aguardar a conclusao de ambas as corrotinas e so ler as variaveis depois disso. (Sobre abordagens mais rigorosas para estado compartilhado, falaremos em outros dias; hoje o importante e a estrutura de vida das tarefas.)

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

suspend fun buildShortReport(expenses: List<Expense>): String = coroutineScope {
    var total = 0
    var count = 0

    val totalJob = launch { total = calcTotal(expenses) }
    val countJob = launch { count = calcCount(expenses) }

    totalJob.join()
    countJob.join()

    "count=$count, total=$total"
}

Aqui duas ideias sao importantes de uma vez:

Primeiro, coroutineScope garante que nao “vamos sair para fora” enquanto os filhos nao terminarem. Isso e structured concurrency em acao, e e exatamente assim que Kotlin propoe construir etapas paralelas como uma unica operacao.

Segundo, vemos Job “ao vivo”: launch retornou um Job, e join() virou um ponto explicito de espera.

Conectamos ao main() e vemos o resultado

import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val expenses = demoExpenses()
    val report = buildShortReport(expenses)
    println(report) // count=3, total=1950
}

Ganhamos uma habilidade pequena, mas importante: construir uma “operacao com etapas paralelas internas” sem vazar corrotinas para fora.

Quando join() realmente e necessario

Agora vem um momento de honestidade: as vezes join() dentro de coroutineScope parece desnecessario, porque “mas coroutineScope ja espera os filhos”. E isso e verdade: por si so, coroutineScope vai terminar quando todas as corrotinas dentro terminarem.

Mas join() continua sendo util quando voce precisa de um ponto exato de sincronizacao no meio da operacao. Por exemplo, voce quer esperar pela etapa A, depois fazer a etapa B, e ao mesmo tempo ter a etapa C rodando em paralelo.

Vamos mostrar um exemplo no contexto do nosso relatorio: digamos que queremos primeiro saber o total, depois montar uma linha “checagem de orcamento”, e em paralelo contar a quantidade.

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

suspend fun buildBudgetReport(expenses: List<Expense>): String = coroutineScope {
    var total = 0
    var count = 0

    val totalJob = launch { total = calcTotal(expenses) }
    val countJob = launch { count = calcCount(expenses) }

    totalJob.join()
    val budgetLine = if (total > 1500) "budget: high" else "budget: ok"

    countJob.join()
    "count=$count, total=$total, $budgetLine"
}

Aqui join() nao e um “quebra-galho”, e parte da logica: precisamos do total para escolher o texto. E colocamos explicitamente a barreira exatamente onde ela e necessaria.

7. Erros tipicos

Erro nº 1: iniciar corrotinas “no ar” sem um CoroutineScope claro.
As vezes o iniciante acha que corrotina e apenas uma “funcao que roda sozinha”. Entao aparecem iniciacoes no codigo que nao estao vinculadas a uma operacao: nao fica claro quem as espera e quem responde pela conclusao. O habito correto e iniciar corrotinas apenas dentro de um scope que seja a fronteira da operacao: no console, frequentemente e runBlocking; e dentro de codigo suspend, e coroutineScope. A propria ideia de que launch e async sao iniciados como extension functions de CoroutineScope nao e por acaso: e uma dica da linguagem — “nao inicie uma tarefa sem dono”.

Erro nº 2: confundir “Job = resultado”.
Um cenario muito comum: o estudante escreve val x = launch { ... } e depois tenta usar x como um valor calculado. Mas launch retorna Job, e Job e “ciclo de vida”, nao dados. Se voce precisa de um resultado, voce escolhe async e await(), e usa Job para aguardar a conclusao via join().

Erro nº 3: esperar uma “sequencia magica” sem join().
Paralelismo e traiçoeiro porque a saida no console e a ordem das acoes ficam imprevisiveis se voce nao colocou pontos de espera. Se pela logica voce precisa de “primeiro esperar, depois continuar”, isso deve estar expresso no codigo. A ferramenta mais simples e honesta hoje e job.join(). E mesmo que, num lugar especifico, desse para confiar que o scope externo vai esperar todas as tarefas, um join() explicito muitas vezes deixa o programa mais compreensivel.

Erro nº 4: tentar usar runBlocking dentro de uma funcao suspend.
Essa solucao as vezes parece “mas eu preciso esperar”. Porem, runBlocking bloqueia a thread e coloca uma “fronteira sincrona” onde as corrotinas deveriam permanecer assincronas. Para structured concurrency dentro de codigo suspend, use coroutineScope, que cria uma fronteira gerenciavel e so termina depois que todas as corrotinas filhas terminarem.

1
Tarefa
Kotlin SELF, nível 53, lição 0
Bloqueado
Sinal de conclusão
Sinal de conclusão
1
Tarefa
Kotlin SELF, nível 53, lição 0
Bloqueado
Dois resultados
Dois resultados
1
Tarefa
Kotlin SELF, nível 53, lição 0
Bloqueado
Equipe e estagiário
Equipe e estagiário
1
Tarefa
Kotlin SELF, nível 53, lição 0
Bloqueado
Montagem do slogan
Montagem do slogan
Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION