Kotlin 协程中的 Job 结构化并发与取消
Job 是 Kotlin 协程生命周期管理的核心。每个由协程构建器创建的协程都有自己的 Job;父子 Job 组成一棵任务树,使父协程能够等待子协程,并让取消和异常按照明确的规则传播。本章从 Job 的状态开始,依次说明父子关系、等待、取消、清理以及回调 API 的可取消封装。
1 结构化并发依赖 Job
结构化并发的父子关系会产生四个重要结果:
- 子协程继承父协程的大部分上下文元素。
- 父协程会等待所有子协程结束。
- 父协程被取消时,所有子协程也会被取消。
- 普通子协程抛出未处理的非
CancellationException异常时,异常会使父任务失败,父任务随后取消其他子任务。
下面的子协程继承了父协程的 CoroutineName:
fun main(): Unit = runBlocking(CoroutineName("main")) {
println(coroutineContext[CoroutineName]?.name) // main
launch {
delay(1_000)
println(coroutineContext[CoroutineName]?.name) // main
}
}
这里需要区分普通上下文元素和 Job:子协程可以继续使用父协程传下来的 CoroutineName,但不会与父协程共用同一个 Job。launch 会为子协程创建新的 Job,并把父协程的 Job 设为它的父任务。
runBlocking 的 Job
└── launch 的 Job
这棵 Job 树才是结构化并发的实际载体。代码块的嵌套只是表面形式,等待、取消和异常传播都依赖背后的父子 Job 关系。
2 Job 的生命周期
从概念上说,Job 表示一个具有生命周期、可以取消的任务。从形式上说,Job 是接口,但它规定了明确的状态和行为。
一个任务可能经历下面的状态变化:
New ── start ──> Active ── 协程体结束 ──> Completing ── 子任务全部结束 ──> Completed
│ │
└──── cancel 或 fail ─────────┴────> Cancelling ── 清理结束 ──> Cancelled
| 状态 | isActive | isCompleted | isCancelled | 含义 |
|---|---|---|---|---|
| New | false | false | false | 惰性协程已创建但尚未启动 |
| Active | true | false | false | 已启动且尚未完成或取消 |
| Completing | true | false | false | 协程体已结束,正在等待子任务 |
| Cancelling | false | false | true | 已收到取消或失败信号,正在清理 |
| Cancelled | false | true | true | 已取消或失败并彻底结束 |
| Completed | false | true | false | 已正常结束 |
Active 不表示协程此刻一定占用线程。协程在 delay()、await() 等调用处挂起时,其 Job 通常仍然是 Active。
下面的代码展示了手动创建的 Job、默认启动的协程和惰性启动的协程:
suspend fun main() = coroutineScope {
// Job() 返回 CompletableJob,默认处于 Active 状态
val manualJob = Job()
println(manualJob) // 类似 JobImpl{Active}@...
manualJob.complete()
println(manualJob) // 类似 JobImpl{Completed}@...
// launch 默认使用 CoroutineStart.DEFAULT
val activeJob = launch {
delay(1_000)
}
println(activeJob) // 类似 StandaloneCoroutine{Active}@...
activeJob.join()
println(activeJob) // 类似 StandaloneCoroutine{Completed}@...
// 惰性协程创建后处于 New 状态
val lazyJob = launch(start = CoroutineStart.LAZY) {
delay(1_000)
}
println(lazyJob) // 类似 LazyStandaloneCoroutine{New}@...
lazyJob.start()
println(lazyJob) // 类似 LazyStandaloneCoroutine{Active}@...
lazyJob.join()
println(lazyJob) // 类似 LazyStandaloneCoroutine{Completed}@...
}
具体类名和对象标识属于实现细节,不同版本可能不同。判断状态时应优先使用 isActive、isCompleted 和 isCancelled。
3 协程构建器如何创建 Job
launch 返回 Job:
fun main(): Unit = runBlocking {
val job: Job = launch {
delay(1_000)
println("Test")
}
}
async 返回 Deferred<T>。Deferred<T> 继承 Job,因此既能管理任务生命周期,也能通过 await() 获取计算结果:
fun main(): Unit = runBlocking {
val deferred: Deferred<String> = async {
delay(1_000)
"Test"
}
val job: Job = deferred
println(deferred.await()) // Test
}
当前协程的 Job 保存在 CoroutineContext 中:
fun main(): Unit = runBlocking {
val nullableJob: Job? = coroutineContext[Job]
val currentJob: Job = coroutineContext.job
println(currentJob.isActive) // true
}
两种访问方式的差别是:coroutineContext[Job] 找不到元素时返回 null,而 coroutineContext.job 找不到时抛出异常。
3.1 Job 不会被直接复用
每个协程构建器都会创建一个新的 Job。来自父上下文或构建器参数的 Job 会成为新 Job 的父任务,而不是直接成为新协程自己的 Job。
fun main(): Unit = runBlocking {
val name = CoroutineName("Some name")
val parent = Job()
val child = launch(name + parent) {
val childName = coroutineContext[CoroutineName]
val childJob = coroutineContext[Job]
println(childName == name) // true
println(childJob == parent) // false
println(childJob in parent.children) // true
}
child.join()
}
这个例子用于观察机制。实际项目中不应随意把独立的 Job() 传给已有作用域的 launch 或 async。
3.2 传入独立 Job 会切断原父子关系
fun main(): Unit = runBlocking {
launch(Job()) {
delay(1_000)
println("Will not be printed")
}
}
传入的 Job() 替换了 runBlocking 上下文中的父 Job。结构因此变为两棵互不相连的树:
runBlocking Job
独立 Job
└── launch Job
runBlocking 看不到这个 launch,因此不会等待它。主程序可能在延迟结束前退出。这样还会失去父作用域取消子任务、子任务失败通知父任务等结构化并发能力。
通常应该让构建器自动建立关系:
coroutineScope {
launch {
// 自动成为 coroutineScope 的子任务
}
launch {
// 自动成为 coroutineScope 的子任务
}
}
4 等待协程和子任务
join() 是挂起函数。它会挂起调用者,直到目标 Job 进入 Completed 或 Cancelled 状态,但不会阻塞执行线程。
fun main(): Unit = runBlocking {
val job1 = launch {
delay(1_000)
println("Test1")
}
val job2 = launch {
delay(2_000)
println("Test2")
}
job1.join()
job2.join()
println("All tests are done")
}
两个任务创建后立即并发执行。job1.join() 只等待第一个任务,不会阻止第二个任务继续运行,因此总耗时约为两秒。
children 属性可以枚举一个 Job 的直接子任务:
fun main(): Unit = runBlocking {
launch {
delay(1_000)
println("Test1")
}
launch {
delay(2_000)
println("Test2")
}
val children: Sequence<Job> = coroutineContext.job.children
println("Number of children: ${children.count()}")
coroutineContext.job.children.forEach { child ->
child.join()
}
println("All tests are done")
}
注意,Sequence 可能随任务状态变化。上面重新读取了一次 children,避免在计数之后继续使用已经消费过的序列。
5 Job 工厂函数和 CompletableJob
下面的 Job() 不是构造函数,而是工厂函数,因为 Job 本身是接口:
public fun Job(parent: Job? = null): CompletableJob
它返回 CompletableJob,并提供两个重要方法:
fun complete(): Boolean
fun completeExceptionally(
exception: Throwable
): Boolean
5.1 为什么直接 join Job 会永久等待
suspend fun main(): Unit = coroutineScope {
val job = Job()
launch(job) {
delay(1_000)
println("Text 1")
}
launch(job) {
delay(2_000)
println("Text 2")
}
job.join() // 如果没有 complete() 或 cancel(),会一直等待
println("Will not be printed")
}
Job() 创建的任务没有协程体,即使当前所有子任务都结束,它仍然保持 Active,因为它仍可接收新的子任务。只有显式调用 complete() 或 cancel(),它才会进入完成流程。
如果只想等待当前子任务,可以逐个等待:
job.children.forEach { child ->
child.join()
}
如果已经创建完所有子任务,可以让父任务开始正常完成,再等待整个任务树:
suspend fun main(): Unit = coroutineScope {
val job = Job()
launch(job) {
delay(1_000)
println("Text 1")
}
launch(job) {
delay(2_000)
println("Text 2")
}
job.complete()
job.join()
}
complete() 不会取消已经存在的子任务。父 Job 进入 Completing,等待所有现有子任务结束,然后进入 Completed。完成流程开始后,新提交的子任务不会正常运行。
completeExceptionally(exception) 则让任务异常完成,并取消已有子任务:
fun main() = runBlocking {
val job = Job()
launch(job) {
repeat(5) { number ->
delay(200)
println("Rep$number")
}
}
launch {
delay(500)
job.completeExceptionally(
IllegalStateException("Task failed")
)
}
job.join()
println("Done")
}
6 取消的基本机制
Kotlin 协程采用协作式取消。cancel() 不会强制杀死线程,而是把取消状态写入 Job,并把取消信号向所有子任务传播。协程随后在可取消挂起点或显式状态检查处响应取消。
suspend fun main(): Unit = coroutineScope {
val job = launch {
repeat(1_000) { i ->
delay(200)
println("Printing $i")
}
}
delay(1_100)
job.cancel()
job.join()
println("Cancelled successfully")
}
典型输出为:
Printing 0
Printing 1
Printing 2
Printing 3
Printing 4
Cancelled successfully
取消传播方向需要准确理解:
取消父 Job
├── 取消子 Job A
├── 取消子 Job B
└── 递归取消更深层的 Job
正常取消某个子任务不会使父任务失败。子任务抛出未处理的非 CancellationException 异常,才会在普通 Job 关系中向父任务传播失败。
6.1 cancel join 和 cancelAndJoin
cancel() 只发送取消信号,不等待清理完成。join() 只等待目标任务结束。因此,需要“取消并等到彻底结束”时应组合调用:
job.cancel()
job.join()
协程库提供了更简洁的扩展函数:
job.cancelAndJoin()
其概念实现是:
public suspend fun Job.cancelAndJoin() {
cancel()
return join()
}
等待很重要,因为被取消的协程可能正在执行不可取消的同步代码:
suspend fun main() = coroutineScope {
val job = launch {
repeat(1_000) { i ->
delay(100)
Thread.sleep(100) // 阻塞操作,不检查协程取消
println("Printing $i")
}
}
delay(1_000)
job.cancelAndJoin()
println("Cancelled successfully")
}
如果只调用 cancel(),后面的日志可能先于目标协程的最后一次打印出现。cancelAndJoin() 则保证目标任务完成取消以后再继续。
6.2 Android 中的生命周期取消
现代 Android 项目通常使用 viewModelScope。ViewModel 被清理时,这个作用域会自动取消:
class ProfileViewModel : ViewModel() {
fun onCreate() {
viewModelScope.launch {
loadUserData()
}
}
private suspend fun loadUserData() {
// 加载用户数据
}
}
如果必须手动管理作用域,应在生命周期结束时取消整个作用域:
class ProfileViewModel : ViewModel() {
private val scope = CoroutineScope(
SupervisorJob() + Dispatchers.Main
)
override fun onCleared() {
scope.cancel()
}
}
cancelChildren() 只取消当前子任务,父 Job 仍然可用;scope.cancel() 会取消根 Job 及其所有子任务,并阻止该作用域继续正常启动任务。
7 取消如何在协程内部发生
当 Job 被取消时,它先进入 Cancelling。协程在下一个可取消挂起点抛出 CancellationException:
suspend fun main(): Unit = coroutineScope {
val job = launch {
try {
repeat(1_000) { i ->
delay(200)
println("Printing $i")
}
} catch (exception: CancellationException) {
println(exception)
throw exception // 保持取消传播
}
}
delay(1_100)
job.cancelAndJoin()
println("Cancelled successfully")
}
如果捕获 CancellationException 后不重新抛出,后续代码可能错误地继续执行,或者上层无法按预期观察取消。除非有明确理由终止取消传播,否则处理完成后应重新抛出。
7.1 使用 finally 清理资源
取消依靠异常展开调用栈,所以 finally 仍然会执行。普通同步清理应直接放在这里:
suspend fun main(): Unit = coroutineScope {
val job = launch {
try {
delay(2_000)
println("Done")
} finally {
closeFile()
unregisterListener()
println("Cleanup finished")
}
}
delay(1_000)
job.cancelAndJoin()
}
资源管理函数如 use 和 useLines 本身也依赖 finally 关闭资源,通常能够正确处理协程取消。
7.2 取消后不能直接执行可取消挂起清理
进入 finally 时,当前 Job 已经处于取消状态。同步代码可以继续执行,但 delay() 等可取消挂起函数会立即再次抛出 CancellationException;在该上下文中创建的普通子协程也会继承取消状态。
val job = launch {
try {
delay(2_000)
} finally {
println("Finally") // 可以执行
launch {
println("Will not be printed")
}
delay(1_000) // 再次抛出 CancellationException
println("Will not be printed")
}
}
如果清理过程本身必须调用挂起函数,应把最小必要范围放入 withContext(NonCancellable):
suspend fun main(): Unit = coroutineScope {
val job = launch {
try {
delay(200)
println("Coroutine finished")
} finally {
println("Finally")
withContext(NonCancellable) {
delay(1_000)
suspendCleanup()
println("Cleanup done")
}
}
}
delay(100)
job.cancelAndJoin()
println("Done")
}
不要使用 launch(NonCancellable) 启动独立任务,因为这会切断正常的父子关系。NonCancellable 应只用于必须完成的挂起式清理,而且范围应尽可能小。
8 使用 invokeOnCompletion 观察结束
invokeOnCompletion() 为 Job 注册完成处理器。当任务正常完成、取消或失败并进入终止状态时,处理器会执行一次:
suspend fun main(): Unit = coroutineScope {
val job = launch {
delay(1_000)
}
job.invokeOnCompletion { cause: Throwable? ->
when (cause) {
null -> println("Completed normally")
is CancellationException -> {
println("Cancelled: ${cause.message}")
}
else -> println("Failed: $cause")
}
}
delay(400)
job.cancelAndJoin()
}
cause 的含义如下:
| 值 | 含义 |
|---|---|
null | 正常完成 |
CancellationException | 任务被取消 |
| 其他异常 | 任务因该异常失败 |
如果注册时任务已经完成,处理器通常会立即执行。处理器在任务完成流程中同步调用,但不能假定它运行在哪条线程上。因此,处理器应快速、非阻塞、线程安全,且不应直接执行挂起函数。需要挂起式清理时,仍应在协程体的 finally 中使用 withContext(NonCancellable)。
9 suspend 不等于支持取消
suspend 只表示函数可以挂起当前协程,也可以调用其他挂起函数。它不表示函数每次调用都一定挂起,不表示会创建新协程,也不保证响应取消。
suspend fun calculate(): Int {
return 42
}
这个函数虽然带有 suspend,但既没有真正挂起,也没有检查当前 Job 的状态。因此,它不是取消检查点。
常见的可取消挂起函数包括:
delay()yield()Job.join()Deferred.await()Channel.send()和Channel.receive()Mutex.lock()
具体取消保证仍应以相应 API 的文档为准。
suspendCoroutine 可以挂起协程,但本身不把协程取消传递给底层回调任务:
suspend fun waitForCallback(): String =
suspendCoroutine { continuation ->
someApi.request { result ->
continuation.resume(result)
}
}
如果等待期间协程被取消,someApi.request 仍可能继续运行。需要取消支持时,应使用 suspendCancellableCoroutine 并显式取消底层任务。
10 让没有挂起点的任务响应取消
Thread.sleep()、同步文件操作以及长时间 CPU 计算都不会自动检查协程取消。下面的任务即使收到取消信号,也可能继续执行完整个循环:
suspend fun main(): Unit = coroutineScope {
val job = launch(Dispatchers.Default) {
repeat(1_000) { i ->
Thread.sleep(200)
println("Printing $i")
}
}
delay(1_000)
job.cancelAndJoin()
}
可以使用 isActive、ensureActive() 或 yield() 让长任务协作式响应取消。
10.1 isActive
val job = launch(Dispatchers.Default) {
while (isActive) {
performPartOfCalculation()
}
}
isActive 返回布尔值,由代码决定何时结束和如何清理。
10.2 ensureActive
val job = launch(Dispatchers.Default) {
while (true) {
performPartOfCalculation()
ensureActive()
}
}
当 Job 不再活跃时,ensureActive() 立即抛出 CancellationException。它只检查状态,不主动让出线程,适合 CPU 密集型循环。
10.3 yield
suspend fun cpuIntensiveOperations() =
withContext(Dispatchers.Default) {
cpuIntensiveOperation1()
yield()
cpuIntensiveOperation2()
yield()
cpuIntensiveOperation3()
}
yield() 会检查取消,并把执行机会交还调度器。当前协程稍后重新参与调度,恢复时可能运行在同一线程池中的另一条线程上。
| 方式 | 检查取消 | 主动让出执行机会 | 取消后的行为 |
|---|---|---|---|
isActive | 是 | 否 | 返回 false |
ensureActive() | 是 | 否 | 抛出 CancellationException |
yield() | 是 | 是 | 抛出 CancellationException |
只需要检查取消时,优先使用轻量的 ensureActive();还需要改善协程之间的调度公平性时,可以使用 yield()。
11 使用 suspendCancellableCoroutine 封装回调
suspendCancellableCoroutine 提供 CancellableContinuation<T>。除了 resume 和 resumeWithException,它还允许通过 invokeOnCancellation 注册取消处理器。
suspend fun someTask(): Unit =
suspendCancellableCoroutine { continuation ->
val operation = startOperation(
onSuccess = {
continuation.resume(Unit)
},
onFailure = { error ->
continuation.resumeWithException(error)
}
)
continuation.invokeOnCancellation {
operation.cancel()
}
}
当调用者取消协程时,取消处理器会停止底层任务,避免出现“协程已结束,但网络请求或监听器仍在运行”的资源泄漏。
下面以 Retrofit 的回调 API 为例:
suspend fun getOrganizationRepos(
organization: String
): List<Repo> =
suspendCancellableCoroutine { continuation ->
val call = apiService.getOrganizationRepos(organization)
call.enqueue(
object : Callback<List<Repo>> {
override fun onResponse(
call: Call<List<Repo>>,
response: Response<List<Repo>>
) {
val body = response.body()
if (response.isSuccessful && body != null) {
continuation.resume(body)
} else {
continuation.resumeWithException(
ApiException(
response.code(),
response.message()
)
)
}
}
override fun onFailure(
call: Call<List<Repo>>,
throwable: Throwable
) {
continuation.resumeWithException(throwable)
}
}
)
continuation.invokeOnCancellation {
call.cancel()
}
}
执行关系如下:
调用协程被取消
↓
CancellableContinuation 收到取消
↓
执行 invokeOnCancellation
↓
调用 Retrofit Call.cancel()
↓
底层网络请求停止
Retrofit 已原生支持挂起函数时,应优先直接声明:
interface GithubApi {
@GET("orgs/{organization}/repos?per_page=100")
suspend fun getOrganizationRepos(
@Path("organization") organization: String
): List<Repo>
}
只有第三方库仍然只提供回调 API 时,才需要手动使用 suspendCancellableCoroutine 进行桥接。封装时还必须考虑回调重复触发、取消与回调同时发生、底层错误映射等竞态问题。
12 实践结论
理解 Kotlin 协程取消,可以归纳为以下原则:
- 每个协程拥有自己的
Job,父子Job构成结构化并发的任务树。 - 父任务等待子任务;取消从父任务向子任务传播。
- 普通子任务的未处理异常会使父任务失败;正常的
CancellationException不表示程序错误。 cancel()只发出信号;需要确认任务已结束时使用cancelAndJoin()。- 取消是协作式的。长时间计算必须定期调用
ensureActive()、检查isActive或使用合适的可取消挂起点。 - 同步清理放在
finally;必须挂起的清理使用范围尽可能小的withContext(NonCancellable)。 - 回调 API 应通过
suspendCancellableCoroutine把协程取消传递到底层任务。 - 不要随意向现有
launch或async传入独立Job(),否则会切断原来的结构化并发关系。
这些规则共同保证任务不会脱离生命周期继续运行,并让取消、资源释放和错误传播保持可预测。