Kotlin 协程中的 Job 结构化并发与取消

21 阅读8分钟

Kotlin 协程中的 Job 结构化并发与取消

Job 是 Kotlin 协程生命周期管理的核心。每个由协程构建器创建的协程都有自己的 Job;父子 Job 组成一棵任务树,使父协程能够等待子协程,并让取消和异常按照明确的规则传播。本章从 Job 的状态开始,依次说明父子关系、等待、取消、清理以及回调 API 的可取消封装。

1 结构化并发依赖 Job

结构化并发的父子关系会产生四个重要结果:

  1. 子协程继承父协程的大部分上下文元素。
  2. 父协程会等待所有子协程结束。
  3. 父协程被取消时,所有子协程也会被取消。
  4. 普通子协程抛出未处理的非 CancellationException 异常时,异常会使父任务失败,父任务随后取消其他子任务。

下面的子协程继承了父协程的 CoroutineName

fun main(): Unit = runBlocking(CoroutineName("main")) {
    println(coroutineContext[CoroutineName]?.name) // main

    launch {
        delay(1_000)
        println(coroutineContext[CoroutineName]?.name) // main
    }
}

这里需要区分普通上下文元素和 Job:子协程可以继续使用父协程传下来的 CoroutineName,但不会与父协程共用同一个 Joblaunch 会为子协程创建新的 Job,并把父协程的 Job 设为它的父任务。

runBlocking 的 Job
└── launch 的 Job

这棵 Job 树才是结构化并发的实际载体。代码块的嵌套只是表面形式,等待、取消和异常传播都依赖背后的父子 Job 关系。

2 Job 的生命周期

从概念上说,Job 表示一个具有生命周期、可以取消的任务。从形式上说,Job 是接口,但它规定了明确的状态和行为。

一个任务可能经历下面的状态变化:

New ── start ──> Active ── 协程体结束 ──> Completing ── 子任务全部结束 ──> Completed
                   │                             │
                   └──── cancel 或 fail ─────────┴────> Cancelling ── 清理结束 ──> Cancelled
状态isActiveisCompletedisCancelled含义
Newfalsefalsefalse惰性协程已创建但尚未启动
Activetruefalsefalse已启动且尚未完成或取消
Completingtruefalsefalse协程体已结束,正在等待子任务
Cancellingfalsefalsetrue已收到取消或失败信号,正在清理
Cancelledfalsetruetrue已取消或失败并彻底结束
Completedfalsetruefalse已正常结束

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}@...
}

具体类名和对象标识属于实现细节,不同版本可能不同。判断状态时应优先使用 isActiveisCompletedisCancelled

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() 传给已有作用域的 launchasync

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 进入 CompletedCancelled 状态,但不会阻塞执行线程。

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 项目通常使用 viewModelScopeViewModel 被清理时,这个作用域会自动取消:

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()
}

资源管理函数如 useuseLines 本身也依赖 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()
}

可以使用 isActiveensureActive()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>。除了 resumeresumeWithException,它还允许通过 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 协程取消,可以归纳为以下原则:

  1. 每个协程拥有自己的 Job,父子 Job 构成结构化并发的任务树。
  2. 父任务等待子任务;取消从父任务向子任务传播。
  3. 普通子任务的未处理异常会使父任务失败;正常的 CancellationException 不表示程序错误。
  4. cancel() 只发出信号;需要确认任务已结束时使用 cancelAndJoin()
  5. 取消是协作式的。长时间计算必须定期调用 ensureActive()、检查 isActive 或使用合适的可取消挂起点。
  6. 同步清理放在 finally;必须挂起的清理使用范围尽可能小的 withContext(NonCancellable)
  7. 回调 API 应通过 suspendCancellableCoroutine 把协程取消传递到底层任务。
  8. 不要随意向现有 launchasync 传入独立 Job(),否则会切断原来的结构化并发关系。

这些规则共同保证任务不会脱离生命周期继续运行,并让取消、资源释放和错误传播保持可预测。