协程的取消和异常

574 阅读7分钟

一、协程的取消

在不需要协程继续工作时,需要及时地取消它,以免浪费内存和电量。

协程内部是通过抛出一个特殊的异常来实现取消的:CancellationException。如果你想在取消时传递一些关于取消的原因,可以在调用cancel时提供一个CancellationException的实例:

fun cancel(cause: CancellationException? = null)

当然,你如果不想提供自己的CancellationException实例,内部将创建一个默认的CancellationException:

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

在协程内部,子协程通过异常来通知其父协程自己已经取消了。父协程首先要看一下抛出来的异常是什么,

  • 如果是CancellationException,那么就不需要采取其他行动。这个异常会被所有的处理者忽略。
  • CancellationException不会被CoroutineExceptionHandler捕获,如果想对CancellationException进行捕获就要使用try...catch进行处理。
  • 而如果不是,那么就该抛异常就抛异常,正如前面所说,如果内部的子Job发生异常,那么它对应的parent Job与它相关连的其它子Job都将取消运行。俗称连锁反应。我们也可以改变这种默认机制,Kotlin提供了SupervisorJob来改变这种机制。
  • 一旦取消了一个scope,你就不能在被取消的scope中启动新的协程。

二、为什么我的协程没有停止?

首先,我们需要搞清楚一点,如果我们只是调用cancel,这并不意味着协程的执行就会立刻停止。如果你没有在协程代码块中进行cancel的感知,然后手动停止协程代码块的执行,那么它就会继续执行,直到协程里面的工作全部做完。

来看个例子:

fun testCancelEarly() {
    val scope = CoroutineScope(Dispatchers.IO)
    
    val startTime = System.currentTimeMillis()

    scope.launch {
        val job = scope.launch {
            var nextPrintTime = startTime
            var i = 0
            while (i < 5) {
                if (System.currentTimeMillis() >= nextPrintTime) {
                    println("Hello ${i++}")
                    nextPrintTime += 500L
                }
            }
        }
        delay(1000L)
        println("Cancel")
        job.cancel()
        println("Done")
    }
}

输出:

Hello 0
Hello 1
Hello 2
Cancel
Done
Hello 3
Hello 4

可以看到

  • 虽然我们调用了cancel,但是并没有立即停止下来,而是继续执行到结束。
  • 一旦job.cancel被调用,协程就会进入Cancelling状态。但随后,我们看到Hello 3和Hello 4被打印出来。说明只有在工作完成后,协程才会进入Cancelled状态。

协程的执行并不是在调用cancel时停止。我们需要修改我们的代码,定期检查该协程是否处于active状态:

  • 检查取消状态并停止执行
  • 抛出一个CancellationException

你需要确保你实现的所有协程都是可以取消的,因此你需要定期或在开始一个长期运行的工作之前检查当前协程的状态。例如,如果你正在从磁盘上读取多个文件,在你开始读取每个文件之前,检查该协程是否被取消。这样就可以避免在不需要的时候做多余的工作。

三、检查Job的活动状态

3.1、ensureActive()

先看源码:

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

实现原理则是:在内部判断到已取消时抛出CancellationException。

上面的例子加以改进:

fun testCancelEarly() {
    val scope = CoroutineScope(Dispatchers.IO)
    
    val startTime = System.currentTimeMillis()

    scope.launch {
        val job = scope.launch {
            var nextPrintTime = startTime
            var i = 0
            while (i < 5) {
            	ensureActive()
                if (System.currentTimeMillis() >= nextPrintTime) {
                    println("Hello ${i++}")
                    nextPrintTime += 500L
                }
            }
        }
        delay(1000L)
        println("Cancel")
        job.cancel()
        println("Done")
    }
}

输出:

Hello 0
Hello 1
Hello 2
Cancel
Done

3.2、yield()

先看源码:

public suspend fun yield(): Unit = suspendCoroutineUninterceptedOrReturn sc@ { uCont ->}

首先yield()是一个官方定义的suspend函数,我们可以在协程中使用它,它有几个作用:

  • 它暂时降低当前长时间运行的CPU任务的优先级,为其他任务提供公平的运行机会;
  • 检查当前Job是否被取消;
  • 允许子任务的执行,当你的任务数大于当前允许并行执行的数目时,这可能很重要。

如果你正在做的工作是下面几种类型:

  1. CPU繁重
  2. 可能会耗尽线程池
  3. 你想让线程做其他工作,而不需要向线程池添加更多线程

那么就使用yield()函数。yield所做的第一个操作将是检查完成情况,如果工作已经完成,则通过抛出CancellationException退出协程。

所以上面的例子还可以这么改:

fun testCancelEarly() {
    val scope = CoroutineScope(Dispatchers.IO)
    
    val startTime = System.currentTimeMillis()

    scope.launch {
        val job = scope.launch {
            var nextPrintTime = startTime
            var i = 0
            while (i < 5) {
            	yield()
                if (System.currentTimeMillis() >= nextPrintTime) {
                    println("Hello ${i++}")
                    nextPrintTime += 500L
                }
            }
        }
        delay(1000L)
        println("Cancel")
        job.cancel()
        println("Done")
    }
}

输出:

Hello 0
Hello 1
Hello 2
Cancel
Done

3.3、delay()

delay()函数是如何做到的呢,delay()函数会检查当前的协程是否被取消,如果被取消,则会抛出一个CancellationException,从而终止当前的协程,我们来验证一下:

fun testCancelEarly() {
    val scope = CoroutineScope(Dispatchers.IO)
    
    val startTime = System.currentTimeMillis()

    scope.launch {
        val job = scope.launch {
            var nextPrintTime = startTime
            var i = 0
            while (i < 5) {
            	delay(100)
                if (System.currentTimeMillis() >= nextPrintTime) {
                    println("Hello ${i++}")
                    nextPrintTime += 500L
                }
            }
        }
        delay(1000L)
        println("Cancel")
        job.cancel()
        println("Done")
    }
}

输出:

Hello 0
Hello 1
Hello 2
Cancel
Done

我们知道,CancellationException会被try...catch进行捕获,如果上面的例子我们对delay()做了try...catch处理,结果会怎么样呢?

fun testCancelEarly() {
    val scope = CoroutineScope(Dispatchers.IO)
    
    val startTime = System.currentTimeMillis()

    scope.launch {
        val job = scope.launch {
            var nextPrintTime = startTime
            var i = 0
            while (i < 5) {
                try {
            		delay(100)
                } catch (e:Exception){
                     println("${e.message}")
                }    
                if (System.currentTimeMillis() >= nextPrintTime) {
                    println("Hello ${i++}")
                    nextPrintTime += 500L
                }
            }
        }
        delay(1000L)
        println("Cancel")
        job.cancel()
        println("Done")
    }
}

输出:

Hello 0
Hello 1
Hello 2
Cancel
Done
StandaloneCoroutine was cancelled
StandaloneCoroutine was cancelled
Hello 3
StandaloneCoroutine was cancelled
StandaloneCoroutine was cancelled
StandaloneCoroutine was cancelled
StandaloneCoroutine was cancelled
Hello 4

可以发现,如果对delay()做了try...catch处理,取消的CancellationException被我们自己处理了,并没有向上抛出,则该协程依旧不会停止执行。

为什么会出现这种情况,原因也非常简单,协程框架的并发取消是需要这个异常的,它自己可以处理,所以这里要注意:捕获了CancellationException异常后,要考虑是否应该重新抛出来

如果要想停止执行,要怎么办呢?答案是:使用throw将CancellationException再次抛出去。

fun testCancelEarly() {
    val scope = CoroutineScope(Dispatchers.IO)
    
    val startTime = System.currentTimeMillis()

    scope.launch {
        val job = scope.launch {
            var nextPrintTime = startTime
            var i = 0
            while (i < 5) {
                try {
            		delay(100)
                } catch (e:Exception){
                     println("${e.message}")
                     throw e
                }    
                if (System.currentTimeMillis() >= nextPrintTime) {
                    println("Hello ${i++}")
                    nextPrintTime += 500L
                }
            }
        }
        delay(1000L)
        println("Cancel")
        job.cancel()
        println("Done")
    }
}

输出:

Hello 0
Hello 1
Hello 2
Cancel
Done

当然,使用如下几种方式也可以达到一样的效果:

  • withContext(Dispatchers.IO){}
  • async { }.await()

四、取消之后的收尾工作

假如,当一个协程被取消时,你想执行一个特定的动作:关闭任何你想关闭的资源、清理代码之类的。

4.1、try…catch

因为当一个协程被取消时,会抛出CancellationException,那么我们可以用try…catch包住我们在协程中需要执行的代码,在finally块中,执行清理动作。

fun testCancelEarly() {
    val scope = CoroutineScope(Dispatchers.IO)
    
    val startTime = System.currentTimeMillis()

    scope.launch {
        val job = scope.launch {
            try {
                var nextPrintTime = startTime
                var i = 0
                while (i < 5) {
                    yield()
                    if (System.currentTimeMillis() >= nextPrintTime) {
                        println("Hello ${i++}")
                        nextPrintTime += 500L
                    }
                }
            } catch (e: Exception) {
                println("Exception ${e.message}")
            } finally {
                println("finally Clean Up")
            }
        }
        delay(1000L)
        println("Cancel")
        job.cancel()
        println("Done")
    }
}

输出:

Hello 0
Hello 1
Hello 2
Cancel
Done
Exception StandaloneCoroutine was cancelled
finally Clean Up

但是,如果需要在finally代码块执行suspend函数,是不行的。因为这个时候协程已经处于Canceling状态,因此不能再挂起。

为了能够在协程被取消时调用suspend函数,我们需要切换到NonCancellable CoroutineContext中做清理工作。这允许协程代码挂起,并将协程保持在Canceling状态,直到清理工作完成。

什么是NonCancellable?它是官方提供的一个工具类,继承自Job,但始终处于isActive为true的状态,且是不可取消的Job。它是专门为withContext设计的,像上面这种需要在不可取消的情况下执行的代码块,就需要用到它。

public object NonCancellable : AbstractCoroutineContextElement(Job), Job 

下面来看示例:

fun testCancelEarly() {
    val scope = CoroutineScope(Dispatchers.IO)
    
    val startTime = System.currentTimeMillis()

    scope.launch {
        val job = scope.launch {
            try {
                var nextPrintTime = startTime
                var i = 0
                while (i < 5) {
                    yield()
                    if (System.currentTimeMillis() >= nextPrintTime) {
                        println("Hello ${i++}")
                        nextPrintTime += 500L
                    }
                }
            } catch (e: Exception) {
                println("Exception ${e.message}")
            } finally {
                withContext(NonCancellable){
                    delay(2000L)
                    println("finally Clean Up")
                }
            }
        }
        delay(1000L)
        println("Cancel")
        job.cancel()
        println("Done")
    }
}

输出:

Hello 0
Hello 1
Hello 2
Cancel
Done
Exception StandaloneCoroutine was cancelled
finally Clean Up

从示例代码中可以看出,即使job已经被cancel了,但是在withContext里面的执行清理的代码还是继续在执行着,符合我们的需求。

4.2、suspendCancellableCoroutine 和 invokeOnCancellation

下面来看示例:

fun testCancelEarly() {
    val scope = CoroutineScope(Dispatchers.IO)
    
    val startTime = System.currentTimeMillis()

    scope.launch {
        val job = scope.launch {
            suspendCancellableCoroutine<Any> {
                it.invokeOnCancellation {
                    println("finally Clean Up")
                }

                var nextPrintTime = startTime
                var i = 0
                while (i < 5) {
                    ensureActive()
                    if (System.currentTimeMillis() >= nextPrintTime) {
                        println("Hello ${i++}")
                        nextPrintTime += 500L
                    }
                }
            }
        }
        delay(1000L)
        println("Cancel")
        job.cancel()
        println("Done")
    }
}

输出:

Hello 0
Hello 1
Hello 2
Cancel
finally Clean Up
Done

从示例代码中可以看出:

  • 相较于try...catch,在调用cancel的时候,invokeOnCancellation立刻就被感知到了;
  • 由于yield()是suspend函数,无法在suspendCancellableCoroutine 中使用。

五、异常

当一个协程发生了异常,它会将该异常传播到它的父级。然后,父协程将执行以下逻辑:

  • 取消其他的子协程

  • 取消自己

  • 将异常传播到其父级

最终该异常会传播到协程的层次结构的根部,最顶层,所有被CoroutineScope启动的协程都将被取消。

虽然传播异常在某些情况下是有意义的,但在其他情况下这是不合适的。

举个例子:假设,某个点击按钮的处理逻辑交给一个CoroutineScope启协程来处理。如果其中的一个子协程抛出了一个异常,那么该CoroutineScope就会被取消,那么该按钮的点击操作就变得没有任何反应,因为一个被取消了的CoroutineScope不能再启动更多的协程。

怎么解决上面的问题?你可以在创建CoroutineScope的CoroutineContext的时候,使用Job的另一个实现,即SupervisorJob。

用上SupervisorJob之后,其中一个子协程崩了,并不影响其他子协程。SupervisorJob不会取消自己或其他子协程。而且,SupervisorJob也不会传播异常,而是让子协程处理它。

如果子协程没有处理这个异常,并且该CoroutineScope的CoroutineContext没有配置CoroutineExceptionHandler,那么该异常会达到线程的ExceptionHandler。如果是JVM,那么该异常会打印log到控制台上;如果是Android,那么app将会崩溃。

举个例子:

fun testExceptionEarly() {
    val scope = CoroutineScope(Dispatchers.IO+ SupervisorJob())
    scope.launch {
        val task1 = scope.launch {
            println("task1--hello")
            delay(300)
            throw NullPointerException()
            println("task1--world")
        }
        val task2 = scope.launch {
            println("task2--hello")
            delay(600)
            println("task2--world")
        }
    }
}

输出:

task1--hello
task2--hello
task2--world

之后app crash了

java.lang.NullPointerException
        at com.florizt.test2.MainActivity$testExceptionEarly$1$task1$1.invokeSuspend(MainActivity.kt:52)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
        at kotlinx.coroutines.internal.LimitedDispatcher.run(LimitedDispatcher.kt:42)
        at kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:95)
        at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:570)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:749)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:677)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:664)
    	Suppressed: kotlinx.coroutines.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@8a8c4fa, Dispatchers.IO]
  • 无论你使用哪种类型的Job,都会抛出未捕获的异常;
  • lifecycleScope和viewModelScope的CoroutineContext都有SupervisorJob()。

值得注意的是:

在coroutineScope构建器或由其他协程创建的协程中抛出的异常不会被try.catch捕获

既然try...catch是捕获不住异常的,那怎么办?办法就是:设置CoroutineExceptionHandler

  fun testExceptionEarly() {
        val scope = CoroutineScope(Dispatchers.IO+ SupervisorJob())
        scope.launch {
            val task1 = scope.launch(CoroutineExceptionHandler { coroutineContext, throwable ->
                println("task1--Exception:  ${throwable.message}")
            }) {
                println("task1--hello")
                delay(300)
                throw NullPointerException()
                println("task1--world")
            }

            val task2 = scope.launch {
                println("task2--hello")
                delay(600)
                println("task2--world")
            }
        }
    }

输出:

task1--hello
task2--hello
task1--Exception:  null
task2--world

六、总结

  • 协程执行了cancel()后,不会马上停止,而是进入Cancelling状态,需要协程内部通过ensureActive()、yield()、delay()等可响应取消操作的方法来配合cancel()
  • 协程取消会抛出CancellationException,该异常只能try...catch,不能被CoroutineExceptionHandler捕获
  • 如果try...catch了CancellationException,可能会影响到并发取消,所以要考虑是否将该异常重新抛出来
  • 其他错误异常,使用try...catch需要注意的点很多,使用不当就捕获不了异常,导致应用crash,最好通过CoroutineExceptionHandler捕获异常

七、不应取消的协程

在有些情况下,你想让一个操作全部完成,而不能被中途取消掉,即使用户已离开此Activity。比如写入数据库或向服务器发起某种网络请求。然而,viewModelScope或lifecycleScope在完成状态时,会cancel掉,那我有个重要的工作还没做完,你给我cancel了不合适(cancel之后,其实并不会停止协程中代码的执行,因为cancel动作需要协程里面配合才行)。那咋办?

7.1、applicationScope

假如,我们的应用中有一个ViewModel和一个Repository,其逻辑如下:

class MyViewModel(private val repo: Repository) : ViewModel() {
  fun callRepo() {
    viewModelScope.launch {
      repo.doWork()
    }
  }
}

class Repository(private val ioDispatcher: CoroutineDispatcher) {
  suspend fun doWork() {
    withContext(ioDispatcher) {
      doSomeOtherWork()
      veryImportantOperation() // 这个操作不应该被取消,它非常重要
    }
  }
}

我们不希望veryImportantOperation()被viewModelScope控制,因为它可以在任何时候被取消。我们希望该操作比viewModelScope生命周期更长。我们怎么才能做到这一点?

为此,请在Application类中创建自己的Scope,并在由它启动的协程中调用这些重要的操作

与我们看到的其他解决方案(如GlobalScope)相比,创建自己的CoroutineScope的好处是你可以根据需要对其进行配置。比如:你可以配置一个CoroutineExceptionHandler,将自己的线程池用作Dispatcher等,将所有常见的配置放在它的CoroutineContext中,非常方便。

你可以将其称为applicationScope,并且它必须包含一个SupervisorJob()以便协程中的异常不会在层次结构中传播。

class MyApplication : Application() {
  //不需要取消该Scope,因为它会随着进程死亡而终止。
  val applicationScope = CoroutineScope(SupervisorJob() + otherConfig)
}

7.2、NonCancellable

有些任务你并不想被手动取消,也可以使用NonCancellable作为任务的CoroutineContext