Pular para o conteúdo

Trilha de Kotlin · parte 26 de 59

Cancelamento cooperativo

Aula da fase 4 da trilha de Kotlin: Por que um `catch` genérico quebra o cancelamento, e como `ensureActive` e `NonCancellable` tratam a limpeza?

intermediárioPublicada em · 21 min de leitura

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

Imagine que você pede a um pedreiro para parar a obra. Ele não congela no meio de uma parede, e também não desliga o cérebro na hora. Ele termina o movimento que estava fazendo, guarda as ferramentas no próximo intervalo e vai embora. O cancelamento de uma coroutine funciona da mesma forma. Chamar cancel() não interrompe o código à força: envia um sinal, e a coroutine só para quando chega a um ponto em que pode parar, que é um ponto de suspensão ou uma verificação explícita. Se o código não coopera, ele continua rodando até o fim, como o pedreiro que ignora o pedido e termina a parede.

Esta aula trata desse modelo e de suas consequências. Vamos ver um loop de CPU que ignora o cancelamento, a forma de verificar o sinal com ensureActive, o efeito de um catch genérico que engole a exceção de cancelamento, a diferença entre um finally com e sem NonCancellable, e o withTimeout. 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 princípio do cancelamento cooperativo, com a citação da documentação. Depois vemos um loop que não coopera e outro que coopera, com a saída real de cada um. A seção 3 trata do catch genérico, que é o erro mais comum e o mais difícil de notar em revisão. A seção 4 mostra a hierarquia de jobs, com a figura correspondente. A 5 cuida da limpeza com finally e NonCancellable, e a 6 do withTimeout. A seção 7 reúne os erros que um revisor procura. A 8 compara o modelo com o cancelamento de Go, com trechos executados nas duas linguagens.

1. o cancelamento é cooperativo

A documentação oficial é direta sobre o modelo: “In Kotlin, coroutine cancellation is cooperative. Coroutines react to cancellation only when they cooperate by suspending or checking for cancellation explicitly.” Em português, o cancelamento no Kotlin é cooperativo, e as coroutines só reagem quando cooperam, seja suspendendo, seja verificando o cancelamento explicitamente.

A mesma página explica o que acontece depois do pedido. Quando uma coroutine é cancelada, ela continua rodando até chegar a um ponto em que pode suspender. Se a suspensão acontece ali, a função suspensa verifica o cancelamento e, se ele existe, a coroutine para e lança CancellationException. Portanto, o cancelamento só tem efeito quando existe um ponto de verificação, e é isso que um loop de CPU puro não tem.

Essa escolha existe por um motivo de projeto. Interromper uma thread no meio de qualquer instrução deixaria estruturas de dados em estado inconsistente, e o sistema não saberia em que ponto parar sem deixar o código pela metade. Ao exigir cooperação, a biblioteca deixa o código decidir os pontos seguros de parada. O custo é que quem escreve um loop longo precisa lembrar de verificar.

2. loop sem check e loop com ensureActive

O primeiro teste roda um loop de 200 milhões de iterações sem nenhuma verificação. Depois de 10 ms, a coroutine recebe cancel(). O segundo teste faz o mesmo com um loop que chama ensureActive() a cada iteração. Os dois fazem parte de um mesmo programa de sete casos, executado de uma vez, e os trechos abaixo são as partes desse programa que tratam do cancelamento:

val semCheck = launch(Dispatchers.Default) {
    while (i < 200_000_000L) { i++ }
}
delay(10)
semCheck.cancel()
semCheck.join()
println("terminou com i = $i, ativo? ${semCheck.isActive}, cancelado? ${semCheck.isCancelled}")

val comCheck = launch(Dispatchers.Default) {
    while (j < 2_000_000_000L) {
        ensureActive()
        j++
    }
}
delay(10)
comCheck.cancel()
comCheck.join()
println("parou antes do limite: ${j < 2_000_000_000L}")

A saída real foi:

terminou com i = 200000000, ativo? false, cancelado? true
parou antes do limite: true

No primeiro caso, o loop rodou até o fim, e i chegou a 200 000 000, mesmo com o cancelamento pedido no meio. A coroutine ficou com isCancelled verdadeiro e isActive falso, o que mostra que o estado foi registrado, mas o trabalho não parou. O cancelamento só produziu efeito no join, que esperou o fim do loop.

No segundo caso, ensureActive() lança CancellationException assim que o sinal está presente, e o loop para antes do limite de 2 bilhões. A documentação descreve ensureActive() como a função que lança CancellationException quando a coroutine está cancelada, e isActive como a propriedade que retorna false nesse caso.

Na prática, um loop de processamento de arquivo, de cálculo ou de transformação de lista precisa de um ponto de verificação a cada passo ou a cada bloco de passos. A escolha entre os dois depende do custo da verificação. Um ensureActive em um loop apertado tem custo, e o código pode verificar a cada mil iterações, desde que o intervalo seja pequeno o bastante para o cancelamento ser percebido. Esta aula não mediu esse custo, e o intervalo ideal fica a critério de quem escreve o código.

3. catch genérico que engole o cancelamento

O erro mais comum nesta área é o catch genérico. A exceção de cancelamento é uma subclasse de Exception, e por isso um catch (e: Exception) a captura. Se o código captura e não relança, o cancelamento desaparece, e a coroutine continua como se nada tivesse acontecido.

O teste abaixo põe um delay(1000) dentro de um try com catch (e: Exception). O cancelamento chega em 10 ms.

val engole = launch {
    try {
        delay(1_000)
    } catch (e: Exception) {
        println("capturei ${e::class.simpleName}")
    }
    println("a coroutine continuou depois do cancelamento")
}
delay(10)
engole.cancel()
engole.join()

A saída real foi:

capturei JobCancellationException
a coroutine continuou depois do cancelamento

A exceção que chegou ao catch tem o nome JobCancellationException, que é o tipo lançado por cancel() nesta execução. O ponto que importa é que ela foi capturada por catch (e: Exception), o que prova que ela é uma Exception. A linha seguinte ao try rodou, e a coroutine seguiu em frente depois de ter sido cancelada. Qualquer trabalho posterior ao catch continuaria a acontecer, e o pai ainda esperaria por essa coroutine.

A correção é capturar a exceção de cancelamento, registrar se for preciso e relançá-la. A saída abaixo vem de um teste separado com a mesma estrutura:

val relanca = launch {
    try {
        delay(1_000)
    } catch (e: CancellationException) {
        println("cancelamento visto, relançando")
        throw e
    } catch (e: Exception) {
        println("erro de verdade")
    }
    println("esta linha não deveria aparecer")
}
delay(10)
relanca.cancel()
relanca.join()

A saída real foi:

cancelamento visto, relançando

A linha esta linha não deveria aparecer não foi impressa. O throw e deixou o cancelamento subir, e o catch (e: Exception) nem chegou a ser usado para a exceção de cancelamento. A figura compara os dois caminhos.

Quem captura o cancelamento. cancel() → catch da coroutine; catch da coroutine → Engole (catch (e: Exception)); catch da coroutine → Relança (throw e)
figura 1 · Quem captura o cancelamento

A figura começa no cancel(), que faz o delay lançar a exceção. O losango representa o catch, e a escolha entre os dois caminhos está na forma do catch. À esquerda, o catch (e: Exception) sem relançamento engole o cancelamento e deixa o código seguinte rodar, em rosa. À direita, o catch que relança com throw e mantém o cancelamento vivo, em verde. Quem lê deve notar que a diferença é uma linha, e que o código em si parece quase igual nos dois casos.

Esse erro também aparece em código que não usa try diretamente. Uma função de biblioteca que captura Throwable para registrar falhas faz o mesmo. A nota Tratamento funcional de erros mostra que runCatching captura Throwable, incluindo o CancellationException das coroutines. Ao redor de uma chamada suspend, ele é uma causa silenciosa desse mesmo problema.

4. cancelamento desce pela hierarquia

Uma coroutine filha herda o job do pai, e a relação funciona nos dois sentidos para cancelamento. Quando o pai é cancelado, o cancelamento desce para todos os filhos e para os netos, e o pai só termina quando todos os filhos terminam. Quando um filho falha com uma exceção que não é de cancelamento, a falha sobe até o pai, que cancela os irmãos, como mostrou a aula sobre CoroutineScope.

A figura abaixo mostra a descida do cancelamento em uma árvore com dois filhos e dois netos. Ela também mostra o que cada neto faz com o sinal, porque o resultado depende de como cada um reage.

Cancelar o pai cancela a árvore. Job pai → Filho 1; Job pai → Filho 2; Filho 1 → Neto em finally (cancelado); Filho 2 → Neto com catch genérico (cancelado)
figura 2 · Cancelar o pai cancela a árvore

O job pai está no topo, e o cancelamento sai dele para os dois filhos ao mesmo tempo. Cada filho repassa o sinal ao seu neto. O neto da esquerda tem o finally com NonCancellable, que roda a limpeza mesmo depois do cancelamento, e aparece em verde. O neto da direita tem um catch genérico, e aparece em rosa, porque engole o sinal e deixa o trabalho do neto seguir. O leitor deve seguir o caminho de cima para baixo e perceber que a hierarquia decide quem recebe o cancelamento, e que o código de cada nó decide o que ele faz com o recebido.

Um detalhe que a figura não mostra é que o pai espera pelos filhos antes de terminar. Se um neto engole o cancelamento e continua trabalhando, o pai fica esperando por esse trabalho, e o join no pai não retorna enquanto o neto não terminar. Esse problema pode aparecer como um encerramento que demora mais do que deveria, porque o join do pai espera um trabalho que ignorou o sinal.

Pergunta de entrevistaPor que um catch genérico pode quebrar o cancelamento?

O cancelamento é cooperativo: a coroutine só para num ponto de suspensão ou quando checa isActive ou ensureActive. O cancelamento aparece como CancellationException, que é uma exceção. Um catch (e: Exception) a captura, e o código continua como se nada tivesse acontecido. Por isso capturo só o que sei tratar, e relanço CancellationException quando capturo algo genérico. Para limpeza, uso finally, e withContext(NonCancellable) quando a limpeza precisa suspender.

5. limpeza com finally e NonCancellable

O finally roda quando a coroutine é cancelada, porque o cancelamento é uma exceção e o finally roda sempre. O problema é que o finally roda já no estado cancelado. Se ele chama uma função suspend, como delay ou um close() suspenso, essa chamada lança CancellationException de novo, e a limpeza é interrompida no meio. A documentação de NonCancellable descreve o caso: ele é útil quando operações como fechar recursos com um close() suspenso precisam terminar mesmo que a coroutine tenha sido cancelada antes.

O teste abaixo compara os dois casos. No primeiro, o finally chama delay(10) sem proteção. No segundo, ele usa withContext(NonCancellable).

val semProtecao = launch {
    try {
        delay(1_000)
    } finally {
        delay(10)
        println("limpeza sem NonCancellable")
    }
}
delay(10)
semProtecao.cancel()
semProtecao.join()

val comProtecao = launch {
    try {
        delay(1_000)
    } finally {
        withContext(NonCancellable) {
            delay(10)
            println("limpeza concluída com NonCancellable")
        }
    }
}
delay(10)
comProtecao.cancel()
comProtecao.join()

A saída real foi:

limpeza concluída com NonCancellable

A linha limpeza sem NonCancellable não aparece. O delay(10) dentro do finally lançou o cancelamento de novo, e o println seguinte nunca rodou. O segundo bloco imprimiu a linha, porque withContext(NonCancellable) executa o bloco sem que o cancelamento do pai o interrompa.

A página de cancelamento da documentação traz um aviso sobre esse recurso, em inglês: “Avoid using NonCancellable with other coroutine builders like .launch() or .async(). Doing so disrupts structured concurrency by breaking the parent-child relationship.” Em português, NonCancellable deve ser usado com withContext, num trecho curto de limpeza, e nunca como escopo de launch ou async, porque isso quebra a relação entre pai e filho.

6. withTimeout

withTimeout cancela um bloco quando o tempo passa do limite e lança TimeoutCancellationException. O teste usa um limite de 50 ms para um delay de mil:

try {
    withTimeout(50) { delay(1_000) }
} catch (e: TimeoutCancellationException) {
    println("${e.message}")
}

A saída real foi Timed out waiting for 50 ms. O withTimeout é um caso de cancelamento com nome próprio. Ele usa o mesmo mecanismo que o cancel() manual, e a exceção lançada é um tipo específico de cancelamento, por isso não deve ser engolida por um catch genérico. Quem captura Exception ao redor de um withTimeout perde o timeout como se fosse uma falha qualquer.

A página de cancelamento consultada menciona withTimeoutOrNull como a forma de expirar um bloco, e não descreve withTimeout. O comportamento de withTimeout aqui foi verificado na execução, e não na página.

7. erros comuns e boas práticas

O primeiro erro é o catch (e: Exception) que não relança. A lista de sinais de alerta do currículo traz esse caso, e ele é difícil de notar, porque o código parece tratar a falha de forma responsável. A boa prática é capturar só as exceções que o código sabe tratar e, quando a exceção de cancelamento aparecer, relançá-la com throw e.

O segundo erro é o loop de CPU sem verificação em uma coroutine que pode ser cancelada. O cancelamento parece funcionar, porque o cancel() retorna, mas o trabalho continua até o fim. A boa prática é verificar com ensureActive() no ponto de passo do loop, ou usar uma função suspensa dentro do laço.

O terceiro erro é o finally com função suspensa sem NonCancellable. A limpeza é interrompida pelo próprio cancelamento, e o recurso fica aberto. A boa prática é envolver a limpeza em withContext(NonCancellable) e deixar o bloco curto.

O quarto erro é o NonCancellable dentro de launch. Ele parece resolver o problema de terminar um trabalho, mas separa o filho do pai e tira a garantia da concorrência estruturada. A boa prática é usar NonCancellable só dentro de um finally.

O quinto erro é esquecer que runCatching captura Throwable, incluindo o cancelamento. A boa prática é usar runCatching só para falhas de negócio conhecidas, longe de chamadas suspend.

8. comparação com Go

Go também tem cancelamento cooperativo, e essa parte é igual nas duas linguagens. Nenhuma delas oferece uma forma segura de encerrar uma goroutine ou uma coroutine vinda de fora. A documentação de runtime.Goexit deixa isso claro pelo escopo da função: ela “terminates the goroutine that calls it. No other goroutine is affected.” Uma goroutine só encerra a si mesma, e o pedido de parada depende do código que está rodando. O que muda é o caminho do sinal, e essa diferença é a que pesa na prática.

Em Go, o sinal é um valor. O pacote context define a interface Context, cujo método Done() devolve um canal que é fechado quando o trabalho deve parar, e Err() informa o motivo com context.Canceled ou context.DeadlineExceeded. Para reagir ao cancelamento, a função precisa ter o contexto em mãos, e ele chega como parâmetro. No Kotlin, o sinal segue a hierarquia de jobs que o escopo montou. Um launch dentro de um CoroutineScope vira filho, e o cancelamento do pai desce até ele sem que a função receba nada. Um trabalho Kotlin pode ser cancelado mesmo sem parâmetro algum. Em Go, o mesmo trabalho só para se alguém passou o contexto até ele, e um parâmetro que deixou de ser repassado desconecta o trabalho sem aviso.

A diferença tem uma causa de desenho. A árvore de jobs exige um objeto dono dos filhos, que é o escopo, e o launch faz o registro automaticamente. Em Go, o contexto é um valor comum, que pode ser guardado em uma struct, passado a uma API ou derivado sem nenhum objeto que o possua. Essa flexibilidade é o que obriga o parâmetro explícito. O custo é que a ligação entre pai e filho depende de quem escreveu cada chamada. Esta explicação é uma leitura do desenho, porque a documentação consultada não formula essa justificativa.

Go também forma uma árvore de contextos. A documentação diz que WithCancel devolve um contexto derivado que “points to the parent context”, e a nota Context descreve como o cancelamento desce pelo mapa de filhos. A árvore, porém, se constrói por derivação explícita, e não por launch. A biblioteca padrão também não transforma falha em cancelamento. Quando uma goroutine falha, o código precisa chamar cancel por conta própria. O comportamento da seção 4, em que a falha de um filho cancela os irmãos, aparece em Go apenas no errgroup de golang.org/x/sync, cuja documentação diz que “The first goroutine in the group that returns a non-nil error will cancel the associated Context, if any.” Em Kotlin a regra vem do escopo. Em Go, ela vem de um pacote fora da biblioteca padrão.

O equivalente do catch que engole aparece de outra forma, porque Go não tem exceções. A função que recebe o sinal e devolve nil faz o mesmo papel: o cancelamento some e o chamador acredita que tudo deu certo. O trecho abaixo vem do programa executado com go run, na versão go1.27.2.

// Equivalente ao catch que engole: recebe o sinal e devolve nil.
func engole(ctx context.Context) error {
	<-ctx.Done()
	fmt.Println("  cancelamento recebido e ignorado")
	return nil
}

// Versão correta: devolve o motivo do cancelamento ao chamador.
func propaga(ctx context.Context) error {
	<-ctx.Done()
	fmt.Println("  cancelamento visto, propagando")
	return ctx.Err()
}

Com o contexto já cancelado, a saída de engole foi cancelamento recebido e ignorado seguido de erro retornado: <nil>. A de propaga foi cancelamento visto, propagando seguido de erro retornado: context canceled. A diferença de Go é que o erro é um valor, e o chamador pode testá-lo. O erro continua possível, porque um nil devolvido sem checagem também apaga o sinal, só que aí o rastro fica no valor de retorno, e não num catch escondido.

O laço que ignora o sinal também tem equivalente direto. Em Go, a checagem de ctx.Err() faz o papel do ensureActive() da seção 2. O programa abaixo cancelou o contexto antes de começar o laço, para que a saída fosse a mesma em todas as execuções.

func semCheck(ctx context.Context, limite int) int {
	i := 0
	for i < limite {
		i++
	}
	return i
}

func comCheck(ctx context.Context, limite int) (int, error) {
	i := 0
	for i < limite {
		if err := ctx.Err(); err != nil {
			return i, err
		}
		i++
	}
	return i, nil
}

A saída foi sem verificação: i = 200000000, chegou ao limite? true e com verificação: i = 0, erro = context canceled. O laço sem consulta ao contexto rodou até o fim, como no Kotlin. Vale notar que o runtime de Go consegue retirar uma goroutine do processador no meio de um laço, desde a versão 1.14, segundo as notas dessa versão. Essa preempção serve ao escalonador e não cancela contexto nenhum, então um laço que não consulta ctx continua até o limite. Em Java, a JEP 444 diz que o escalonador de virtual threads “does not currently implement time sharing”, o que é outro assunto e aparece na aula Virtual threads e testes com runTest.

A limpeza tem uma inversão. No Kotlin, um delay dentro do finally é cancelado no próprio ponto de suspensão, e por isso a seção 5 precisa de NonCancellable. Em Go, o defer roda até o fim do seu próprio código, e nenhuma chamada dentro dele é interrompida pelo contexto, porque não existe ponto de suspensão que o cancelamento tome. O perigo aparece quando a limpeza consulta o contexto e decide pular o trabalho, como no primeiro caso abaixo. Para evitar isso, context.WithoutCancel cria um contexto derivado que, segundo a documentação, “is not canceled when parent is canceled”, e que “returns no Deadline or Err”.

func limpezaSemProtecao(ctx context.Context) {
	defer func() {
		select {
		case <-ctx.Done():
			fmt.Println("  limpeza pulada:", ctx.Err())
		default:
			fmt.Println("  limpeza concluída")
		}
	}()
	<-ctx.Done()
}

func limpezaComProtecao(ctx context.Context) {
	defer func() {
		limpeza := context.WithoutCancel(ctx)
		fmt.Println("  limpeza concluída, Err do contexto de limpeza:", limpeza.Err())
	}()
	<-ctx.Done()
}

Com um contexto cancelado, a primeira saída foi limpeza pulada: context canceled, e a segunda foi limpeza concluída, Err do contexto de limpeza: <nil>. A documentação de WithoutCancel diz que o contexto derivado aponta para o pai, mas não afirma em frase própria que os valores do pai continuam acessíveis. Esta aula não testou isso.

O WithTimeout de Go tem o papel do withTimeout da seção 6, com uma diferença de forma. O prazo vencido aparece como context.DeadlineExceeded, um valor que o código compara, e não como uma exceção que é capturada. A documentação separa os dois casos: DeadlineExceeded é devolvido por Err quando o prazo passou, e Canceled quando o cancelamento veio por outro motivo. Não rodei um exemplo de WithTimeout nesta aula, então a comparação fica apenas com o que a documentação diz.

Os erros mais comuns seguem caminhos diferentes em cada linguagem. Em Go, o erro mais frequente é o parâmetro de contexto substituído por context.Background() no meio de uma cadeia de chamadas, o que desconecta aquele trabalho do cancelamento da requisição, exatamente o desperdício que a nota Context descreve. O segundo é devolver nil depois de receber ctx.Done(), ou esperar em um select sem incluir o ctx.Done(). Em Kotlin, o erro mais frequente é o catch genérico da seção 3 e o laço sem ensureActive() da seção 2. Nas duas linguagens o sinal chega, e o erro está em quem o recebe e decide o que fazer com ele.

Para revisar um serviço concreto, vale procurar os dois padrões em Go e em Kotlin. Em Go, procure funções que recebem ctx e nunca o usam, e chamadas que criam context.Background() onde já existe um contexto da requisição. Em Kotlin, procure catch (e: Exception) ao redor de chamadas suspend e laços longos sem nenhum ponto de verificação.

9. exercícios

  1. Modifique o loop de CPU da seção 2 para verificar o cancelamento a cada mil iterações em vez de a cada uma. Compare se o i final muda em relação à versão com ensureActive a cada passo, e explique o que isso significa para o tempo de resposta ao cancelamento.
  2. Crie uma coroutine com try { delay(1000) } catch (e: Exception) { } e cancele. Verifique a saída e depois corrija o código para que o cancelamento volte a subir.
  3. Escreva uma função suspend que simula fechar um recurso com delay(50) no finally. Chame-a dentro de uma coroutine cancelada, uma vez sem NonCancellable e outra com. Anote a diferença na saída.

10. glossário

Cancelamento cooperativo: modelo em que a coroutine só para em pontos de suspensão ou quando verifica o cancelamento.

CancellationException: exceção que sinaliza o cancelamento de uma coroutine. A documentação diz que ela é lançada no próximo ponto de verificação.

ensureActive: função que lança CancellationException se a coroutine atual já foi cancelada.

isActive: propriedade que retorna falso quando a coroutine foi cancelada ou concluída.

NonCancellable: job especial usado com withContext para executar um trecho sem ser afetado pelo cancelamento do pai.

withTimeout: função que cancela um bloco quando o tempo limite é atingido, lançando TimeoutCancellationException.

fontes

Consultadas em 20/10/2026:

A página https://kotlinlang.org/docs/cancellation-and-timeouts.html retornou conteúdo vazio na consulta, e nenhuma afirmação desta aula depende dela. A justificativa de projeto da seção 1, sobre interromper uma thread no meio de uma instrução, é explicação geral e não é citação de documentação.

Comparação com Go (seção 8), consultadas em 20/10/2026:

  • Pacote context, https://pkg.go.dev/context. Foram usados os textos de Done, Err, WithCancel, WithoutCancel (adicionado no go1.21.0), Canceled e DeadlineExceeded.
  • Pacote runtime, função Goexit, conferida com go doc runtime.Goexit na toolchain local. A frase diz que ela encerra só a goroutine que a chama.
  • Pacote errgroup, https://pkg.go.dev/golang.org/x/sync/errgroup, para a regra de cancelar o contexto do grupo no primeiro erro. A página mostra a versão v0.24.0 e avisa que não é a mais recente do módulo.
  • Notas da versão Go 1.14, https://go.dev/doc/go1.14, seção Runtime, sobre a preempção assíncrona de goroutines.
  • Documentação local da toolchain, obtida com go doc e com a especificação em doc/go_spec.html da distribuição. As frases sobre envio em canal fechado, sobre o canal nil e sobre a instrução go foram conferidas nesse arquivo.

Os trechos de Go foram compilados e executados com go run na versão go1.27.2 (go version go1.27.2 darwin/arm64). A página pública do pacote mostra go1.27.3, mas a toolchain instalada na máquina era a 1.27.2. Não verificado em Go: o comportamento de WithoutCancel com valores do contexto pai, a execução de WithTimeout e a de errgroup, que a aula descreve pela documentação e não pela execuçã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.

Não verificado: o custo de ensureActive em loops apertados. A aula não mediu nenhum desempenho.

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
?