目录
一、协程取消
1.取消协程的执行
2.使计算代码可取消
3.在finally中释放资源
4.运行不能取消的代码块
二、协程超时
异步超时与资源管理
一、协程取消
1.取消协程的执行
在一个长时间运行的应用程序中,你也许需要对你的后台协程进行细粒度的控制。 比如说,
一个用户也许关闭了一个启动了协程的界面,那么现在协程的执行结果已经不再被需要了,
这时,它应该是可以被取消的。 该 launch 函数返回了一个可以被用来取消运行中的协程的
Job:
runBlocking {
val job = launch {
Log.d(TAG,"job: I'm sleeping ...")
}
delay(100L)
Log.d(TAG,"main: I'm tired of waiting!")
job.cancel() // 取消该作业
job.join() // 等待作业执行结束
Log.d(TAG,"main: Now I can quit.")
}
一旦 main 函数调用了 job.cancel ,我们在其它的协程中就看不到任何输出,因为它被取消
了。 这里也有一个可以使 Job 挂起的函数 cancelAndJoin 它合并了对 cancel 以及 join 的调用。
2.取消是协作的
协程的取消是协作的。一段协程代码必须协作才能被取消。 所有 kotlinx.coroutines 中的
挂起函数都是 可被取消的 。它们检查协程的取消, 并在取消时抛出 CancellationException。
然而,如果协程正在执行计算任务,并且没有检查取消的话,那么它是不能被取消的,就如
如下示例代码所示:
runBlocking {
//sampleStart
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (i < 5) { // 一个执行计算的循环,只是为了占用 CPU
// 每秒打印消息两次
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: I'm sleeping ${i++} ...")
nextPrintTime += 500L
}
}
}
delay(1300L) // 等待一段时间
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 取消一个作业并且等待它结束
println("main: Now I can quit.")
//sampleEnd
}
运行示例代码,并且我们可以看到它连续打印出了“I'm sleeping”,甚至在调用取消后, 作业
仍然执行了五次循环迭代并运行到了它结束为止。
可以通过捕获CancellationException
而不重新抛出它来观察到相同的问题。
runBlocking {
//sampleStart
val job = launch(Dispatchers.Default) {
repeat(5) { i ->
try {
Log.d(TAG,"job: I'm sleeping $i ...")
delay(500)
} catch (e: CancellationException) {
Log.d(TAG,"CancellationException")
Log.d(TAG,e.toString())
}
}
}
delay(1300L) // delay a bit
Log.d(TAG,"main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
Log.d(TAG,"main: Now I can quit.")
}
虽然捕获异常(Exception)被视为一种反模式(anti-pattern),但这个问题可能会以更微妙的方式出现,比如在使用runCatching
函数时,该函数不会重新抛出CancellationException
。
为什么取消协程是协作的?
在编程中,特别是在涉及并发和异步操作的上下文中,"协程"(Coroutine)是一种可以暂停和恢复执行的函数或方法。协程允许程序在等待某些操作(如I/O操作)完成时释放执行权,从而允许其他任务运行,这提高了程序的效率和响应性。
当我们说“一段协程代码必须协作才能被取消”时,意味着协程本身需要包含一些机制或代码,以响应外部的取消请求。这不同于传统的线程或进程,后者可以通过操作系统层面的信号或中断直接强制终止。协程的取消需要更加细致和协作的处理,因为协程的运行是在用户态管理的,而不是由操作系统直接控制。
以下是一些关键点,解释了为什么协程的取消需要协作:
-
状态管理:协程可能处于多种状态(如运行中、等待中、已完成等)。要安全地取消协程,需要确保它不会在取消过程中处于不一致的状态。
-
资源清理:协程可能会分配资源(如内存、文件句柄、网络连接等)。取消协程时,需要确保这些资源被适当地释放或回收,以避免资源泄漏。
-
取消点:协程需要在其执行路径上明确设置“取消点”,这些点是检查取消请求的地方。如果在这些点检测到取消请求,协程将停止执行并适当地清理资源。
-
异常处理:取消协程通常通过抛出或传播异常来实现。这意味着协程需要能够捕获并处理这些异常,以避免程序崩溃。
-
用户态控制:由于协程的调度和执行是在用户态进行的,没有操作系统的直接干预,因此取消操作需要协程代码本身的支持和配合。
总之,协程的取消机制依赖于协程内部的协作,这意味着协程需要编写成能够响应取消请求的形式,包括在适当的时候检查取消状态、清理资源、以及适当地处理取消操作引发的异常。这种设计使得协程的取消更加灵活和安全,但同时也要求开发者在编写协程时更加注意取消逻辑的实现。
2.使计算代码可取消
我们有两种方法来使执行计算的代码可以被取消。第一种方法是定期调用挂起函数来检查取
消。对于这种目的 yield 是一个好的选择。 另一种方法是显式的检查取消状态。让我们试试第
二种方法。
将前一个示例中的 while (i < 5) 替换为 while (isActive) 并重新运行它。
runBlocking {
//sampleStart
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (isActive) { // 可以被取消的计算循环
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: I'm sleeping ${i++} ...")
nextPrintTime += 500L
}
}
}
delay(1300L) // 等待一段时间
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 取消该作业并等待它结束
println("main: Now I can quit.")
}
你可以看到,现在循环被取消了。isActive 是一个可以被使用在 CoroutineScope 中的扩展属
性。
3.在finally中释放资源
我们通常使用如下的方法处理在被取消时抛出 CancellationException 的可被取消的挂起函数。
比如说, try {……} finally {……} 表达式以及 Kotlin 的 use 函数一般在协程被取消的时候执
行它们的终结动作:
runBlocking {
//sampleStart
val job = launch {
try {
repeat(1000) { i ->
println("job: I'm sleeping $i ...")
delay(500L)
}
} catch (e:CancellationException){
println("CancellationException:"+e.message)
} finally {
println("job: I'm running finally")
}
}
delay(1300L) // 延迟一段时间
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 取消该作业并且等待它结束
println("main: Now I can quit.")
}
join 和 cancelAndJoin 等待了所有的终结动作执行完毕, 所以运行示例得到了下面的输出:
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm running finally
main: Now I can quit.
4.运行不能取消的代码块
在前一个例子中任何尝试在 finally 块中调用挂起函数的行为都会抛出
CancellationException,因为这里持续运行的代码是可以被取消的。通常,这并不是一个问
题,所有良好的关闭操作(关闭一个文件、取消一个作业、或是关闭任何一种通信通道)通
常都是非阻塞的,并且不会调用任何挂起函数。然而,在真实的案例中,当你需要挂起一个
被取消的协程,你可以将相应的代码包装在 withContext(NonCancellable) {……} 中,并使用
withContext 函数以及 NonCancellable 上下文,见如下示例所示:
runBlocking {//sampleStart
val job = launch {
try {
repeat(1000) { i ->
println("job: I'm sleeping $i ...")
delay(500L)
}
} finally {
withContext(NonCancellable) {
println("job: I'm running finally")
delay(1000L)
//如果不用withContext,则此句话不会被打印
println("job: And I've just delayed for 1 sec because I'm non-cancella ble")
}
}
}
delay(1300L) // 延迟一段时间
println("main: I'm tired of waiting!")
job.cancelAndJoin() // 取消该作业并等待它结束
println("main: Now I can quit.")
}
关于 Kotlin 协程(Coroutines)中处理取消操作和异常情况的说明:
-
协程和取消操作:在 Kotlin 协程中,协程可以被取消以释放资源或响应超时等情况。当协程被取消时,它会尽可能快地抛出一个
CancellationException
异常来通知调用者或相关代码,当前操作已被取消。 -
finally 块中的挂起函数:在 try-catch-finally 结构中,finally 块用于执行清理操作,无论 try 块中的代码是否成功执行或抛出异常。如果在 finally 块中调用了挂起函数(即一个可能挂起执行的函数,如网络请求或延迟操作),并且此时协程已被取消,那么尝试执行这个挂起函数将会导致
CancellationException
被抛出。 -
为什么通常不是问题:通常,finally 块中的清理操作(如关闭文件、取消作业、关闭通信通道等)都是非阻塞的,意味着它们不需要等待其他操作完成。因此,这些操作通常不会调用挂起函数,从而避免了在协程被取消时抛出
CancellationException
的问题。 -
处理被取消的协程中的挂起函数:然而,在某些情况下,你可能需要在已被取消的协程中执行挂起函数。例如,你可能需要在取消操作时执行一些清理工作,这些工作本身包含挂起操作。为了在这种情况下避免
CancellationException
,Kotlin 协程提供了withContext(NonCancellable) { ... }
结构。 -
withContext(NonCancellable) { ... }:这个函数允许你在一个不会被取消的上下文中执行代码块。这意味着,即使外部协程已被取消,
withContext(NonCancellable) { ... }
内部的代码仍然会执行,而不会抛出CancellationException
。这对于执行必须完成的清理操作特别有用,即使这些操作包含挂起函数。
综上所述,这段话是在说明如何在 Kotlin 协程中处理取消操作和异常,特别是在需要执行挂起函数作为清理操作时,如何使用 withContext(NonCancellable) { ... }
来确保这些操作能够安全执行,而不会因协程被取消而失败。
二、协程超时
在实践中绝大多数取消一个协程的理由是它有可能超时。 当你手动追踪一个相关 Job 的引用
并启动了一个单独的协程在延迟后取消追踪,这里已经准备好使用 withTimeout 函数来做这件
事。 来看看示例代码:
runBlocking {//sampleStart
try {
var result = withTimeout(4000L){
repeat(1000){i->
println("I am sleep $i...")
delay(500L)
}
}
Log.d(TAG,"result:"+result.toString())
}catch (e:TimeoutCancellationException){
Log.d(TAG,"TimeoutCancellationException:"+e.message)
}
}
withTimeout 抛出了 TimeoutCancellationException ,它是 CancellationException 的子类。 我
们之前没有在控制台上看到堆栈跟踪信息的打印。这是因为在被取消的协程中
CancellationException 被认为是协程执行结束的正常原因。 然而,在这个示例中我们在
main 函数中正确地使用了 withTimeout 。
由于取消只是一个例外,所有的资源都使用常用的方法来关闭。 如果你需要做一些各类使用
超时的特别的额外操作,可以使用类似 withTimeout 的 withTimeoutOrNull 函数,并把这些会
超时的代码包装在 try {...} catch (e: TimeoutCancellationException) {...} 代码块中,而
withTimeoutOrNull 通过返回 null 来进行超时操作,从而替代抛出一个异常:
runBlocking {//sampleStart
try {
var result = withTimeoutOrNull(4000L){
repeat(1000){i->
println("I am sleep $i...")
delay(500L)
}
}
Log.d(TAG,"result:"+result.toString())
}catch (e:TimeoutCancellationException){
Log.d(TAG,"TimeoutCancellationException:"+e.message)
}
}
运行这段代码时不再抛出异常,输出为:
I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Result is null
异步超时与资源管理
withTimeout
中的超时事件相对于其代码块内运行的代码是异步的,并且可能随时发生,甚至在从超时块内部返回之前立即发生。如果你在该代码块内部打开或获取了一些需要在代码块外部关闭或释放的资源,请牢记这一点。
例如,这里我们使用Resource
类来模拟一个可关闭的资源,该类仅通过递增获取计数器并在其close
函数中递减计数器来跟踪它被创建了多少次。现在,让我们创建大量协程,每个协程在withTimeout
块的末尾创建一个Resource
并在块外部释放该资源。我们添加了一个小的延迟,以便更有可能在withTimeout
块刚好完成时发生超时,这将导致资源泄漏。
这段话是关于Kotlin协程中withTimeout
函数行为的一个重要说明。让我们一步步解析它的含义:
-
withTimeout
中的超时事件是异步的:这意味着超时不是由withTimeout
代码块内部的代码直接控制的。它不是在该代码块执行到某个特定点时触发的,而是由协程调度器根据指定的超时时间来管理的。因此,即使代码块内部的代码正在运行,超时也可能在任何时候发生。 -
可能随时发生,甚至在从超时块内部返回之前立即发生:这句话进一步强调了超时的异步性。它表明,即使代码块内部的代码看起来即将完成并准备返回,超时也可能在返回操作实际发生之前的一瞬间触发。这种情况可能导致一些棘手的问题,特别是当涉及到资源管理时。
-
如果你在代码块内部打开或获取了一些资源:这里提到的“资源”可以是任何需要在使用完毕后关闭或释放的东西,比如文件句柄、数据库连接、网络连接等。在
withTimeout
代码块内部打开或获取这些资源是常见的做法,但你需要意识到超时的异步性可能导致这些资源在未被正确关闭或释放的情况下被遗弃。 -
请牢记这一点:这是一个警告,提醒开发者在使用
withTimeout
时需要特别注意资源管理。由于超时的异步性,你不能简单地假设代码块内部的代码总是会执行到末尾并有机会关闭或释放所有资源。因此,你需要采取额外的措施来确保资源的正确管理,比如使用try-finally
结构来确保资源在发生超时或其他异常时也能被正确关闭或释放。
综上所述,这段话是在强调在使用withTimeout
时需要注意资源管理的异步性和潜在的风险,以及需要采取适当的措施来确保资源的正确管理。
runBlocking {//sampleStart
repeat(10000){
launch {
var res = withTimeout(60){
delay(50)
Resource()
}
res.close()
}
}
}
Log.d(TAG,"aq:$aq")
class Resource{
var aq = 0
init{
aq++
}
fun close(){
aq--
}
}
如果你运行上面的代码,你会发现它并不总是打印零,尽管这可能会取决于你机器的时序。你可能需要调整这个示例中的超时时间,以便实际看到非零值。
请注意,这里从10K个协程中对已获取的计数器进行增减操作是完全线程安全的,因为这一操作总是在同一个线程中执行,即runBlocking
所使用的线程。关于这一点的更多信息,将在关于协程上下文的章节中解释。
为了解决这个问题,你可以将资源的引用存储在一个变量中,而不是从withTimeout
块中返回它。
runBlocking {//sampleStart
repeat(10000){
launch {
var res: Resource? = null
try {
res = withTimeout(60){
delay(50)
Resource()
}
}finally {
res?.close()
}
}
}
}
Log.d(TAG,"aq:$aq")
class Resource{
var aq = 0
init{
aq++
}
fun close(){
aq--
}
}
输出结果一直为0,Resource没有泄露。
推荐文章
取消与超时 · Kotlin 官方文档 中文版