Pular para o conteúdo

Trilha de Kotlin · parte 24 de 59

CoroutineScope e concorrência estruturada

Aula da fase 4 da trilha de Kotlin: Quando `coroutineScope` falha tudo e `supervisorScope` isola falhas, e quem é responsável por cada coroutine criada?

intermediárioPublicada em · 23 min de leitura

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

Pense num grupo de amigos saindo para jantar. Se um deles passa mal no meio do caminho, o grupo tem duas saídas razoáveis: todos voltam juntos, ou os outros seguem e alguém cuida do amigo. Nenhuma das duas é certa em todos os casos, e o que importa é que alguém decidiu antes. Em coroutines, essa decisão tem nome: quem é o dono de cada trabalho, quem espera por ele e o que acontece com os irmãos quando um deles falha. O CoroutineScope é o lugar onde essa decisão é tomada, e a concorrência estruturada é o princípio de que nenhuma coroutine nasce sem um dono.

Esta aula trata desse princípio. Vamos ver por que coroutines soltas causam vazamento de trabalho, como coroutineScope e supervisorScope diferem na propagação de falhas, por que launch e async tratam exceções de forma diferente e por que o GlobalScope aparece em todo código que um revisor rejeita. Todo código foi compilado com kotlinc 2.1.21 e executado com kotlinx-coroutines-core-jvm 1.10.2.

o mapa da aula

Começamos pelo problema que a concorrência estruturada resolve, que é o trabalho que sobrevive a quem o criou. Depois vemos o que é um CoroutineScope e o Job que ele carrega, e comparamos coroutineScope com supervisorScope num exemplo que roda. Na seção 4 tratamos de launch e async e de onde as exceções vão parar. A seção 5 mostra o GlobalScope em ação, com a saída real, e a seção 6 traz as bordas e os erros que um revisor procura. A seção 7 compara com Go, que não tem escopo estruturado na biblioteca padrão.

1. o problema das coroutines sem dono

Uma coroutine lançada sem escopo claro é um trabalho que ninguém espera e ninguém cancela. Num handler HTTP, por exemplo, uma auditoria disparada em segundo plano pode continuar rodando depois que a resposta já saiu. Se a requisição foi cancelada pelo cliente, a auditoria não sabe disso. Se a auditoria falha, ninguém está olhando para ela. O resultado é trabalho órfão, que consome recurso, escreve dados fora de hora e some dos logs.

A documentação oficial descreve a solução com uma frase que vale a aula inteira: a hierarquia de contextos “enables structured concurrency, where related coroutines can be canceled together or handle exceptions as a group”. Em português, a hierarquia permite que coroutines relacionadas sejam canceladas juntas ou que tratem exceções como um grupo. A concorrência estruturada é o princípio de que o tempo de vida de uma coroutine filha fica contido no de quem a criou. O pai só termina quando os filhos terminam, e o cancelamento desce pela árvore.

Esse princípio troca liberdade por previsibilidade. Você perde a possibilidade de lançar um trabalho e esquecê-lo, e ganha a garantia de que nada sobrevive ao escopo que o criou, sem depender de disciplina.

2. CoroutineScope e Job

Um CoroutineScope é o contexto onde coroutines são criadas. Ele carrega um Job, que representa o ciclo de vida de quem o dirige. Quando você escreve launch { ... } dentro de um escopo, a nova coroutine vira filha do Job desse escopo. O filho herda o contexto do pai por padrão, e a documentação diz exatamente isso: o contexto é herdado do pai por padrão.

A forma mais simples de criar um escopo estruturado é o builder coroutineScope. Ele é uma função suspend: suspende quem o chamou até que todos os filhos terminem, e só então devolve o controle. Isso significa que o código depois de coroutineScope { ... } só roda quando o trabalho interno acabou, ou quando o escopo falhou. Em muitos casos isso basta, e a estrutura do código mostra a estrutura do trabalho.

O runBlocking também cria um escopo, mas ele bloqueia a thread atual. Para main e testes, isso é aceitável. Dentro de uma função suspend, o escopo deve ser criado com coroutineScope, que suspende em vez de bloquear.

3. coroutineScope e supervisorScope

A diferença mais importante entre os dois escopos aparece quando um filho falha. O exemplo abaixo roda os dois casos lado a lado, com um filho que espera 100 ms e outro que falha em 50 ms.

import kotlinx.coroutines.*

suspend fun tarefa(nome: String, atraso: Long, falha: Boolean) {
    try {
        delay(atraso)
        if (falha) throw IllegalStateException("$nome falhou")
        println("$nome concluída")
    } catch (e: CancellationException) {
        println("$nome cancelada")
        throw e
    }
}

suspend fun auditoria(id: Int, origem: String) {
    try {
        delay(100)
        println("auditoria $origem do pedido $id concluída")
    } catch (e: CancellationException) {
        println("auditoria $origem do pedido $id cancelada")
        throw e
    }
}

suspend fun pedidoEstruturado(id: Int) {
    coroutineScope {
        launch { auditoria(id, "estruturada") }
    }
}

@OptIn(DelicateCoroutinesApi::class)
fun pedidoGlobal(id: Int) {
    GlobalScope.launch { auditoria(id, "global") }
}

fun main() = runBlocking {
    println("--- coroutineScope: uma falha derruba os irmãos")
    try {
        coroutineScope {
            launch { tarefa("A", 100, false) }
            launch { tarefa("B", 50, true) }
        }
    } catch (e: IllegalStateException) {
        println("escopo falhou com: ${e.message}")
    }

    println("--- supervisorScope: a falha fica no filho")
    supervisorScope {
        launch { tarefa("C", 100, false) }
        launch {
            try {
                tarefa("D", 50, true)
            } catch (e: IllegalStateException) {
                println("D tratou o próprio erro: ${e.message}")
            }
        }
    }
    println("fim do supervisorScope")

    println("--- dono do trabalho: cancelar o pedido")
    val estruturada = launch { pedidoEstruturado(1) }
    delay(10)
    estruturada.cancel()
    estruturada.join()

    println("--- GlobalScope: o trabalho não pertence ao pedido")
    val pedido = launch { pedidoGlobal(2) }
    pedido.join()
    delay(10)
    pedido.cancel()
    delay(200)
    println("fim")
}

A saída real foi:

--- coroutineScope: uma falha derruba os irmãos
A cancelada
escopo falhou com: B falhou
--- supervisorScope: a falha fica no filho
D tratou o próprio erro: D falhou
C concluída
fim do supervisorScope
--- dono do trabalho: cancelar o pedido
auditoria estruturada do pedido 1 cancelada
--- GlobalScope: o trabalho não pertence ao pedido
auditoria global do pedido 2 concluída
fim

No primeiro bloco, o filho B falha em 50 ms. O coroutineScope recebe a exceção, cancela o irmão A, que estava no meio do delay(100), e relança a falha para quem chamou. A linha A cancelada aparece porque A recebeu o cancelamento e o relançou. O escopo é quebrado como um todo, e isso é o que a documentação quer dizer com falha em grupo.

No segundo bloco, o filho D falha da mesma forma, mas o supervisorScope não propaga a falha para cima nem para os irmãos. A documentação descreve o comportamento: o escopo “propagates the cancellation in one direction only and cancels all its children only if it failed itself”. Ele também espera todos os filhos antes de terminar, como o coroutineScope. A falha de D ficou tratada dentro de D, e C terminou normalmente. A figura resume os dois comportamentos.

Falha em coroutineScope e supervisorScope. Filho B → Escopo (falha sobe); Escopo → Filho A (cancelamento); Filho D → Escopo (não sobe)
figura 1 · Falha em coroutineScope e supervisorScope

O grupo de cima mostra o coroutineScope. A seta rosa é a falha do filho B subindo até o escopo, e a seta de cancelamento desce até o filho A. O grupo de baixo mostra o supervisorScope, em que a falha do filho D fica tracejada e não sobe, porque o escopo não a recebe. O filho C segue verde até o fim. A pergunta que a figura responde é quem paga pela falha: no primeiro caso, todo o grupo, e no segundo, só quem falhou.

A escolha entre os dois depende do que o negócio exige. Um conjunto de chamadas que só faz sentido inteiro, como gravar um pedido e seus itens, pede coroutineScope, porque uma parte falha e o resto não deve continuar. Um conjunto de tarefas independentes, como enviar notificações para vários destinatários, pede supervisorScope, porque uma falha num destinatário não deve impedir os outros. Quem escolhe o escopo errado perde uma garantia que achava que tinha.

4. launch, async e o destino das exceções

launch e async são os dois construtores de coroutines filhas mais usados, e tratam exceções de maneira diferente. A documentação diz que os builders vêm em dois tipos: os que propagam exceções automaticamente, como launch, e os que as expõem para quem usa o resultado, como async e produce. Quando o builder é a raiz de uma coroutine, sem pai, launch trata a exceção como não capturada, de forma parecida com o Thread.uncaughtExceptionHandler do Java. O async, por sua vez, guarda a exceção no objeto Deferred, e ela só aparece quando alguém chama await.

Quando a coroutine é filha de um escopo, a regra fica mais simples. Um launch que falha cancela o pai, e o pai cancela os irmãos, como mostrou a seção 3. O async guarda a exceção no Deferred em vez de a deixar escapar sozinha, e ela só aparece para quem chama await. A documentação diz que o async captura todas as exceções e as representa no Deferred. Um async cujo resultado ninguém consome esconde o erro dentro desse objeto, e esse é um erro que costuma passar despercebido. A aula não verificou, nesta execução, se a falha de um async filho cancela o pai da mesma forma que a de um launch, e o exercício 2 existe para que o leitor observe isso.

Como a documentação afirma que a falha de um filho cancela o pai “with that exception” e que esse comportamento “cannot be overridden”, a proteção contra falhas fica no desenho do escopo, e não no tratamento de cada filho. Por isso o supervisorScope existe, e por isso um try dentro de cada launch costuma ser o lugar mais honesto para tratar um erro esperado.

5. GlobalScope: o trabalho sem dono

GlobalScope é um escopo sem Job. A documentação é direta: ele é usado para lançar coroutines de nível superior cujo ciclo de vida não é limitado pela concorrência estruturada, e como não tem Job, não há como cancelar todas as coroutines lançadas nele nem esperar que terminem.

A API marca o GlobalScope com @DelicateCoroutinesApi, e a documentação diz que ele é uma API delicada. Ele é fácil de usar, e por isso mesmo perde todos os benefícios da estrutura. O texto da página de exceções diz que criar uma coroutine raiz para a aplicação inteira é um dos raros usos legítimos, e que por isso é preciso optar explicitamente com @OptIn. A mesma página chama o uso de GlobalScope por iniciantes de antipadrão.

No exemplo da seção 3, a auditoria global continua rodando depois que o chamador foi cancelado. A linha auditoria global do pedido 2 concluída aparece porque o cancel() atinge o Job do pedido, mas esse Job já tinha terminado, e a auditoria não tem pai. Já a auditoria estruturada recebeu o cancelamento e imprimiu cancelada. A figura põe os dois casos lado a lado.

Quem é dono da auditoria. Job do pedido → Auditoria (cancel() desce); Job do pedido → Auditoria (sem vínculo)
figura 2 · Quem é dono da auditoria

Na figura, o grupo de cima é o trabalho lançado dentro de um escopo. A seta de cancelamento desce do job do pedido para a auditoria, e a auditoria para. O grupo de baixo tem o job do pedido retornando logo, e a auditoria em GlobalScope aparece em rosa. A ligação tracejada entre os dois indica ausência de vínculo: a auditoria não tem pai, então nada desce até ela. O leitor deve ver que o que parece uma relação entre pedido e auditoria, no código, não existe no tempo de vida.

Na prática, um GlobalScope.launch dentro de um handler faz a resposta sair antes que o trabalho termine, e o trabalho sobrevive ao cancelamento da requisição. Nenhum código do serviço consegue esperar por ele, porque a documentação diz que não há como esperar as coroutines lançadas no escopo. Se a aplicação for desligada com a auditoria em andamento, o trabalho pendente se perde. Quem usa GlobalScope por preguiça de criar um escopo melhor troca um problema de código por um problema de operação.

6. bordas, erros comuns e boas práticas

O primeiro erro é o GlobalScope em código de produção, principalmente dentro de handlers. O motivo do erro costuma ser a dificuldade de encontrar um escopo para a tarefa, e a boa prática é perguntar quem deve esperar por ela. Se a resposta for a requisição, o trabalho pertence a um escopo da requisição. Se a resposta for o serviço inteiro, ele pertence a um escopo do ciclo de vida da aplicação. Em ambos os casos o escopo é criado de forma explícita e cancelado no fim, sem GlobalScope.

O segundo erro é launch sem tratamento de exceção. Um launch raiz com falha vai para o tratador de exceções não capturadas, e um launch filho cancela o pai. Ninguém do lado de fora vê o erro se nada o observa. A boa prática é tratar falhas esperadas dentro da própria coroutine com try, e deixar as inesperadas subirem para o escopo, onde elas aparecem.

O terceiro erro é achar que supervisorScope torna o código mais seguro. Ele isola a falha de um filho dos irmãos, mas não impede que uma exceção não tratada chegue a um tratador global. Um filho de supervisorScope que falha sem try ainda produz um erro, e ele precisa de um lugar onde seja visto.

O quarto erro é usar NonCancellable dentro de launch ou async para proteger um trabalho do cancelamento. A página de cancelamento avisa que isso quebra a concorrência estruturada, porque separa o filho do pai. A aula Cancelamento cooperativo trata do uso correto de NonCancellable, que fica dentro de um finally.

Uma borda que a aula deixa aberta é a relação entre escopo e framework. Um serviço Spring cria seus escopos de acordo com o que o framework oferece para requisições e para o ciclo de vida da aplicação. Esse ponto depende do framework e da versão, e a fase 5 volta a ele. Esta aula não verificou essa integração.

Pergunta de entrevistaComo você garante que uma coroutine não vaza e continua rodando depois que não é mais necessária?

Uso concorrência estruturada: toda coroutine nasce dentro de um escopo com dono, como o de uma requisição ou o de um serviço. Quando o escopo é cancelado, as filhas são canceladas junto. Evito GlobalScope, porque ela não tem dono, e a tarefa pode continuar depois que quem a iniciou terminou. Se preciso de trabalho que sobrevive ao chamador, deixo isso explícito, com um escopo do serviço cancelado no shutdown. Também coloco timeout em chamadas externas, para que nenhuma espere para sempre.

7. comparação com Go

Go não tem CoroutineScope, e essa falta tem uma consequência direta: nada no runtime obriga uma goroutine a ter dono. Quem escreve go f() cria um trabalho que dura até a função terminar, e a linguagem não guarda nenhuma relação entre esse trabalho e quem o criou. A especificação descreve o efeito do go com a frase “A “go” statement starts the execution of a function call as an independent concurrent thread of control, or goroutine, within the same address space”, ou seja, a chamada passa a executar como uma linha de controle independente, uma goroutine, dentro do mesmo espaço de endereços. Na parte sobre a execução do programa, ela diz que, quando a função main retorna, o programa termina, e “It does not wait for other (non-main) goroutines to complete”, ou seja, não espera pelas outras goroutines.

Por isso, em Go, a concorrência estruturada é um padrão que o próprio código monta. A biblioteca padrão oferece as peças: context para propagar o cancelamento, sync.WaitGroup para esperar, e errors.Join para juntar erros. O programa abaixo monta uma versão com falha em cadeia, parecida com coroutineScope, e outra em que cada falha fica com a tarefa que a produziu, parecida com supervisorScope. Depois ele lança uma auditoria sem dono. Foi compilado e executado com go run, com o Go 1.27.2.

package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"
)

// Cancelamento é cooperativo: a tarefa precisa observar ctx.Done().
func tarefa(ctx context.Context, nome string, atraso time.Duration, falha bool) error {
	select {
	case <-time.After(atraso):
	case <-ctx.Done():
		fmt.Printf("%s cancelada: %v\n", nome, context.Cause(ctx))
		return ctx.Err()
	}
	if falha {
		return fmt.Errorf("%s falhou", nome)
	}
	fmt.Printf("%s concluída\n", nome)
	return nil
}

// Parecido com coroutineScope: a primeira falha cancela o contexto dos irmãos
// e o erro sobe para quem chamou.
func escopoEstruturado(ctx context.Context, tarefas ...func(context.Context) error) error {
	ctx, cancel := context.WithCancelCause(ctx)
	defer cancel(nil)

	var (
		wg      sync.WaitGroup
		mu      sync.Mutex
		primeiro error
	)
	for _, t := range tarefas {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if err := t(ctx); err != nil {
				mu.Lock()
				if primeiro == nil {
					primeiro = err
					cancel(err)
				}
				mu.Unlock()
			}
		}()
	}
	wg.Wait()
	return primeiro
}

// Parecido com supervisorScope: cada falha fica com a tarefa que a produziu,
// e nada cancela os irmãos. Quem chama recebe a lista no fim.
func escopoSupervisor(ctx context.Context, tarefas ...func(context.Context) error) error {
	var (
		wg    sync.WaitGroup
		mu    sync.Mutex
		erros []error
	)
	for _, t := range tarefas {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if err := t(ctx); err != nil {
				mu.Lock()
				erros = append(erros, err)
				mu.Unlock()
			}
		}()
	}
	wg.Wait()
	return errors.Join(erros...)
}

func auditoria(ctx context.Context, origem string, id int) {
	select {
	case <-time.After(100 * time.Millisecond):
		fmt.Printf("auditoria %s do pedido %d concluída\n", origem, id)
	case <-ctx.Done():
		fmt.Printf("auditoria %s do pedido %d cancelada\n", origem, id)
	}
}

func main() {
	fmt.Println("--- escopo estruturado: uma falha derruba os irmãos")
	err := escopoEstruturado(context.Background(),
		func(ctx context.Context) error { return tarefa(ctx, "A", 100*time.Millisecond, false) },
		func(ctx context.Context) error { return tarefa(ctx, "B", 50*time.Millisecond, true) },
	)
	fmt.Println("escopo devolveu:", err)

	fmt.Println("--- supervisor: a falha fica na tarefa")
	err = escopoSupervisor(context.Background(),
		func(ctx context.Context) error { return tarefa(ctx, "C", 100*time.Millisecond, false) },
		func(ctx context.Context) error { return tarefa(ctx, "D", 50*time.Millisecond, true) },
	)
	fmt.Println("supervisor devolveu:", err)

	fmt.Println("--- dono do trabalho: cancelar o pedido")
	pedido, cancelarPedido := context.WithCancel(context.Background())
	feito := make(chan struct{})
	go func() {
		defer close(feito)
		// a auditoria recebe o contexto de quem a criou
		var wg sync.WaitGroup
		wg.Add(1)
		go func() {
			defer wg.Done()
			auditoria(pedido, "estruturada", 1)
		}()
		wg.Wait()
	}()
	time.Sleep(10 * time.Millisecond)
	cancelarPedido()
	<-feito

	fmt.Println("--- sem dono: go sem nenhum contexto ou espera")
	go auditoria(context.Background(), "solta", 2)
	time.Sleep(10 * time.Millisecond)
	fmt.Println("fim")
}
--- escopo estruturado: uma falha derruba os irmãos
A cancelada: B falhou
escopo devolveu: B falhou
--- supervisor: a falha fica na tarefa
C concluída
supervisor devolveu: D falhou
--- dono do trabalho: cancelar o pedido
auditoria estruturada do pedido 1 cancelada
--- sem dono: go sem nenhum contexto ou espera
fim

O escopoEstruturado faz o papel do coroutineScope com peças da biblioteca. A função context.WithCancelCause cria um contexto que pode ser cancelado com uma causa, e a documentação diz que “Calling cancel with a non-nil error (the “cause”) records that error in ctx”. Por isso a tarefa A consegue imprimir B falhou: context.Cause devolve o erro que cancelou o contexto. A documentação de Context também descreve a cascata com a frase “When a Context is canceled, all Contexts derived from it are also canceled”, ou seja, quando um contexto é cancelado, todos os derivados dele também são. Em Go, a cascata desce pelos contextos que o código criou e passou adiante. Em Kotlin, ela desce pela hierarquia de Jobs que o builder herda, sem nenhum código extra.

Dono do trabalho em Kotlin e em Go. pedido → auditoria (cancel() desce); pedido → tarefa A (cancel() desce); pedido → auditoria(ctx) (ctx.Done())
figura 3 · Dono do trabalho em Kotlin e em Go

A figura coloca os dois modelos lado a lado. No grupo do Kotlin, o cancel() do pedido desce até a auditoria e a tarefa A pelas setas da herança, e nenhuma linha precisa ser escrita para isso. No grupo do Go, o contexto do pedido chega à auditoria só porque ela recebeu ctx como parâmetro, e a seta rotulada como ctx.Done() é a única ligação. A auditoria solta, em rosa e tracejada, não tem nenhuma seta de entrada, porque nenhum contexto foi passado a ela. Quem lê a figura deve procurar a seta que falta.

A tarefa precisa cooperar com o cancelamento. A documentação de Done diz que o canal “is closed when work done on behalf of this context should be canceled”, ou seja, é fechado quando o trabalho feito em nome do contexto deve ser cancelado. Se a função tarefa usasse só time.After, o cancelamento seria ignorado e ela esperaria até o fim. Por isso o select aparece ali. No Kotlin, a página de cancelamento diz que “Suspending functions in the kotlinx.coroutines library, such as the delay() function, check for cancellation when they suspend”. Uma tarefa que só chama delay já responde ao cancelamento sem código extra. O custo do modelo explícito do Go é que o compilador não avisa quando um ctx deixou de ser passado. Um parâmetro esquecido produz um cancelamento que nunca chega, e o programa continua compilando.

O escopoSupervisor mostra outra diferença. Em Go, a falha é um valor que a tarefa retorna, e nada a sobe sozinha. O código precisa guardar os erros e devolvê-los, e a função errors.Join faz essa junção. A documentação diz que “Any nil error values are discarded. Join returns nil if every value in errs is nil”, ou seja, valores nil são descartados e a função devolve nil quando todos são nil. A função foi adicionada no Go 1.20. No Kotlin, a exceção de um filho sobe pela hierarquia enquanto o código do meio não a trata, e o supervisorScope apenas muda até onde ela chega.

A última parte do programa lança a auditoria solta com go auditoria(context.Background(), "solta", 2). Essa goroutine não recebe nada de quem a criou, e a saída mostra a consequência: a linha da auditoria não aparece, porque main termina 10 ms depois, antes dos 100 ms que a auditoria espera. Nenhum código do programa consegue cancelá-la de fora. A FAQ oficial do Go diz que as goroutines não expõem nome nem identificador único ao programador, e indica canais para os casos em que uma goroutine precisa ser controlada. Ou seja, o sinal de parada tem de ser observado pela própria goroutine. O Kotlin tem o equivalente no GlobalScope.launch da seção 5, e a diferença aparece no código: o uso exige @OptIn(DelicateCoroutinesApi::class). Essa anotação não muda o comportamento da coroutine. Ela registra, no próprio arquivo, que alguém decidiu aceitar um trabalho sem dono, e um revisor pode procurar por ela. Em Go não existe anotação equivalente. A pista de que uma goroutine não tem dono é a falta de um parâmetro ctx, e o compilador não cobra esse parâmetro.

Falha não tratada separa as duas linguagens de vez. Rodamos um programa em que main tem um defer e uma goroutine executa panic("auditoria quebrou") enquanto main dorme 100 ms. A saída começou com panic: auditoria quebrou, seguida do rastro da goroutine, e o processo terminou com status 2 (o go run mostrou exit status 2). Nem a linha do defer nem o fim apareceram. A especificação da linguagem descreve esse caminho com a frase “At that point, the program is terminated and the error condition is reported, including the value of the argument to panic”. Um panic que nenhuma função recupera derruba o programa inteiro, qualquer que seja a goroutine que o causou. No Kotlin, a seção 4 mostrou que um launch raiz, sem pai, trata a exceção como não capturada. A documentação compara esse tratamento com o Thread.uncaughtExceptionHandler do Java e não diz que o processo cai. Esta aula não executou um caso equivalente em Kotlin, então o comportamento do processo nesse caso fica como não verificado.

Duas ferramentas de Go fazem a ponte com a seção anterior. A partir da versão 1.25, sync.WaitGroup ganhou o método Go, que executa a função numa nova goroutine e soma a tarefa ao contador, de modo que o par Add e Done deixa de ser escrito à mão. A página do pacote diz que Go “calls f in a new goroutine and adds that task to the WaitGroup”. Um programa de teste com três chamadas a wg.Go rodou com go1.27.2 e imprimiu tarefas concluídas: 3. Mesmo assim, o WaitGroup continua só contando, e não cancela nada. O errgroup, do módulo golang.org/x/sync, junta espera, primeiro erro e cancelamento, e não faz parte da biblioteca padrão. Rodamos um exemplo com errgroup.WithContext na versão v0.24.0 do módulo, e a saída foi A cancelada: B falhou seguida de errgroup devolveu: B falhou. A documentação diz que a primeira função que devolve erro não nulo cancela o contexto derivado, e que Wait devolve esse erro. A página do pacote não menciona pânico, e SetLimit, que limita as goroutines ativas, foi lido na documentação mas não executado nesta aula.

Esta seção compara modelos de concorrência, não custos. A aula não mediu o custo de criar uma goroutine contra o de uma coroutine e não apresenta número de desempenho para nenhuma das duas. Qualquer afirmação de que uma é mais barata que a outra precisaria de uma medição que ainda não existe.

Os dois lados compartilham erros parecidos, e a comparação ajuda a enxergá-los. Em Go, o mais comum é o go sem contexto, como na auditoria solta, porque o compilador não exige o parâmetro. Também é comum esquecer o Add antes do go, ou chamar Done mais vezes do que o contador permite. A documentação diz que, se o contador ficar negativo, Add causa pânico. No Kotlin, o erro típico é o GlobalScope e o async sem await, que escondem a falha dentro do Deferred. Nos dois casos a pergunta de revisão é a mesma: quem espera por este trabalho, e quem decide quando ele para.

8. exercícios

  1. Escreva um serviço com três tarefas que buscam dados em paralelo dentro de coroutineScope. Faça a segunda falhar e observe o que acontece com as outras duas. Depois troque para supervisorScope e compare. Escreva uma frase para cada caso dizendo qual comportamento o negócio exige.
  2. Troque o launch da auditoria por async na seção 3 e chame await no escopo do pedido. Observe em que momento a exceção aparece, se ela falhar. Explique por que um async que ninguém espera pode esconder erros.
  3. Reescreva pedidoGlobal sem GlobalScope, recebendo um CoroutineScope como parâmetro. Verifique que o cancelamento do chamador agora chega à auditoria.

9. glossário

CoroutineScope: contexto onde coroutines são criadas. Carrega um Job que define o ciclo de vida delas.

Job: objeto que representa uma coroutine ou um escopo, com estados de ativo, cancelado e concluído.

Concorrência estruturada: princípio de que coroutines filhas ficam contidas no escopo que as criou, e de que o pai só termina com os filhos.

coroutineScope: builder suspend que cria um escopo e só retorna quando os filhos terminam, propagando falhas para cima.

supervisorScope: builder suspend que cria um escopo em que a falha de um filho não cancela os irmãos nem o pai.

GlobalScope: escopo sem Job, marcado como API delicada, que não permite cancelar nem esperar as coroutines lançadas nele.

Deferred: resultado de async, que carrega o valor ou a exceção até await.

fontes

Consultadas em 20/10/2026:

Fontes Go, consultadas em 20/10/2026, com a toolchain go1.27.2. A toolchain local foi usada para executar os exemplos, e as páginas públicas foram lidas para as citações. Como a página pública pode mostrar uma versão mais nova, os trechos citados foram checados contra a documentação conferida nessa data.

  • Especificação da linguagem, https://go.dev/ref/spec. Foram usadas as frases sobre a instrução go, sobre o término do programa quando main retorna e sobre o término por panic não recuperado.
  • Perguntas frequentes, https://go.dev/doc/faq. Foram usadas as frases sobre goroutines não terem nome nem identificador exposto ao programador, e a indicação de canais para goroutines especiais.
  • Pacote context, https://pkg.go.dev/context. Foram usadas as descrições de WithCancelCause, Cause e Done, e a frase sobre a cascata de cancelamento.
  • Pacote sync, https://pkg.go.dev/sync. Foram usadas as descrições de WaitGroup.Go (adicionado na versão 1.25), Add, Done e Wait.
  • Pacote errors, https://pkg.go.dev/errors. Foi usada a descrição de Join, adicionado na versão 1.20.
  • Pacote errgroup, https://pkg.go.dev/golang.org/x/sync/errgroup. Foram usadas as descrições de WithContext, Go e Wait, e a descrição de SetLimit, que não foi executada. A página mostra a versão v0.24.0 como não sendo a mais recente do módulo, e o exemplo foi executado com essa versão.

Ambiente dos exemplos: kotlinc 2.1.21, kotlinx-coroutines-core-jvm 1.10.2, JDK Temurin 21.0.8. A saída foi repetida duas vezes e não mudou. Os exemplos em Go rodaram com go run na toolchain go1.27.2 (darwin/arm64). O exemplo de errgroup rodou num módulo separado, com golang.org/x/sync v0.24.0.

Não verificado: a integração entre CoroutineScope e os handlers do Spring Boot. A aula não executou nada com Spring. O caso de panic não recuperado em Kotlin também não foi executado, e o comportamento do processo nesse caso não foi verificado. O método SetLimit do errgroup não foi executado.

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
?