Kotlin 协程 (四) ——— Job 对象

1,343 阅读6分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

一、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 的生命周期

四、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 的超时处理。