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

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

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

1 结构化并发依赖 Job

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

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

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

kotlin 复制代码
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 设为它的父任务。

text 复制代码
runBlocking 的 Job
└── launch 的 Job

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

2 Job 的生命周期

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

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

text 复制代码
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、默认启动的协程和惰性启动的协程:

kotlin 复制代码
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

kotlin 复制代码
fun main(): Unit = runBlocking {
    val job: Job = launch {
        delay(1_000)
        println("Test")
    }
}

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

kotlin 复制代码
fun main(): Unit = runBlocking {
    val deferred: Deferred<String> = async {
        delay(1_000)
        "Test"
    }

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

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

kotlin 复制代码
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

kotlin 复制代码
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 会切断原父子关系

kotlin 复制代码
fun main(): Unit = runBlocking {
    launch(Job()) {
        delay(1_000)
        println("Will not be printed")
    }
}

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

text 复制代码
runBlocking Job

独立 Job
└── launch Job

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

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

kotlin 复制代码
coroutineScope {
    launch {
        // 自动成为 coroutineScope 的子任务
    }

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

4 等待协程和子任务

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

kotlin 复制代码
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 的直接子任务:

kotlin 复制代码
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 本身是接口:

kotlin 复制代码
public fun Job(parent: Job? = null): CompletableJob

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

kotlin 复制代码
fun complete(): Boolean

fun completeExceptionally(
    exception: Throwable
): Boolean

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

kotlin 复制代码
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(),它才会进入完成流程。

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

kotlin 复制代码
job.children.forEach { child ->
    child.join()
}

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

kotlin 复制代码
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) 则让任务异常完成,并取消已有子任务:

kotlin 复制代码
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,并把取消信号向所有子任务传播。协程随后在可取消挂起点或显式状态检查处响应取消。

kotlin 复制代码
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")
}

典型输出为:

text 复制代码
Printing 0
Printing 1
Printing 2
Printing 3
Printing 4
Cancelled successfully

取消传播方向需要准确理解:

text 复制代码
取消父 Job
├── 取消子 Job A
├── 取消子 Job B
└── 递归取消更深层的 Job

正常取消某个子任务不会使父任务失败。子任务抛出未处理的非 CancellationException 异常,才会在普通 Job 关系中向父任务传播失败。

6.1 cancel join 和 cancelAndJoin

cancel() 只发送取消信号,不等待清理完成。join() 只等待目标任务结束。因此,需要"取消并等到彻底结束"时应组合调用:

kotlin 复制代码
job.cancel()
job.join()

协程库提供了更简洁的扩展函数:

kotlin 复制代码
job.cancelAndJoin()

其概念实现是:

kotlin 复制代码
public suspend fun Job.cancelAndJoin() {
    cancel()
    return join()
}

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

kotlin 复制代码
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 被清理时,这个作用域会自动取消:

kotlin 复制代码
class ProfileViewModel : ViewModel() {

    fun onCreate() {
        viewModelScope.launch {
            loadUserData()
        }
    }

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

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

kotlin 复制代码
class ProfileViewModel : ViewModel() {

    private val scope = CoroutineScope(
        SupervisorJob() + Dispatchers.Main
    )

    override fun onCleared() {
        scope.cancel()
    }
}

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

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

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

kotlin 复制代码
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 仍然会执行。普通同步清理应直接放在这里:

kotlin 复制代码
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;在该上下文中创建的普通子协程也会继承取消状态。

kotlin 复制代码
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)

kotlin 复制代码
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 注册完成处理器。当任务正常完成、取消或失败并进入终止状态时,处理器会执行一次:

kotlin 复制代码
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 只表示函数可以挂起当前协程,也可以调用其他挂起函数。它不表示函数每次调用都一定挂起,不表示会创建新协程,也不保证响应取消。

kotlin 复制代码
suspend fun calculate(): Int {
    return 42
}

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

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

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

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

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

kotlin 复制代码
suspend fun waitForCallback(): String =
    suspendCoroutine { continuation ->
        someApi.request { result ->
            continuation.resume(result)
        }
    }

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

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

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

kotlin 复制代码
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

kotlin 复制代码
val job = launch(Dispatchers.Default) {
    while (isActive) {
        performPartOfCalculation()
    }
}

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

10.2 ensureActive

kotlin 复制代码
val job = launch(Dispatchers.Default) {
    while (true) {
        performPartOfCalculation()
        ensureActive()
    }
}

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

10.3 yield

kotlin 复制代码
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 注册取消处理器。

kotlin 复制代码
suspend fun someTask(): Unit =
    suspendCancellableCoroutine { continuation ->
        val operation = startOperation(
            onSuccess = {
                continuation.resume(Unit)
            },
            onFailure = { error ->
                continuation.resumeWithException(error)
            }
        )

        continuation.invokeOnCancellation {
            operation.cancel()
        }
    }

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

下面以 Retrofit 的回调 API 为例:

kotlin 复制代码
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()
        }
    }

执行关系如下:

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

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

kotlin 复制代码
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(),否则会切断原来的结构化并发关系。

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

相关推荐
千里马学框架1 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台1 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone1 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc1 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
sun0077001 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼1 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone1 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen1 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone1 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui