本文已参与「新人创作礼」活动,一起开启掘金创作之路。
一、Job 简介
每一个协程创建时,都会生成一个 Job 实例,这个实例是协程的唯一标识,负责管理协程的生命周期。
当协程创建、执行、完成、取消时,Job 的状态也会随之改变,通过检查 Job 对象的属性可以追踪到协程当前的状态。
二、Job 的状态
Job 一共包含六个状态:
- 新创建 New
- 活跃 Active
- 完成中 Completing
- 已完成 Completed
- 取消中 Cancelling
- 已取消 Cancelled
我们无法直接访问这些状态,但我们可以访问 Job 的属性:isActive、isCancelled 和 isCompleted,通过这些属性可以得知 Job 当前的状态。
三、Job 的生命周期
通常来说,Job 的生命周期会经过四个状态:New → Active → Completing → Completed。
协程在 Active 和 Completing 时可以响应取消命令。如果收到了取消命令,协程会马上经过 Cancelling → Cancelled 生命周期。
四、Job 的取消
当协程任务不再被需要时,可以调用协程的 cancel() 函数取消协程任务。
对协程作用域调用 cancel() 函数会取消此作用域内的所有子协程。如果需要取消单个子任务,可以调用单个子任务的 cancel() 函数,取消单个子协程不会影响其兄弟协程继续执行。
协程取消时,协程内部的代码可以 catch 到 CancellationException。
runBlocking {
val job = launch(start = CoroutineStart.ATOMIC) {
try {
println("start")
delay(500)
println("done")
} catch (e: CancellationException) {
e.printStackTrace()
}
}
job.cancel()
}
这段程序中,我们指定协程的启动模式为 CoroutineStart.ATOMIC,以使得 job 任务在 cancel() 前一定能够得到执行,这样我们的 try-catch 代码块才能捕获到异常,运行这段程序,输出如下:
start
kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job="coroutine#2":StandaloneCoroutine{Cancelling}@39ac0c0a
at kotlinx.coroutines.JobSupport.cancel(JobSupport.kt:1578)
...
可以看出,捕获到了 JobCancellationException 异常,这个异常是 CancellationException 的一个子类。
五、判断 Job 的状态
协程取消后,Job 的 isActive 属性为 false,所以我们可以用这个属性检查 Job 是否处于活跃状态。
除此之外,还可以使用 ensureActive() 函数检查 Job 是否处于活跃状态,如果 Job 处于非活跃状态,这个方法会立即抛出 CancellationException 异常。
yield() 函数也可以检查 Job 是否处于活跃状态,和 ensureActive() 类似,如果 Job 处于非活跃状态,yield() 函数也会立即抛出 CancellationException 异常。
不同点在于,yield() 函数还会尝试让出线程的执行权,给其他协程提供执行机会。yield() 函数的使用场景是:如果当前任务是一个 CPU 密集型任务,执行时会占用大量 CPU 资源,那么当前函数执行时可能会导致其他任务无法及时得到执行。所以 yield() 函数相当于询问其他任务:你们需要执行吗?如果需要的话你们先执行。这样就能避免自己把 CPU 长时间抢占。
看一个协程取消的例子:
runBlocking {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (i < 5) {
if (System.currentTimeMillis() >= nextPrintTime) {
println("I'm sleeping $i")
i++
nextPrintTime += 500
}
}
}
delay(1200)
println("Time to finish")
job.cancelAndJoin()
println("Done")
}
在这个例子中,job 是一个 CPU 密集型任务,我们将 job 的调度器设置为 Dispatchers.Default。
job 任务会不断地循环执行,不断地获取当前时间,每隔 500ms 打印一次,打印 5 次后退出。在 1200ms 后,我们调用了 job 的 cancelAndJoin() 函数。
看起来 job 任务只会执行三次打印,就会被 cancel() 掉。但实际执行结果如下:
I'm sleeping 0
I'm sleeping 1
I'm sleeping 2
Time to finish
I'm sleeping 3
I'm sleeping 4
Done
可以看出,job 任务并没有按我们设想的那样打印三次就被 cancel。而是在调用 cancel 方法后,仍然在继续打印,直到 5 次打印完才退出。这是为什么呢?
这就是因为 job 是一个 CPU 密集型任务,一直抢占着 CPU,使得 cancel() 命令来不及调度。
要解决这个问题,我们可以在循环时使用 isActive 属性检查 job 是否处于活跃状态:
fun main() {
runBlocking {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (i < 5 && isActive) {
if (System.currentTimeMillis() >= nextPrintTime) {
println("I'm sleeping $i")
i++
nextPrintTime += 500
}
}
}
delay(1200)
println("Time to finish")
job.cancelAndJoin()
println("Done")
}
}
运行程序,输出如下:
I'm sleeping 0
I'm sleeping 1
I'm sleeping 2
Time to finish
Done
可以看出,这次 job 任务就及时退出了。
用 ensureActive() 或 yield() 可以达到一样的效果:
while (i < 5) {
ensureActive()
...
}
while (i < 5) {
yield()
...
}
因为这两个函数也会检查 Job 任务当前的状态,如果是非活跃状态,协程就会抛出 CancellationException 异常,不再继续执行了。
有读者可能会问了,既然抛出了异常,为什么没有导致进程崩溃呢?
这是因为当子协程将这个异常抛到作用域中时,协程的作用域认为 CancellationException 异常是一个「正常」的异常,所以并没有往外继续抛出,作用域直接将其静默处理掉了。
六、取消 Job 时释放资源
如果协程取消时,协程中的一些资源需要释放,那么可以用 try 语句包裹协程中的代码块,并在 finally 中完成资源的释放。
因为前文说过,cancel() 时协程中的代码块会收到 CancellationException,这个异常是可以被协程内部捕获的。
也可以使用 Kotlin 中的 use{} 函数释放资源。但 use{} 函数只能用于实现了 Closeable 接口的对象。
有读者可能又会问了,如果我们在 finally 中继续调用挂起函数会怎么样呢?
如果在 finally 中直接调用挂起函数,是不能使得协程继续挂起的,看一个例子:
runBlocking {
val job = launch {
try {
delay(1000)
} finally {
println("finally start")
delay(1000)
println("finally end")
}
}
yield()
job.cancelAndJoin()
}
在这个例子中,我们在 finally 中继续调用了 delay() 函数。yield() 函数的作用是保证 job 任务能够得到执行,和前文中指定启动模式为 CoroutineStart.ATOMIC 的效果是类似的。
运行程序,输出如下:
finally start
可以看出,finally 中的 delay() 函数并没有成功挂起,协程直接被 cancel 掉了。如果我们在这个 delay 函数这里加上 try-catch,仍然能捕获到 CancelationException 异常:
runBlocking {
val job = launch {
try {
delay(1000)
} finally {
try {
println("finally start")
delay(1000)
println("finally end")
} catch (e: CancellationException) {
e.printStackTrace()
}
}
}
yield()
job.cancelAndJoin()
}
运行程序,输出如下:
finally start
kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job="coroutine#2":StandaloneCoroutine{Cancelling}@3bd323e9
at kotlinx.coroutines.JobSupport.cancel(JobSupport.kt:1578)
...
想要 delay() 函继续挂起,可以把这块代码加上 WithContext(NonCancellable):
fun main() {
runBlocking {
val job = launch {
try {
delay(1000)
} finally {
println("finally start")
withContext(NonCancellable) {
println("delay start")
delay(1000)
println("delay end")
}
println("finally end")
}
}
delay(500)
job.cancelAndJoin()
}
}
运行程序,输出如下:
finally start
delay start
delay end
finally end
可以看到,这次 delay() 函数成功挂起了。
需要说明的是,withContext(NonCancellable) 不一定非要用到 finally 代码块中,如果我们希望协程中的某个代码块不响应 cancel() 命令,就可以使用 withContext(NonCancellable)。
七、Job 超时处理
使用 withTimeout 或 withTimeoutOrNull 可以指定 Job 任务的超时时间。两者的区别在于:
- withTimeout 函数会在超时后抛出一个超时异常 TimeoutCancellationException
- withTimeoutOrNull 函数会在超时后返回一个 null 值
runBlocking {
try {
withTimeout(500) {
launch {
delay(1000)
}
}
} catch (e: TimeoutCancellationException) {
e.printStackTrace()
}
}
运行程序,输出如下:
kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 500 ms
(Coroutine boundary)
...
可以看到,TimeoutCancellationException 被成功捕获了。
再试一下 withTimeoutOrNull 函数
runBlocking {
val result = withTimeoutOrNull(500) {
launch {
delay(1000)
}
}
println("result: $result")
}
运行程序,输出如下:
result: null
可以看到,超时后,withTimeoutOrNull 函数确实返回了一个 null 值。
如果任务没有超时,withTimeoutOrNull 函数会将代码块最后一行作为返回值。
八、小结
本文介绍了协程创建后生成的 Job 对象,Job 对象有其生命周期,通过检查其属性值可以判断 Job 当前所在的生命周期。
本文还介绍了 Job 取消时的表现,以及 Job 的超时处理。