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

文章来源声明: 原文作者:ttyyttemo; 来源站点:掘金; 原文链接:https://juejin.cn/post/7687972882229002281; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

把 Job 生命周期、结构化取消与 NonCancellable 清理讲得系统清晰,适合已会用协程、却常遇到取消不生效或回调泄漏的 Android 与后端开发者补齐认知。

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

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

1 结构化并发依赖 Job

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

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

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

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

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

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

runBlocking 的 Job
└── launch 的 Job

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

2 Job 的生命周期

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

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

New ── start ──> Active ── 协程体结束 ──> Completing ── 子任务全部结束 ──> Completed
                   │                             │
                   └──── cancel 或 fail ─────────┴────> Cancelling ── 清理结束 ──> Cancelled

状态`isActive``isCompleted``isCancelled`含义
Newfalsefalsefalse惰性协程已创建但尚未启动
Activetruefalsefalse已启动且尚未完成或取消
Completingtruefalsefalse协程体已结束,正在等待子任务
Cancellingfalsefalsetrue已收到取消或失败信号,正在清理
Cancelledfalsetruetrue已取消或失败并彻底结束
Completedfalsetruefalse已正常结束

Active 不表示协程此刻一定占用线程。协程在 delay()await() 等调用处挂起时,其 Job 通常仍然是 Active

下面的代码展示了手动创建的 Job、默认启动的协程和惰性启动的协程:

<span>suspend</span> <span><span>fun</span> <span>main</span><span>()</span></span> = coroutineScope {
    <span>// Job() 返回 CompletableJob,默认处于 Active 状态</span>
    <span>val</span> manualJob = Job()
    println(manualJob) <span>// 类似 JobImpl{Active}@...</span>

    manualJob.complete()
    println(manualJob) <span>// 类似 JobImpl{Completed}@...</span>

    <span>// launch 默认使用 CoroutineStart.DEFAULT</span>
    <span>val</span> activeJob = launch {
        delay(<span>1_000</span>)
    }
    println(activeJob) <span>// 类似 StandaloneCoroutine{Active}@...</span>

    activeJob.join()
    println(activeJob) <span>// 类似 StandaloneCoroutine{Completed}@...</span>

    <span>// 惰性协程创建后处于 New 状态</span>
    <span>val</span> lazyJob = launch(start = CoroutineStart.LAZY) {
        delay(<span>1_000</span>)
    }
    println(lazyJob) <span>// 类似 LazyStandaloneCoroutine{New}@...</span>

    lazyJob.start()
    println(lazyJob) <span>// 类似 LazyStandaloneCoroutine{Active}@...</span>

    lazyJob.join()
    println(lazyJob) <span>// 类似 LazyStandaloneCoroutine{Completed}@...</span>
}

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

3 协程构建器如何创建 Job

launch 返回 Job

<span><span>fun</span> <span>main</span><span>()</span></span>: <span>Unit</span> = runBlocking {
    <span>val</span> job: Job = launch {
        delay(<span>1_000</span>)
        println(<span>"Test"</span>)
    }
}

async 返回 Deferred<T>Deferred<T> 继承 Job,因此既能管理任务生命周期,也能通过 await() 获取计算结果:

<span><span>fun</span> <span>main</span><span>()</span></span>: <span>Unit</span> = runBlocking {
    <span>val</span> deferred: Deferred<String> = async {
        delay(<span>1_000</span>)
        <span>"Test"</span>
    }

    <span>val</span> job: Job = deferred
    println(deferred.await()) <span>// Test</span>
}

当前协程的 Job 保存在 CoroutineContext 中:

<span><span>fun</span> <span>main</span><span>()</span></span>: <span>Unit</span> = runBlocking {
    <span>val</span> nullableJob: Job? = coroutineContext[Job]
    <span>val</span> currentJob: Job = coroutineContext.job

    println(currentJob.isActive) <span>// true</span>
}

两种访问方式的差别是:coroutineContext[Job] 找不到元素时返回 null,而 coroutineContext.job 找不到时抛出异常。

3.1 Job 不会被直接复用

每个协程构建器都会创建一个新的 Job。来自父上下文或构建器参数的 Job 会成为新 Job 的父任务,而不是直接成为新协程自己的 Job

<span><span>fun</span> <span>main</span><span>()</span></span>: <span>Unit</span> = runBlocking {
    <span>val</span> name = CoroutineName(<span>"Some name"</span>)
    <span>val</span> parent = Job()

    <span>val</span> child = launch(name + parent) {
        <span>val</span> childName = coroutineContext[CoroutineName]
        <span>val</span> childJob = coroutineContext[Job]

        println(childName == name)        <span>// true</span>
        println(childJob == parent)       <span>// false</span>
        println(childJob <span>in</span> parent.children) <span>// true</span>
    }

    child.join()
}

这个例子用于观察机制。实际项目中不应随意把独立的 Job() 传给已有作用域的 launchasync

3.2 传入独立 Job 会切断原父子关系

<span><span>fun</span> <span>main</span><span>()</span></span>: <span>Unit</span> = runBlocking {
    launch(Job()) {
        delay(<span>1_000</span>)
        println(<span>"Will not be printed"</span>)
    }
}

传入的 Job() 替换了 runBlocking 上下文中的父 Job。结构因此变为两棵互不相连的树:

runBlocking Job

独立 Job
└── launch Job

runBlocking 看不到这个 launch,因此不会等待它。主程序可能在延迟结束前退出。这样还会失去父作用域取消子任务、子任务失败通知父任务等结构化并发能力。

通常应该让构建器自动建立关系:

coroutineScope {
    launch {
        <span>// 自动成为 coroutineScope 的子任务</span>
    }

    launch {
        <span>// 自动成为 coroutineScope 的子任务</span>
    }
}

4 等待协程和子任务

join() 是挂起函数。它会挂起调用者,直到目标 Job 进入 CompletedCancelled 状态,但不会阻塞执行线程。

<span><span>fun</span> <span>main</span><span>()</span></span>: <span>Unit</span> = runBlocking {
    <span>val</span> job1 = launch {
        delay(<span>1_000</span>)
        println(<span>"Test1"</span>)
    }

    <span>val</span> job2 = launch {
        delay(<span>2_000</span>)
        println(<span>"Test2"</span>)
    }

    job1.join()
    job2.join()
    println(<span>"All tests are done"</span>)
}

两个任务创建后立即并发执行。job1.join() 只等待第一个任务,不会阻止第二个任务继续运行,因此总耗时约为两秒。

children 属性可以枚举一个 Job 的直接子任务:

<span><span>fun</span> <span>main</span><span>()</span></span>: <span>Unit</span> = runBlocking {
    launch {
        delay(<span>1_000</span>)
        println(<span>"Test1"</span>)
    }

    launch {
        delay(<span>2_000</span>)
        println(<span>"Test2"</span>)
    }

    <span>val</span> children: Sequence<Job> = coroutineContext.job.children
    println(<span>"Number of children: <span>${children.count()}</span>"</span>)

    coroutineContext.job.children.forEach { child ->
        child.join()
    }

    println(<span>"All tests are done"</span>)
}

注意,Sequence 可能随任务状态变化。上面重新读取了一次 children,避免在计数之后继续使用已经消费过的序列。

5 Job 工厂函数和 CompletableJob

下面的 Job() 不是构造函数,而是工厂函数,因为 Job 本身是接口:

<span>public</span> <span><span>fun</span> <span>Job</span><span>(parent: <span>Job</span>? = <span>null</span>)</span></span>: CompletableJob

它返回 CompletableJob,并提供两个重要方法:

<span><span>fun</span> <span>complete</span><span>()</span></span>: <span>Boolean</span>

<span><span>fun</span> <span>completeExceptionally</span><span>(
    exception: <span>Throwable</span>
)</span></span>: <span>Boolean</span>

5.1 为什么直接 join Job 会永久等待

<span>suspend</span> <span><span>fun</span> <span>main</span><span>()</span></span>: <span>Unit</span> = coroutineScope {
    <span>val</span> job = Job()

    launch(job) {
        delay(<span>1_000</span>)
        println(<span>"Text 1"</span>)
    }

    launch(job) {
        delay(<span>2_000</span>)
        println(<span>"Text 2"</span>)
    }

    job.join() <span>// 如果没有 complete() 或 cancel(),会一直等待</span>
    println(<span>"Will not be printed"</span>)
}

Job() 创建的任务没有协程体,即使当前所有子任务都结束,它仍然保持 Active,因为它仍可接收新的子任务。只有显式调用 complete()cancel(),它才会进入完成流程。

如果只想等待当前子任务,可以逐个等待:

job.children.forEach { child ->
    child.join()
}

如果已经创建完所有子任务,可以让父任务开始正常完成,再等待整个任务树:

<span>suspend</span> <span><span>fun</span> <span>main</span><span>()</span></span>: <span>Unit</span> = coroutineScope {
    <span>val</span> job = Job()

    launch(job) {
        delay(<span>1_000</span>)
        println(<span>"Text 1"</span>)
    }

    launch(job) {
        delay(<span>2_000</span>)
        println(<span>"Text 2"</span>)
    }

    job.complete()
    job.join()
}

complete() 不会取消已经存在的子任务。父 Job 进入 Completing,等待所有现有子任务结束,然后进入 Completed。完成流程开始后,新提交的子任务不会正常运行。

completeExceptionally(exception) 则让任务异常完成,并取消已有子任务:

<span><span>fun</span> <span>main</span><span>()</span></span> = runBlocking {
    <span>val</span> job = Job()

    launch(job) {
        repeat(<span>5</span>) { number ->
            delay(<span>200</span>)
            println(<span>"Rep<span>$number</span>"</span>)
        }
    }

    launch {
        delay(<span>500</span>)
        job.completeExceptionally(
            IllegalStateException(<span>"Task failed"</span>)
        )
    }

    job.join()
    println(<span>"Done"</span>)
}

6 取消的基本机制

Kotlin 协程采用协作式取消。cancel() 不会强制杀死线程,而是把取消状态写入 Job,并把取消信号向所有子任务传播。协程随后在可取消挂起点或显式状态检查处响应取消。

<span>suspend</span> <span><span>fun</span> <span>main</span><span>()</span></span>: <span>Unit</span> = coroutineScope {
    <span>val</span> job = launch {
        repeat(<span>1_000</span>) { i ->
            delay(<span>200</span>)
            println(<span>"Printing <span>$i</span>"</span>)
        }
    }

    delay(<span>1_100</span>)
    job.cancel()
    job.join()

    println(<span>"Cancelled successfully"</span>)
}

典型输出为:

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

其概念实现是:

<span>public</span> <span>suspend</span> <span><span>fun</span> Job.<span>cancelAndJoin</span><span>()</span></span> {
    cancel()
    <span>return</span> join()
}

等待很重要,因为被取消的协程可能正在执行不可取消的同步代码:

<span>suspend</span> <span><span>fun</span> <span>main</span><span>()</span></span> = coroutineScope {
    <span>val</span> job = launch {
        repeat(<span>1_000</span>) { i ->
            delay(<span>100</span>)
            Thread.sleep(<span>100</span>) <span>// 阻塞操作,不检查协程取消</span>
            println(<span>"Printing <span>$i</span>"</span>)
        }
    }

    delay(<span>1_000</span>)
    job.cancelAndJoin()
    println(<span>"Cancelled successfully"</span>)
}

如果只调用 cancel(),后面的日志可能先于目标协程的最后一次打印出现。cancelAndJoin() 则保证目标任务完成取消以后再继续。

6.2 Android 中的生命周期取消

现代 Android 项目通常使用 viewModelScopeViewModel 被清理时,这个作用域会自动取消:

<span>class</span> <span>ProfileViewModel</span> : <span>ViewModel</span>() {

    <span><span>fun</span> <span>onCreate</span><span>()</span></span> {
        viewModelScope.launch {
            loadUserData()
        }
    }

    <span>private</span> <span>suspend</span> <span><span>fun</span> <span>loadUserData</span><span>()</span></span> {
        <span>// 加载用户数据</span>
    }
}

如果必须手动管理作用域,应在生命周期结束时取消整个作用域:

<span>class</span> <span>ProfileViewModel</span> : <span>ViewModel</span>() {

    <span>private</span> <span>val</span> scope = CoroutineScope(
        SupervisorJob() + Dispatchers.Main
    )

    <span>override</span> <span><span>fun</span> <span>onCleared</span><span>()</span></span> {
        scope.cancel()
    }
}

cancelChildren() 只取消当前子任务,父 Job 仍然可用;scope.cancel() 会取消根 Job 及其所有子任务,并阻止该作用域继续正常启动任务。

7 取消如何在协程内部发生

Job 被取消时,它先进入 Cancelling。协程在下一个可取消挂起点抛出 CancellationException

<span>suspend</span> <span><span>fun</span> <span>main</span><span>()</span></span>: <span>Unit</span> = coroutineScope {
    <span>val</span> job = launch {
        <span>try</span> {
            repeat(<span>1_000</span>) { i ->
                delay(<span>200</span>)
                println(<span>"Printing <span>$i</span>"</span>)
            }
        } <span>catch</span> (exception: CancellationException) {
            println(exception)
            <span>throw</span> exception <span>// 保持取消传播</span>
        }
    }

    delay(<span>1_100</span>)
    job.cancelAndJoin()
    println(<span>"Cancelled successfully"</span>)
}

如果捕获 CancellationException 后不重新抛出,后续代码可能错误地继续执行,或者上层无法按预期观察取消。除非有明确理由终止取消传播,否则处理完成后应重新抛出。

7.1 使用 finally 清理资源

取消依靠异常展开调用栈,所以 finally 仍然会执行。普通同步清理应直接放在这里:

<span>suspend</span> <span><span>fun</span> <span>main</span><span>()</span></span>: <span>Unit</span> = coroutineScope {
    <span>val</span> job = launch {
        <span>try</span> {
            delay(<span>2_000</span>)
            println(<span>"Done"</span>)
        } <span>finally</span> {
            closeFile()
            unregisterListener()
            println(<span>"Cleanup finished"</span>)
        }
    }

    delay(<span>1_000</span>)
    job.cancelAndJoin()
}

资源管理函数如 useuseLines 本身也依赖 finally 关闭资源,通常能够正确处理协程取消。

7.2 取消后不能直接执行可取消挂起清理

进入 finally 时,当前 Job 已经处于取消状态。同步代码可以继续执行,但 delay() 等可取消挂起函数会立即再次抛出 CancellationException;在该上下文中创建的普通子协程也会继承取消状态。

<span>val</span> job = launch {
    <span>try</span> {
        delay(<span>2_000</span>)
    } <span>finally</span> {
        println(<span>"Finally"</span>) <span>// 可以执行</span>

        launch {
            println(<span>"Will not be printed"</span>)
        }

        delay(<span>1_000</span>) <span>// 再次抛出 CancellationException</span>
        println(<span>"Will not be printed"</span>)
    }
}

如果清理过程本身必须调用挂起函数,应把最小必要范围放入 withContext(NonCancellable)

<span>suspend</span> <span><span>fun</span> <span>main</span><span>()</span></span>: <span>Unit</span> = coroutineScope {
    <span>val</span> job = launch {
        <span>try</span> {
            delay(<span>200</span>)
            println(<span>"Coroutine finished"</span>)
        } <span>finally</span> {
            println(<span>"Finally"</span>)

            withContext(NonCancellable) {
                delay(<span>1_000</span>)
                suspendCleanup()
                println(<span>"Cleanup done"</span>)
            }
        }
    }

    delay(<span>100</span>)
    job.cancelAndJoin()
    println(<span>"Done"</span>)
}

不要使用 launch(NonCancellable) 启动独立任务,因为这会切断正常的父子关系。NonCancellable 应只用于必须完成的挂起式清理,而且范围应尽可能小。

8 使用 invokeOnCompletion 观察结束

invokeOnCompletion()Job 注册完成处理器。当任务正常完成、取消或失败并进入终止状态时,处理器会执行一次:

<span>suspend</span> <span><span>fun</span> <span>main</span><span>()</span></span>: <span>Unit</span> = coroutineScope {
    <span>val</span> job = launch {
        delay(<span>1_000</span>)
    }

    job.invokeOnCompletion { cause: Throwable? ->
        <span>when</span> (cause) {
            <span>null</span> -> println(<span>"Completed normally"</span>)
            <span>is</span> CancellationException -> {
                println(<span>"Cancelled: <span>${cause.message}</span>"</span>)
            }
            <span>else</span> -> println(<span>"Failed: <span>$cause</span>"</span>)
        }
    }

    delay(<span>400</span>)
    job.cancelAndJoin()
}

cause 的含义如下:

含义
`null`正常完成
`CancellationException`任务被取消
其他异常任务因该异常失败

如果注册时任务已经完成,处理器通常会立即执行。处理器在任务完成流程中同步调用,但不能假定它运行在哪条线程上。因此,处理器应快速、非阻塞、线程安全,且不应直接执行挂起函数。需要挂起式清理时,仍应在协程体的 finally 中使用 withContext(NonCancellable)

9 suspend 不等于支持取消

suspend 只表示函数可以挂起当前协程,也可以调用其他挂起函数。它不表示函数每次调用都一定挂起,不表示会创建新协程,也不保证响应取消。

<span>suspend</span> <span><span>fun</span> <span>calculate</span><span>()</span></span>: <span>Int</span> {
    <span>return</span> <span>42</span>
}

这个函数虽然带有 suspend,但既没有真正挂起,也没有检查当前 Job 的状态。因此,它不是取消检查点。

常见的可取消挂起函数包括:

  • delay()
  • yield()
  • Job.join()
  • Deferred.await()
  • Channel.send()Channel.receive()
  • Mutex.lock()

具体取消保证仍应以相应 API 的文档为准。

suspendCoroutine 可以挂起协程,但本身不把协程取消传递给底层回调任务:

<span>suspend</span> <span><span>fun</span> <span>waitForCallback</span><span>()</span></span>: String =
    suspendCoroutine { continuation ->
        someApi.request { result ->
            continuation.resume(result)
        }
    }

如果等待期间协程被取消,someApi.request 仍可能继续运行。需要取消支持时,应使用 suspendCancellableCoroutine 并显式取消底层任务。

10 让没有挂起点的任务响应取消

Thread.sleep()、同步文件操作以及长时间 CPU 计算都不会自动检查协程取消。下面的任务即使收到取消信号,也可能继续执行完整个循环:

<span>suspend</span> <span><span>fun</span> <span>main</span><span>()</span></span>: <span>Unit</span> = coroutineScope {
    <span>val</span> job = launch(Dispatchers.Default) {
        repeat(<span>1_000</span>) { i ->
            Thread.sleep(<span>200</span>)
            println(<span>"Printing <span>$i</span>"</span>)
        }
    }

    delay(<span>1_000</span>)
    job.cancelAndJoin()
}

可以使用 isActiveensureActive()yield() 让长任务协作式响应取消。

10.1 isActive

<span>val</span> job = launch(Dispatchers.Default) {
    <span>while</span> (isActive) {
        performPartOfCalculation()
    }
}

isActive 返回布尔值,由代码决定何时结束和如何清理。

10.2 ensureActive

<span>val</span> job = launch(Dispatchers.Default) {
    <span>while</span> (<span>true</span>) {
        performPartOfCalculation()
        ensureActive()
    }
}

Job 不再活跃时,ensureActive() 立即抛出 CancellationException。它只检查状态,不主动让出线程,适合 CPU 密集型循环。

10.3 yield

<span>suspend</span> <span><span>fun</span> <span>cpuIntensiveOperations</span><span>()</span></span> =
    withContext(Dispatchers.Default) {
        cpuIntensiveOperation1()
        yield()

        cpuIntensiveOperation2()
        yield()

        cpuIntensiveOperation3()
    }

yield() 会检查取消,并把执行机会交还调度器。当前协程稍后重新参与调度,恢复时可能运行在同一线程池中的另一条线程上。

方式检查取消主动让出执行机会取消后的行为
`isActive`返回 `false`
`ensureActive()`抛出 `CancellationException`
`yield()`抛出 `CancellationException`

只需要检查取消时,优先使用轻量的 ensureActive();还需要改善协程之间的调度公平性时,可以使用 yield()

11 使用 suspendCancellableCoroutine 封装回调

suspendCancellableCoroutine 提供 CancellableContinuation<T>。除了 resumeresumeWithException,它还允许通过 invokeOnCancellation 注册取消处理器。

<span>suspend</span> <span><span>fun</span> <span>someTask</span><span>()</span></span>: <span>Unit</span> =
    suspendCancellableCoroutine { continuation ->
        <span>val</span> operation = startOperation(
            onSuccess = {
                continuation.resume(<span>Unit</span>)
            },
            onFailure = { error ->
                continuation.resumeWithException(error)
            }
        )

        continuation.invokeOnCancellation {
            operation.cancel()
        }
    }

当调用者取消协程时,取消处理器会停止底层任务,避免出现“协程已结束,但网络请求或监听器仍在运行”的资源泄漏。

下面以 Retrofit 的回调 API 为例:

<span>suspend</span> <span><span>fun</span> <span>getOrganizationRepos</span><span>(
    organization: <span>String</span>
)</span></span>: List<Repo> =
    suspendCancellableCoroutine { continuation ->
        <span>val</span> call = apiService.getOrganizationRepos(organization)

        call.enqueue(
            <span>object</span> : Callback<List<Repo>> {
                <span>override</span> <span><span>fun</span> <span>onResponse</span><span>(
                    call: <span>Call</span><<span>List</span><<span>Repo</span>>>,
                    response: <span>Response</span><<span>List</span><<span>Repo</span>>>
                )</span></span> {
                    <span>val</span> body = response.body()

                    <span>if</span> (response.isSuccessful && body != <span>null</span>) {
                        continuation.resume(body)
                    } <span>else</span> {
                        continuation.resumeWithException(
                            ApiException(
                                response.code(),
                                response.message()
                            )
                        )
                    }
                }

                <span>override</span> <span><span>fun</span> <span>onFailure</span><span>(
                    call: <span>Call</span><<span>List</span><<span>Repo</span>>>,
                    throwable: <span>Throwable</span>
                )</span></span> {
                    continuation.resumeWithException(throwable)
                }
            }
        )

        continuation.invokeOnCancellation {
            call.cancel()
        }
    }

执行关系如下:

调用协程被取消
        ↓
CancellableContinuation 收到取消
        ↓
执行 invokeOnCancellation
        ↓
调用 Retrofit Call.cancel()
        ↓
底层网络请求停止

Retrofit 已原生支持挂起函数时,应优先直接声明:

<span>interface</span> <span>GithubApi</span> {

    <span>@GET(<span>"orgs/{organization}/repos?per_page=100"</span>)</span>
    <span>suspend</span> <span><span>fun</span> <span>getOrganizationRepos</span><span>(
        <span>@Path(<span>"organization"</span>)</span> organization: <span>String</span>
    )</span></span>: 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(),否则会切断原来的结构化并发关系。

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