Cancelamento em coroutines

Tempo de leitura: 7 minutes

No desenvolvimento, como na vida, sabemos que é importante evitar fazer mais trabalho do que o necessário, pois isso pode desperdiçar memória e energia. Este princípio também se aplica às coroutines. Você precisa ter certeza de controlar a vida da coroutine e cancelá-la quando não for mais necessária – isso é o que a simultaneidade estruturada representa. Continue lendo para descobrir os prós e contras do cancelamento da coroutine.

 

Cancelamento de chamada

Ao lançar várias corrotinas, pode ser difícil controlá-las ou cancelar cada uma individualmente. Em vez disso, podemos confiar no cancelamento de todas as corrotinas do escopo, pois isso cancelará todas as corrotinas filhas criadas:

// suponha que temos um escopo definido para esta camada do aplicativo

val job1 = scope.launch { … }
val job2 = scope.launch { … }

scope.cancel()

Cancelar o escopo cancela seus filhos

Às vezes, pode ser necessário cancelar apenas uma coroutine, talvez como uma reação a uma entrada do usuário. Chamar job1.cancel garante que apenas aquela coroutine específica seja cancelada e todos os outros irmãos não sejam afetados:

// suponha que temos um escopo definido para esta camada do aplicativo

val job1 = scope.launch { … }
val job2 = scope.launch { … }

// A primeira coroutine será cancelada e a outra não será afetada
job1.cancel()

 

Uma criança cancelada não afeta outros irmãos

As Coroutines tratam do cancellation lançando uma exceção especial: CancellationException. Se desejar fornecer mais detalhes sobre o motivo do cancelamento, você pode fornecer uma instância de CancellationException ao chamar .cancel, pois esta é a assinatura de método completa:

fun cancel(cause: CancellationException? = null)

Se você não fornecer sua própria instância de CancellationException, um padrão CancellationException será criado (código completo aqui):

public override fun cancel(cause: CancellationException?) {
    cancelInternal(cause ?: defaultCancellationException())
}

Como a CancellationException é lançada, você poderá usar esse mecanismo para lidar com o cancelamento da coroutine. Mais sobre como fazer isso na seção Tratamento dos efeitos colaterais do cancelamento abaixo.

Nos bastidores, o job filho notifica seus pais sobre o cancelamento por meio da exceção. O pai usa a causa do cancelamento para determinar se precisa tratar a exceção. Se a criança foi cancelada devido a CancellationException, nenhuma outra ação será necessária para o pai.

Depois de cancelar um escopo, você não poderá lançar novas coroutines no escopo cancelado.

Se estiver usando as bibliotecas androidx KTX, na maioria dos casos, você não cria seus próprios escopos e, portanto, não é responsável por cancelá-los. Se você estiver trabalhando no escopo de um ViewModel, usando viewModelScope ou, se quiser lançar corrotinas vinculadas a um escopo de ciclo de vida, você usaria o lifecycleScope. Tanto viewModelScope quanto lifecycleScope são objetos CoroutineScope que são cancelados no momento certo. Por exemplo, quando ViewModel é limpo, ele cancela as corrotinas lançadas em seu escopo.

 

Por que meu trabalho de coroutine não está parando?

Se apenas chamarmos de cancelar, isso não significa que o trabalho de coroutine irá simplesmente parar. Se você estiver realizando alguns cálculos relativamente pesados, como a leitura de vários arquivos, não há nada que interrompa automaticamente a execução do seu código.

Vamos dar um exemplo mais simples e ver o que acontece. Digamos que precisamos imprimir “Olá” duas vezes por segundo usando coroutines. Vamos deixar a coroutine funcionar por um segundo e depois cancelá-la. Uma versão da implementação pode ser assim:

import kotlinx.coroutines.*
 
fun main(args: Array<String>) = runBlocking<Unit> {
   val startTime = System.currentTimeMillis()
    val job = launch (Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (i < 5) {
            // print a message twice a second
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("Hello ${i++}")
                nextPrintTime += 500L
            }
        }
    }
    delay(1000L)
    println("Cancel!")
    job.cancel()
    println("Done!")
}

Vamos ver o que acontece passo a passo. Ao chamar o lançamento, estamos criando uma nova coroutine no estado ativo. Estamos deixando a coroutine funcionar por 1000 ms. Então, agora vemos impresso:

Hello 0
Hello 1
Hello 2

Depois que job.cancel é chamado, nossa coroutine passa para o estado Canceling. Mas então, vemos que Hello 3 e Hello 4 são impressos no terminal. Somente após a conclusão do trabalho, a coroutine passa para o estado Cancelado.

O trabalho da coroutine não para quando o cancelamento é chamado. Em vez disso, precisamos modificar nosso código e verificar se a coroutine está ativa periodicamente.

O cancelamento do código de coroutine precisa ser cooperativo!

Tornando seu trabalho de coroutine cancelável

Você precisa se certificar de que todo o trabalho de co-rotina que você está implementando é cooperativo com o cancelamento, portanto, você precisa verificar o cancelamento periodicamente ou antes de iniciar qualquer trabalho de longa duração. Por exemplo, se você estiver lendo vários arquivos do disco, antes de começar a ler cada arquivo, verifique se a coroutine foi cancelada ou não. Assim, você evita fazer trabalho intensivo de CPU quando ele não é mais necessário.

val job = launch {
    for(file in files) {
        // TODO check for cancellation
        readFile(file)
    }
}

Todas as funções de suspensão de kotlinx.coroutines são canceláveis: withContext, delay etc. Portanto, se você estiver usando qualquer uma delas, não precisa verificar o cancelamento e interromper a execução ou lançar um CancelamentoException. Mas, se você não os estiver usando, para tornar seu código de coroutine cooperativo, temos duas opções:

  • Verificando job.isActive ou verifyActive()
  • Deixe outro trabalho acontecer usando yield()

Verificando o estado ativo do trabalho

Uma opção está em nosso while(i <5) para adicionar outra verificação para o estado de coroutine:

// Como estamos no bloco de lançamento, temos acesso a job.isActive
while (i < 5 && isActive)

Isso significa que nosso trabalho só deve ser executado enquanto a coroutine estiver ativa. Isso também significa que, quando estivermos fora do tempo, se quisermos fazer alguma outra ação, como registrar se o trabalho foi cancelado, podemos adicionar um cheque para !IsActive e fazer nossa ação lá.

A biblioteca Coroutines fornece outro método útil – ensureActive(). Sua implementação é:

fun Job.ensureActive(): Unit {
    if (!isActive) {
         throw getCancellationException()
    }
}

Como esse método é lançado instantaneamente se o trabalho não estiver ativo, podemos tornar isso a primeira coisa que fazemos em nosso loop while:

while (i < 5) {
    ensureActive()
    …
}

Usando o ensureActive, você evita implementar a instrução if exigida pelo isActive, diminuindo a quantidade de código clichê que você precisa escrever, mas perde a flexibilidade para executar qualquer outra ação, como o registro.

 

Deixe outro trabalho acontecer usando yield()

Se o trabalho que você está fazendo é 1) pesado na CPU, 2) pode esgotar o pool de threads e 3) você deseja permitir que o thread faça outro trabalho sem ter que adicionar mais threads ao pool, então use yield(). A primeira operação feita por yield verificará a conclusão e sairá da coroutine lançando CancellationException se o trabalho já estiver concluído. yield pode ser a primeira função chamada na verificação periódica, como ensureActive() mencionado acima.

 

Cancelamento de Job.join vs Deferred.await

Existem duas maneiras de esperar por um resultado de uma coroutine: jobs retornados do launch podem chamar join e Deferred (um tipo de Job) retornado de async pode ser await.

Job.join suspende uma coroutine até que o trabalho seja concluído. Junto com job.cancel, ele se comporta conforme o esperado:

  • Se você estiver chamando job.cancel e depois job.join, a coroutine será suspensa até que o trabalho seja concluído.
  • Chamar job.cancel após job.join não tem efeito, pois o trabalho já foi concluído.

UseDeferred quando estiver interessado no resultado da coroutine. Esse resultado é retornado por Deferred.await quando a coroutine é concluída. Deferred é um tipo Job e também pode ser cancelado.

Chamar await em um adiado que foi cancelado lança uma JobCancellationException.

val deferred = async {…} 

deferred.cancel()
val result = deferred.await() // lança JobCancellationException!

É por isso que obtemos a exceção: o papel de await é suspender a coroutine até que o resultado seja calculado; uma vez que a coroutine é cancelada, o resultado não pode ser calculado. Portanto, a chamada de await após cancelar leva a JobCancellationException: Job foi cancelado.

Por outro lado, se você estiver chamando deferred.cancel após deferred.await, nada acontece, pois a coroutine já está concluída.

 

Lidando com efeitos colaterais de cancelamento

Digamos que você deseja executar uma ação específica quando uma coroutine é cancelada: fechar quaisquer recursos que você possa estar usando, registrar o cancelamento ou algum outro código de limpeza que deseja executar. Existem várias maneiras de fazer isso:

Verifique se há! IsActive

Se você verificar isActive periodicamente, quando sair do loop while, poderá limpar os recursos. Nosso código acima pode ser atualizado para:

while (i < 5 && isActive) {
   // imprime uma mensagem duas vezes por segundo
    if (…) {
        println(“Hello ${i++}”)
        nextPrintTime += 500L
    }
}

// o trabalho de coroutine é concluído para que possamos limpar
println(“Clean up!”)

Veja em ação aqui.

Então, agora, quando a coroutine não estiver mais ativa, o while vai parar e podemos fazer nossa limpeza.

 

Tente pegar finalmente

Uma vez que uma CancellationException é lançada quando uma co-rotina é cancelada, então podemos encerrar nosso trabalho de suspensão em try/catch e no bloco finally, podemos implementar nosso trabalho de limpeza.

val job = launch {
   try {
      work()
   } catch (e: CancellationException){
      println(“Work cancelled!”)
    } finally {
      println(“Clean up!”)
    }
}

delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)

Mas, se o trabalho de limpeza que precisamos executar estiver em suspensão, o código acima não funcionará mais, pois uma vez que a co-rotina está no estado Cancelando, ela não pode mais ser suspensa. Veja o código completo aqui.

 

Uma coroutine no estado de cancelamento não pode ser suspensa!

Para poder chamar funções suspend quando uma co-rotina é cancelada, precisaremos alternar o trabalho de limpeza que precisamos fazer em um CoroutineContext NonCancellable. Isso permitirá que o código seja suspenso e manterá a coroutine no estado Cancelando até que o trabalho seja concluído.

val job = launch {
   try {
      work()
   } catch (e: CancellationException){
      println(“Work cancelled!”)
    } finally {
      withContext(NonCancellable){
         delay(1000L) // or some other suspend fun 
         println(“Cleanup done!”)
      }
    }
}

delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)

Veja como isso funciona na prática aqui.

 

suspendCancellableCoroutine e invokeOnCancellation

Se você converteu retornos de chamada em coroutines usando o método suspendCoroutine, prefira usar suspendCancellableCoroutine. O trabalho a ser feito no cancelamento pode ser implementado usando continuation.invokeOnCancellation:

suspend fun work() {
   return suspendCancellableCoroutine { continuation ->
       continuation.invokeOnCancellation { 
          // do cleanup
       }
   // resto da implementação
}

Para perceber os benefícios da simultaneidade estruturada e garantir que não estamos fazendo um trabalho desnecessário, você precisa se certificar de que também está tornando seu código cancelável.

Use os CoroutineScopes definidos no Jetpack: viewModelScope ou lifecycleScope que cancela seu trabalho quando seu escopo é concluído. Se você estiver criando seu próprio CoroutineScope, certifique-se de vinculá-lo a um trabalho e chamar cancelar quando necessário.

O cancelamento do código coroutine precisa ser cooperativo, portanto, certifique-se de atualizar seu código para verificar se o cancelamento é lazy e evitar fazer mais trabalho do que o necessário.

Descubra mais sobre os padrões de trabalho que não devem ser cancelados nesta postagem: