跳至主要內容

取消协程

guodongAndroid大约 8 分钟

与任何多线程概念一样,协程的生命周期也将是一个问题。当它处于不一致的状态时,你需要停止任何可能长时间运行的后台任务,以防止出现内存泄露或崩溃。为解决这个问题,协程提供了一套简单的取消机制。

Job 对象

正如你在第三章:“协程入门”中所见,当你使用 launch 协程构建器启动一个新协程时,你会得到一个返回的 Job 对象。这个 Job 对象代表正在运行的协程,然后你可以在任何时候调用 cancel() 来取消它。

这正是 Kotlin 协程有趣的地方,你可以指定一个父 Job 作为多个协程的上下文,调用父协程的 cancel() 将导致所有的协程被取消。

提示

注意: 当你取消父协程时,它所有的子协程也会递归的被取消。

launch 协程构建器通常使用 即发即弃 的方式来启动一个协程。就像启动一个新的线程。如果协程中的代码以异常情况终止,系统将其视为线程中的一个未被捕获的异常:通常在后端 JVM 应用程序中打印至 stderr,而 Android 应用程序通常会崩溃。你可以使用 join() 等待启动的协程完成,但它不会传播其异常。但是,崩溃的子协程也会取消其父协程,并出现相应的异常。

协作式

在一个长时间运行的应用程序中,你可能需要精准的控制你的后台协程。例如,启动协程的任务可能已经完成,并且现在已不再需要它的结果,因此,它的操作可以被取消。这就是 cancel() 的用武之地。

为了取消协程,你只需对从协程构建器返回的 Job 实例调用 cancel()。如果代码是协作式的取消,那么调用 Job 和 Deferred 实例的 cancel() 将停止协程内部的计算。

协程代码应该是 协作式的取消。这意味着你写在挂起函数中的代码在执行任何密集工作前应该先检查协程是否仍然活跃。在实践中,挂起函数应该周期检查 isActive 属性。当协程被取消时,这个属性将被置为 false。所有由 Kotlin 协程库提供的挂起函数都已支持取消。

提示

注意: 在标准库中的子协程挂起点之间已经检查了 isActive,因此你只需要在你自己长时间的计算任务中检查 isActive。

如果你的代码不是协作式的取消,你会得到不可预期的结果。在 starter 项目中打开本章的 CooperativeCancellation.kt 文件并查看下面的示例:

fun main() = runBlocking {
	val startTime = System.currentTimeMillis()
	val job = launch(Dispatchers.Default) {
		var nextPrintTime = startTime
		var i = 0
		while (i < 10) {
			if (System.currentTimeMillis() >= nextPrintTime) {
				println("Doing heavy work: $i")
				i++
				nextPrintTime += 500L
			}
		}
	}
	delay(1000)
	println("Cancelling coroutine")
	job.cancel()
	println("Main: now I can quit!")
}

示例的输出如下:

Doing heavy work: 0
Doing heavy work: 1
Doing heavy work: 2
Cancelling coroutine
Main: now I can quit!
Doing heavy work: 3
Doing heavy work: 4

正如你在上面的输出中所见,即使一秒后协程被取消,但是它仍然在继续执行工作。为了解决这个问题,你必须要检查协程的状态。你需要在 while 中检查 isActive 属性。大部分示例代码保持不变,仅替换 while(i < 10) 为 while(i < 10 && isActive) 即可。输出现在如下:

Doing heavy work: 0
Doing heavy work: 1
Doing heavy work: 2
Cancelling coroutine
Main: now I can quit!

你可以看到,协程在取消 job 后停止执行,因为一旦取消协程,isActive 标志就会设置为false。

你知道标准库中的函数已经支持取消。因此,你可以利用那些函数来重写这个示例。打开 starter 项目中本章的 StdLibCancellation.kt 文件并编写以下代码:

fun main() = runBlocking {
	val job = launch(Dispatchers.Default) {
		var i = 0
		while (i < 1000) {
			println("Doing heavy work ${i++}")
			delay(500)
		}
	}
	delay(1200)
	println("Cancelling")
	job.cancel()
	println("Main: Now I can quit!")
}

输出:

Doing heavy work 0
Doing heavy work 1
Doing heavy work 2
Cancelling
Main: Now I can quit!

你可以看到当你调用 cancel() 后协程停止执行。这是因为 delay() 会在内部检查协程的活跃状态。如果当前协程的 Job 在函数等待期间被取消或已经完成,它会立即恢复并抛出 CancellationException。

CancellationException

协程在内部使用 CancellationException 实例进行取消,然后所有处理器都会忽略这些实例。如果协程的 Job 在挂起时被取消,则它们通常由可取消的挂起函数抛出。它表示协程的正常取消。

提示

注意:CancellationException 不会由默认的未捕获异常处理器打印到控制台/日志中。

当你在其 Job 对象上使用 cancel 函数无故取消协程时,它会终止,但不会取消其父级。无故取消是父协程在不取消自己的情况下取消其子协程的一种机制。

下面的代码片段展示了当子协程取消时的 CancellationException 处理。打开 starter 项目中本章的 CancellationExceptionExample.kt 文件。编写以下代码并运行:

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
	// 1
	val handler = CoroutineExceptionHandler { _, exception ->
		// 6
		println("Caught original $exception")
	}
	// 2
	val parentJob = GlobalScope.launch(handler) {
		val childJob = launch {
			// 4
			throw IOException()
		}
        
		try {
			childJob.join()
		} catch (e: CancellationException) {
			// 5
			println("Rethrowing CancellationException with original cause: ${e.cause}")
			throw e
		}
	}
	// 3
	parentJob.join()
}

输出如下:

Rethrowing CancellationException with original cause: java.io.IOException
Caught original java.io.IOException

让我们回顾下上述示例的执行流程来深入理解代码发生了什么事情:

  1. 你创建了一个处理和打印异常的 handler。
  2. 然后,你创建了一个层级的协程并分别持有它们的 job 引用至 parentJob 和 childJob。
  3. 调用 parentJob.join() 让协程挂起等待 job 完成。
  4. 内部的协程抛出 IOException()。它将取消协程的执行并自动向上传播异常。
  5. 你捕获了 CancellationException 的实例并打印了它的 cause。
  6. 最后,handler 获得要处理的原始异常。

这里非常重要,你要留意 cause。当内部的协程抛出异常时,它将该异常包装到 CancellationException 中,并将 IOException 作为它的 cause。因为这个 cause,导致整个层级的协程都将被取消。

Join,CancelAndJoin & CancelChildren

Kotlin 标准库提供了几个便利的函数来处理协程的完成和取消。

  1. 当使用协程时,你可能对 Job 完成的结果最感兴趣。要了解协程的完成情况,可以使用 join 函数,该函数将挂起调用协程的执行,直到 Job 完成。你已经在实战中见识过 join(),但是让我们再通过一个简单的示例回顾一下它。打开 JoinCoroutineExample.kt 文件并使用下面的代码替换空函数:

    fun main() = runBlocking {
    	val job = launch {
    		println("Crunching numbers [Beep.Boop.Beep]...")
    		delay(1000L)
    	}
        // waits for job's completion
    	job.join()
    	println("main: Now I can quit.")
    }
    

    输出如下:

    Crunching numbers [Beep.Boop.Beep]…
    main: Now I can quit.
    

    你可以看到程序打印的内容,然后等待一秒钟 Job 完成,最后程序完成。

    尝试注释掉 job.join() 然后看看会发生什么。

  2. 如果你想等待多个协程完成,可以使用标准库提供的 joinAll 函数。让我们看一个例子。打开 JoinAllCoroutineExample.kt 文件并编写以下代码:

    fun main() = runBlocking {
    	val jobOne = launch {
    		println("Job 1: Crunching numbers [Beep.Boop.Beep]…")
    		delay(2000L)
    	}
    	val jobTwo = launch {
    		println("Job 2: Crunching numbers [Beep.Boop.Beep]…")
    		delay(500L)
    	}
    	// waits for both the jobs to complete
    	joinAll(jobOne, jobTwo)
    	println("main: Now I can quit.")
    }
    

    输出如下:

    Job 1: Crunching numbers [Beep.Boop.Beep]…
    Job 2: Crunching numbers [Beep.Boop.Beep]…
    main: Now I can quit.
    

    注意程序等待了2秒钟,因为所有的 Job 需要完成,最后才是程序完成。

  3. 如果你想取消,然后等待协程的完成,标准库提供了一个方便的 cancelAndJoin 函数,它将两者结合在一起。

    打开 CancelAndJoinCoroutineExample.kt 文件然后编写下面的代码来尝试它:

    fun main() = runBlocking {
    	val job = launch {
    		repeat(1000) { i ->
    			println("$i. Crunching numbers [Beep.Boop.Beep]…")
    			delay(500L)
    		}
    	}
    	delay(1300L) // delay a bit
    	println("main: I am tired of waiting!")
    	// cancels the job and waits for job’s completion
    	job.cancelAndJoin()
    	println("main: Now I can quit.")
    }
    

    输出如下:

    0. Crunching numbers [Beep.Boop.Beep]…
    1. Crunching numbers [Beep.Boop.Beep]…
    2. Crunching numbers [Beep.Boop.Beep]…
    main: I am tired of waiting!
    main: Now I can quit.
    

    调用 cancelAndJoin() 的协程只是挂起,直到取消的 Job 完成。

  4. 如果你的协程有多个子线程,你想取消所有的子线程,那么你可以使用 cancelChildren 函数。打开 CancelChildren.kt 文件并编写以下代码来测试它:

    fun main() = runBlocking {
    	val parentJob = launch {
    		val childOne = launch {
    			repeat(1000) { i ->
    				println("Child Coroutine 1: $i. Crunching numbers [Beep.Boop.Beep]…")
    				delay(500L)
    			}
    		}
    		// Handle the exception thrown from `launch`
    		// coroutine builder
    		childOne.invokeOnCompletion { exception ->
    			println("Child One: ${exception?.message}")
    		}
    	    val childTwo = launch {
    			repeat(1000) { i ->
    				println("Child Coroutine 2: $i. Crunching numbers [Beep.Boop.Beep]…")
    				delay(500L)
    			}
    		}
    		// Handle the exception thrown from `launch`
    		// coroutine builder
    		childTwo.invokeOnCompletion { exception ->
    			println("Child Two: ${exception?.message}")
    		}
    	}
    	delay(1200L)
    	println("Calling cancelChildren() on the parentJob")
    	parentJob.cancelChildren()
    	println("parentJob isActive: ${parentJob.isActive}")
    }
    

    输出如下:

    Child Coroutine 1: 0. Crunching numbers [Beep.Boop.Beep]…
    Child Coroutine 2: 0. Crunching numbers [Beep.Boop.Beep]…
    Child Coroutine 1: 1. Crunching numbers [Beep.Boop.Beep]…
    Child Coroutine 2: 1. Crunching numbers [Beep.Boop.Beep]…
    Child Coroutine 1: 2. Crunching numbers [Beep.Boop.Beep]…
    Child Coroutine 2: 2. Crunching numbers [Beep.Boop.Beep]…
    Calling cancelChildren() on the parentJob
    parentJob isActive: true
    Child One: Job was canceled
    Child Two: Job was canceled
    

    你现在已经知道有几种方式去取消协程和处理取消。但是,你如何在设定的时间后取消协程?下一节将介绍该特定场景。