协程的结构化并发

24 阅读23分钟

一个协程到底是什么

从技术角度来说,协程就像线程一样,代表了一条独立的业务执行线。但在 Kotlin 协程的实现中,"协程" 其实可以从多个视角来理解:

  1. 管理和父子关系的视角
    通过 launchasync 等协程构建器启动协程时,会返回一个 Job(或 Deferred,它继承自 Job)。这个 Job 对象就是对该协程的一个直接管理者,负责其生命周期控制(启动、取消、完成等)以及父子协程的层级关系。
  2. 作用域的视角
    每个协程代码块(即 launch/async 等的大括号内)都拥有自己的 CoroutineScope。从这个角度看,CoroutineScope 也可以被视为一个协程的“外壳”或环境,管理该范围内启动的所有协程。
  3. 代码块的视角
    更直观的说法是,把 launch { ... }async { ... } 中的大括号里的代码(即 block 参数)看作协程实际要执行的任务内容。
val outerJob0 = scope.launch(start = CoroutineStart.LAZY) {
  innerJob = coroutineContext[Job]
  innerScope = this
  launch { // 协程实际要执行的任务内容。
  }
}

三种视角结合起来,更全面地说明了“一个协程到底是什么”:

  • Job/Deferred:是协程的生命周期和层级关系的技术管理者,可以直接被认为是“协程对象”。

  • CoroutineScope:是协程运行的上下文环境,也可以视为“协程对象”。

  • block 代码块:是协程实际执行的任务逻辑,可以被直观地当作协程本身。

// 使用 CoroutineStart.LAZY 参数只是声明/准备协程,
// 不会立即启动协程体,需要调用 outerJob0.start() 才会真正执行协程体中的代码。
val outerJob0 = scope.launch(start = CoroutineStart.LAZY) {
    innerJob = coroutineContext[Job]
    innerScope = this
    launch {
        // ...
    }
}
// 启动协程
outerJob0.start()

val deferred = scope.async {
    // 在另一个协程中等待 outerJob0 完成
    outerJob0.join()
}

scope.async {
    // 通过 deferred.await() 拿到 async 协程返回的结果。
    // 注意 launch 的 lambda 是没有返回值的。
    val value = deferred.await()
}
  • CoroutineStart.LAZY 表示 协程不会立即启动,而是等到主动调用 start() 或第一次需要它执行(如 join()await())时才会启动只有调用 outerJob0.start() 之后,协程才真正开始执行。

在协程中,我们可以同时把 Job 和 CoroutineScope 看作“协程的对象” ,但两者又有所不同:

Job

Job 主要用于管理和控制协程的执行,其主要功能包括:

  • 启动与取消:可以启动协程任务,也可以随时取消任务。
  • 生命周期管理:可以查询协程的状态(正在执行、已完成、已取消)。
  • 依赖关系:可以设置父子 Job,形成协程执行树。取消父 Job 会递归取消所有子 Job。
  • 挂起与完成:可以等待 Job 完成(如 job.join())。

CoroutineScope

CoroutineScope 更加注重提供启动新协程的上下文环境,包括:

  • 协程上下文:持有一个 CoroutineContext 对象,包含所有协程上下文元素,如 Job、调度器(Dispatcher)、异常处理器等。
  • 协程构建器:提供如 launchasync 等构建器,用于在该作用域内启动新的协程。

二者的关联与区别

关联

  • 每个 CoroutineScope 都有一个关联的 Job,它是 CoroutineScope 上下文的一部分。这个 Job 控制着该作用域内启动的所有协程。
  • 在 CoroutineScope 内启动新协程时,这些协程会继承作用域的上下文(包括这个 Job),因此它们的生命周期由该 Job 管控。
  • 可以通过 coroutineScopesupervisorScope 显式创建新的 CoroutineScope,这些作用域会各自创建新的 Job 来控制其内部协程的生命周期。

区别

  • Job 是具体的对象,代表一个协程的执行实例,可以通过取消、等待等操作来控制协程。
  • CoroutineScope 是抽象的上下文环境,用于启动协程。它持有 CoroutineContext(其中包括 Job 及其他元素)。
  • Job 主要用于控制一个或一组协程的生命周期,而 CoroutineScope 提供了一种在特定上下文环境中启动和管理协程的方法。

为什么下面两次打印都是 true?

val outerJob = scope.launch(Dispatchers.Default) {
    innerJob = coroutineContext.job
    innerScope = this
    launch {

    }
}

println("outerJob: $outerJob")
println("innerJob: $innerJob")
println("outerJob === innerJob: ${outerJob === innerJob}")
println("outerJob === innerScope: ${outerJob === innerScope}")

在这个代码块中,innerJob 通过 coroutineContext[Job] 获取当前协程的 Job。由于这行代码是在 outerJob 协程体内部执行的,因此 coroutineContext[Job] 返回的就是 outerJob 本身。

参考 launch 源码:

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block)
    else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

start 方法内部,实际调用:

public fun <R> start(start: CoroutineStart, receiver: R, block: suspend R.() -> T) {
    start(block, receiver, this)
}

此处,coroutine(即 StandaloneCoroutineLazyStandaloneCoroutine 实例)既实现了 Job,也实现了 CoroutineScope
协程体 block 是在 coroutine 的上下文中执行,因此 block 里的 this 就是当前的 coroutine 对象。

总结下来:

  • outerJob === innerJobtrue,因为 innerJob 是通过 coroutineContext.job 获取的,而这个 coroutineContext 就对应 outerJob
  • outerJob === innerScope 也为 true,因为协程体 block 的接收者 this,就是 StandaloneCoroutineLazyStandaloneCoroutine 对象,也就是 Job 本身。

即,launch 返回的 Job 实例同时也是 block 中的 CoroutineScope,因此这两个引用本质上是同一个对象。

父子协程

父子协程的绑定机制

  1. 继承上下文:当一个协程在另一个协程的作用域(CoroutineScope)内启动时,会继承父协程的上下文,包括父协程的 Job。子协程的 Job 会自动添加到父协程的 Job 之下,形成父子关系。
  2. 取消传播:如果父协程被取消,所有子协程也会被递归取消。这样父协程可以完整控制所有子协程的生命周期。
  3. 独立子协程:如果使用 SupervisorJob,可以实现子协程失败不会影响父协程和其他子协程。

从技术角度,Job 和 CoroutineScope 都可以看作“协程对象”,但在理解父子协程关系时,最好以 Job 作为切入点。请看如下代码:

val job = scope.launch {
    innerJob = launch {
        delay(100)
    }
    val children = job.children
    println("children count: ${children.count()}")
    println("innerJob === children.first(): ${innerJob === children.first()}") // true
    println("innerJob.parent === job: ${innerJob?.parent === job}") // true
}

两次打印都是 true。这说明在协程体内启动新的协程时,的确可以通过 parentchildren 属性获取父协程或子协程。

这里有一个核心问题:launch 函数是如何实现两个 Job 的父子关系绑定的?

  • 内部的 launch 实际执行代码是 this.launch {}。其中的 this 就是外层的 CoroutineScope,而这个 CoroutineScope 中包含了外部返回的 Job 对象。因此,内部协程在创建时会自动关联到外部的 Job,形成父子关系。
  • 如果将 this 换成另一个 scope(如全局 scope),那么新创建的协程就不会与当前协程形成父子关系,而是独立存在。

总结
只要在一个协程体内部通过 this.launch {} 启动新协程,Kotlin 协程框架就会自动将两者的 Job 绑定成父子关系,这样父子协程的管理与取消传播就可以自动实现。

例如下面这段代码:

val job = scope.launch { // 1
    innerJob = scope.launch { // 2
        delay(100)
    }
}

在这个例子中,代码1和代码2启动的协程都是 scope 的子协程,它们之间没有父子关系。也就是说,协程2并不是协程1的子协程。
协程的父子关系由启动协程时所使用的 CoroutineScope 决定。如果你用同一个 scope(如 scope.launch),那么所有协程都是这个 scope 的直接子协程,而互相没有父子关系。

你可以通过指定父 Job 来定制协程的父子关系。例如:

val job = scope.launch {
    innerJob = launch(scope.coroutineContext.job) { // 指定父 Job
        delay(100)
    }
}

这里 scope.coroutineContext.job 显式指定了父协程的 Job,这样 innerJob 就成为了 job 的子协程。

你也可以直接用自定义的 Job 来指定父协程:

val customJob = Job()
innerJob = launch(customJob) {
    delay(100)
}

结构化并发主要包括三方面:

  1. 结构化的取消:父协程取消时,会自动取消所有子协程,实现统一的生命周期管理。
  2. 结构化的异常管理:异常会在父子协程间按照特定规则传播,便于捕获和处理。
  3. 结构化的结束:父协程会等待所有子协程执行完成后才算真正结束。

需要注意的是,所有协程(不管是没有关系的、兄弟协程还是父子协程)在执行上都是并行的,即便指定了父子关系,默认情况下也不会变成串行执行。

在 Java 中,Thread.interrupt() 用于给线程设置一个中断标记,相比直接调用 Thread.stop()(粗暴地立即终止线程),interrupt() 更加安全和友好。但即使如此,被中断的线程仍然需要做好资源清理工作

Exception(异常)字面意思是“例外”,但在实际开发中,它也是程序运行流程中的一种正常组成部分。在中断线程时,务必要注意善后处理(扫尾工作)。

sleep() 方法为例:当线程在 sleep() 状态下被中断时,会抛出 InterruptedException,同时会将线程的中断标记重置为 false
在 Kotlin 中,不再强制要求捕获异常(如 Java 那样),但在 Java 里,所有涉及线程等待的方法(如 Object.wait() 等)都会抛出异常。

因此,在实际项目中,应在关键业务节点定期检查线程的中断标记,并在 sleepwait 等会被中断的等待方法中,及时捕获中断异常,执行必要的清理操作(如关闭数据库连接、关闭网络资源等)。

线程的中断与取消

在多线程编程中,通常推荐通过交互式方式结束线程,而不是直接强制终止。线程的“中断”本质上只是添加了一个中断标记。需要注意的是,对于处于阻塞状态(如 sleep)的线程,调用中断操作会立刻生效,因为这时线程本身没有在主动工作。

1. stop() 方法

stop() 方法用于强制终止线程的执行。它会让线程立刻停止,不论当前代码执行到哪里。
这样做会导致:

  • 数据不一致
  • 资源未释放
    因此,从 Java 1.2 开始,stop() 方法被标记为过时(deprecated),极不建议使用

2. interrupt() 方法

interrupt() 方法用于向线程发送一个中断信号,即设置线程的中断标志为 true
需要注意:

  • 调用 interrupt() 并不会立刻终止线程,线程需要自己定期检查中断状态并主动响应。
  • 检查中断状态的方法有 isInterrupted()Thread.interrupted()
  • 当线程处于阻塞状态(如 sleep()wait()join() 时),如果被中断,会抛出 InterruptedException,此时应当捕获异常并进行必要的资源清理或业务处理。

在实际项目中,建议在关键业务流程节点主动检查中断状态,并在发生 InterruptedException 时,及时做善后处理(如关闭数据库连接、释放资源等),确保线程安全和数据一致性。

3. isInterrupted() 方法

isInterrupted() 方法用于检查线程的中断状态。如果线程已被中断,则返回 true,否则返回 false。与 Thread.interrupted() 不同,isInterrupted() 不会清除中断标志,只是单纯地查询。

示例代码

fun main() = runBlocking<Unit> {
    val thread = thread {
        println("Thread: I'm running!")
        Thread.sleep(200)
        println("Thread: I'm done!")
    }

    // Exception 直译是“例外”,也是程序运行的一部分。
    // 线程中断后一定要做清理工作。sleep 等方法被中断时会抛出异常,并将中断标志重置为 false。
    // 实际开发中,需要在关键节点检查中断标记,并在 sleep、wait 等等待方法中做好中断异常的清理(如关闭资源)。

    var count = 0
    val thread1 = object : Thread() {
        override fun run() {
            println("Thread: I'm running")
            try {
                Thread.sleep(200) // sleep 必须 try-catch,否则编译报错
            } catch (e: InterruptedException) {
                println("isInterrupted = $isInterrupted")
                println("Clearing up resources...")
                return // 结束 run 方法
            }

            // Java 中,所有等待线程的方式都会抛 InterruptedException,比如 Object.wait()
            val lock = Object()
            try {
                synchronized(lock) {
                    lock.wait()
                }
            } catch (e: InterruptedException) {
                println("isInterrupted = $isInterrupted")
                println("Clearing up resources...")
                return
            }

            val thread2 = thread { /* ... */ }
            try {
                thread2.join() // join 也会抛 InterruptedException
            } catch (e: InterruptedException) {
                println("Clearing up resources...")
                return
            }

            val latch = CountDownLatch(3)
            try {
                latch.await() // 也会抛 InterruptedException
            } catch (e: InterruptedException) {
                println("Clearing up resources...")
                return
            }

            /*  
            if (isInterrupted) {
                // 做清理工作
                return
            }
            while (true) {
                // interrupted() 方法第一次返回 true,第二次返回 false,因为会清除中断标志
                count++
                if (count % 100_000_000 == 0) println(count)
                if (count % 1_000_000_000 == 0) break
            }
            */
        }
    }
    thread1.start()
    Thread.sleep(100)
    thread.stop() // 太暴力,直接杀死线程,非常不推荐,会导致对象状态不一致、程序崩溃。
    thread.interrupt() // 更加安全友好的方式,中断线程,仅添加标记;对 sleep 等阻塞方法会直接唤醒并抛异常。
}

说明补充

  • sleep、wait、join、await 等方法被中断时,都会抛出 InterruptedException,并重置中断标记为 false。
  • Kotlin 虽然不强制捕获异常,但在涉及线程中断的场景下,仍然建议在关键节点主动检查和处理中断状态,确保资源能正确释放。
  • stop() 方法极不安全,不推荐使用!推荐用 interrupt() 并在业务代码里善后。

协程的交互式取消

协程的交互式取消,指的是在协程外部调用 cancel() 后,协程内部如何通过代码主动响应并停止当前协程的执行。


线程守护与协程守护

在 Java 中,isDaemon 属性用于区分线程是否为守护线程(daemon thread)。默认为 false,表示是用户线程。当所有用户线程结束后,进程随即终止;守护线程只为用户线程服务,本身不承担核心业务逻辑。

thread { 
    // ... 
}.apply { 
    isDaemon // 是否为守护线程 
}

Kotlin 协程所用的线程默认都是守护线程,这意味着只要主业务协程执行完毕,整个进程就会终止,无需单独管理后台线程的生命周期。


协程的取消机制

  • 协程的取消与 Java 的线程中断(interrupt)类似:外部调用 cancel() 只是发出“取消请求” ,但如果协程内部不主动响应,协程并不会真正终止。
  • 协程内部应主动检查 isActive(属于 CoroutineScope 的扩展属性),并根据业务场景选择结束方式。

示例:

while (true) {
    if (!isActive) {
        // 协程常用的结束方式是抛出 CancellationException
        throw CancellationException() // 协程会自动处理这个异常,安全退出
        // 也可以直接 return
    }
    count++
    if (count % 100_000_000 == 0) println(count)
    if (count % 1_000_000_000 == 0) break
}
  • 对比线程,协程推荐使用 throw CancellationException(),线程则一般使用 return 或捕获中断异常。

注意:除非非常清楚业务需求,否则不要随意将父子协程解绑。结构化并发和协程树依赖可以确保取消传播和异常安全。


注意:

  • 外部 cancel 只是发出请求,协程内部必须响应,如检查 isActive 或执行挂起点,否则协程不会自动终止。
  • 协程线程为守护线程,主流程完毕即进程退出。
  • 推荐结构化并发,保持协程父子关系,便于生命周期和异常统一管理。

协程中 return 与异常取消的区别

在协程里,虽然可以用 return 结束当前协程代码块的执行,但一般更推荐抛出 CancellationException
原因:

  • return 只能结束当前协程本身,不会自动取消它的子协程(不能实现结构化并发的“取消传播”)。
  • 抛出 CancellationException 不仅结束当前协程,还会自动取消所有子协程,并由协程框架接管清理和善后工作。

如果你只需简单地响应取消,不需要额外清理逻辑,可以直接调用 ensureActive(),它会在协程非活跃(被取消)时自动抛出 CancellationException

while (true) {
    ensureActive() // 非活跃则自动抛出异常,协程退出
    // 需要清理时,可以写在下面
    if (!isActive) {
        // 清理资源
        throw CancellationException()
    }
    count++
    if (count % 100_000_000 == 0) println(count)
    if (count % 1_000_000_000 == 0) break
}

协程与线程的中断机制对比

在线程中,如果 sleepwait 时被中断,会抛出 InterruptedException
协程里则是通过抛出 CancellationException 来响应取消,例如:

val job = launch(Dispatchers.Default) {
    var count = 0
    while (true) {
        println("count: ${count++}")
        delay(500) // 被取消时这里会抛 CancellationException
    }
}
delay(1000)
job.cancel() // 协程会在 delay 处抛异常退出

try-catch 对协程取消的影响

协程中使用 try-catch 时要特别注意:

  • 如果你捕获了 CancellationException务必在 catch 里将它重新抛出,否则协程会变成非活跃状态,但不会自动退出,后续的挂起点(如 delay)会立即抛异常,逻辑将混乱。
  • 只有在需要做清理工作的场景下,才建议这样写。

推荐模式:

try {
    // 协程代码
} catch (e: CancellationException) {
    // 做清理工作
    throw e // 重新抛出,保证协程正常退出
}

  • 协程取消推荐用异常而非 return,确保结构化并发和取消传播。
  • ensureActive() 是最简便的协程取消检查方式。
  • try-catch 时记得转抛 CancellationException,避免逻辑异常。

协程的清理与 finally 使用

如果协程中的清理工作无论是正常结束还是被取消都需要执行,最推荐的做法是把清理代码写在 finally 块里。这样就不需要单独捕获异常,不管协程如何退出,finally 都会被执行。

示例代码:

try {// 如果你这样做,一定要注意在后边将异常抛出,因为这个代码会导致 CancellationException 被捕获,反而导致协程无法被结束。并且由于协程已经处于非活跃状态,后续的每次delay会无效,而是直接抛出异常瞬间结束,进而导致整个逻辑混乱了。因此在协程里边反而要小心写try-catch。如果有清理等后续工作,这里这种写无可厚非。
    delay(500)
} /*catch (e: CancellationException) {
    println("Cancelled")
    // 清理工作
    throw e // 记得重新抛出
}*/ finally {
    // 清理工作
}
  • 如果清理逻辑与取消原因无关,finally 是最优位置。
  • 只有当你需要区分是“被取消”还是“正常结束”来做不同清理时,才建议捕获 CancellationException,并记得转抛。

协程和线程的取消异常对比

  • 线程的等待(如 sleep, wait, join)被中断时会抛出 InterruptedException
  • 协程中,所有等待型或挂起函数(如 delay, withTimeout, yield 等)被取消时都会抛出 CancellationException

一个例外:suspendCoroutine

  • suspendCoroutine 不支持取消。如果外部协程被取消,使用该函数的挂起点不会自动抛出异常,也就是协程不会自动响应取消。
  • 推荐使用 suspendCancellableCoroutine,它支持取消,一旦外部取消,立即抛出 CancellationException,协程能正确退出。

示例:

suspendCoroutine<String> {
    // 不支持取消
}

suspendCancellableCoroutine<String> {
    // 支持取消,会自动响应外部 cancel
}

  • 一般清理直接放 finally,不用写 catch。
  • 需要区分取消时再 catch 并转抛 CancellationException
  • 使用挂起函数时,优先选用支持取消的版本,保证协程结构化并发的正确性。

协程的结构化取消

结构化取消是指:父协程被取消时,会自动取消其所有子协程。协程的取消本质上是交互式的——外部调用 cancel(),协程内部则需要主动检查自身状态来响应取消请求。

  • 协程内部应主动通过 isActive 或相关方法,判断当前协程是否仍然活跃,并决定是继续执行业务逻辑,还是进入收尾(清理)流程。

  • 如果协程内部调用了像 delay 这样的挂起函数,这些函数会自动检测协程的取消状态。一旦协程被取消,会自动抛出 CancellationException,协程就会中断执行。

    • 需要注意:只有常规的挂起函数(如 delaywithContextyield 等)才会自动响应取消。如果你自己实现挂起函数(比如用 suspendCoroutine),一定要手动检查协程的存活状态,否则外部 cancel 无法生效。
  • 取消的传播是递归的:无论是外部调用 cancel() 还是协程内部抛出 CancellationException,都会将当前 Job 的 isActive 设为 false,并递归取消所有子 Job。

    • 为什么需要内部也能抛出异常取消?这样协程可以在代码内部发现业务失败时,主动通过抛出 CancellationException 终止自己和所有子协程,与外部调用 cancel 效果一致。

补充说明

  • 父子协程抛出异常的实际时机并不确定,依赖于取消传播和检查点的执行时机。
  • 如果协程内部没有挂起点、检查点或主动检测 isActive,即使外部 cancel 也不会真正终止协程。

协程的取消:

  • 协程的取消本质上需要内部代码主动响应(检查 isActive 或遇到挂起点)。
  • 挂起函数会自动检测并响应取消,自己实现挂起函数要手动检查活跃状态。
  • 抛出 CancellationException 能递归取消所有子协程,无论是外部还是协程自身发起取消,效果相同。

子协程可以拒绝取消吗?

协程的取消有两种方式:

  1. 外部调用 cancel()
  2. 协程内部主动抛出 CancellationException

协程被取消时,会经历如下流程:

  • Job 的 isActive 变为 false
  • 自动调用所有子协程的 cancel()
  • 协程代码内部如果遇到检查点(如挂起函数/isActive 检查)应主动退出,如通过抛出 CancellationException 来终止。

关键:isActive 变为 false 和 cancel 被调用无法阻止,但“是否真正结束”取决于协程内部的实现。

  • 如果捕获了 CancellationException 并吞掉(不再继续抛出),或者根本没有检查点,协程可以强行不退出,继续执行逻辑。
  • 另外,如果使用 Thread.sleep 这类阻塞代码,也不会响应协程的取消。

代码示例 1:吞掉 CancellationException

fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(EmptyCoroutineContext)
    val parentJob = scope.launch {
        val childJob = launch {
            println("Child job started")
            try {
                delay(3000) // delay或者挂起函数,cancel调用的时候, 一定触发CancellationException
            } catch (e : CancellationException) { // 强行让协程的逻辑保留,继续执行。
                // 这里如果不抛出异常,catch内代码执行完后,协程不会被真正终止,会进入“假死亡”状态。
                // 推荐只做清理后重新抛出异常,保证协程能正确响应取消。
            }
            println("Child job finished") // 如果catch中未抛出异常,这行代码依然会被执行
        }
    }
    delay(1000)
    parentJob.cancel() // 取消父协程,同时尝试取消所有子协程
    measureTime { parentJob.join() }.also { println("Duration: $it") }
    delay(10000)
}

此时子协程不会退出parentJob.join() 也会一直等到子协程真正结束。

代码示例 2:阻塞式代码无响应

fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(EmptyCoroutineContext)
    val parentJob = scope.launch {
        val childJob = launch {
            println("Child job started")
            Thread.sleep(3000) // sleep不会因为cancel而触发 CancellationException
            // Thread.sleep 属于阻塞方法,不会检测协程取消状态,即使被cancel也会继续执行到sleep结束
            println("Child job finished")
        }
    }
    delay(1000)
    parentJob.cancel() // 调用cancel后父协程和子协程的isActive都会变为false,但sleep不会立即响应
    measureTime { parentJob.join() }.also { println("Duration: $it") }
    delay(10000)
}

这里即使父协程被取消,子协程依旧执行 sleep无法立即终止


协程的“真正结束”

父协程只有在所有子协程都执行完之后,才算真正结束。
比如外部调用 parentJob.join(),只有等父协程及其所有子协程完全退出后,join() 才会返回。

相关用法:

parentJob.cancel()
parentJob.join() // 等待父协程及子协程结束
parentJob.cancelAndJoin() // 先取消,再等待全部结束
measureTime { parentJob.join() }.also { println("Duration: $it") } // 统计协程关闭耗时

小结

  • 子协程理论上可以“拒绝取消” ,只要在内部捕获或忽略 CancellationException,或者根本不执行检查点。
  • 这样会拖住父协程,使得结构化并发失效,不推荐这样做。
  • 最佳实践:只做必要清理后,记得继续抛出异常,保证结构化并发的父子协程正确取消和释放。

不配合取消:NonCancellable

如果启动某个协程时指定了 NonCancellable,该协程不会因为父协程的取消而被取消,只能通过自身主动取消
NonCancellable 的实现实际上切断了与父协程以及子协程的所有父子关系,因此它既不是父协程的子协程,也不会成为其内部协程的父协程。

val parentJob = scope.launch {
    childJob = launch(NonCancellable) { // 不会受到父协程的取消而取消。
        println("Child started")
        delay(1000)
        println("Child stopped")
    }
    println("childJob parent: ${childJob?.parent}") // 这里会打印为null
    childJob2 = launch(newParent) {} // 这种写法会导致childJob2父协程变成了newParent
}
parentJob.cancel() // 对childJob没有影响
childJob.cancel() // 只有自己才能取消自己

NonCancellable 实现细节:

public object NonCancellable : AbstractCoroutineContextElement(Job), Job
// 由于cancel已被废弃并且为空实现,调用cancel不会有任何效果,协程不会因为父协程取消而停止
@Deprecated(level = DeprecationLevel.WARNING, message = message)
override fun cancel(cause: CancellationException?) {} // cancel已经失效

/**
 * Always returns [emptySequence].
 * @suppress **This an internal API and should not be used from general code.**
 */
@Deprecated(level = DeprecationLevel.WARNING, message = message)
// children属性总是返回空序列,也就是说,NonCancellable 不会有子协程
override val children: Sequence<Job>
    get() = emptySequence()

/**
 * Always returns [NonDisposableHandle] and does not do anything.
 * @suppress **This an internal API and should not be used from general code.**
 */
@Deprecated(level = DeprecationLevel.WARNING, message = message)
// attachChild不会有实际操作,无法形成父子协程链
override fun attachChild(child: ChildJob): ChildHandle = NonDisposableHandle // 取消绑定孩子。

补充说明:

  • 典型场景:常用于 withContext(NonCancellable) { ... },保证收尾代码无论协程是否被取消都能安全执行(比如 finally 块中的 IO、资源回收等)。
  • 由于 NonCancellable 切断了所有取消链条,如果业务协程全部采用 NonCancellable,将无法实现结构化并发的父子协程统一管理,不推荐在普通业务逻辑中随意使用。

一般我们在以下三种情况下不希望协程被取消

1. 收尾工作(清理/释放资源)执行过程中

协程被取消时,常常需要确保某些收尾(清理)操作能够完整执行,不被再次中断。此时推荐用 withContext(NonCancellable) 包裹清理逻辑:

if (!isActive) {
    withContext(NonCancellable) { // 防止当前的协程被中断,确保清理代码完整执行
        // Write to database (Room)
        // Do some work before it is terminated.
    }
    throw CancellationException()
}
try {
    delay(3000)
} catch (e: CancellationException) {
    // 可选:做异常处理
    throw e // 通常依然推荐抛出,保证协程链条完整取消
}

2. 难以收尾或不方便处理取消的场景

对于某些 IO 操作(如数据库、文件读写),一旦开始,协程被取消时可能难以优雅中断,这时可以用 NonCancellable,让这部分代码不响应取消请求:

suspend fun writeInfo() = withContext(Dispatchers.IO) {
    // write to file
    // read from database (Room)
    // write data to file
}

两种处理方式:

suspend fun writeInfo() = withContext(Dispatchers.IO + NonCancellable/*方案一,利用NonCancellable,让运行这个函数的协程不能被取消*/) {
    // write to file
    try {
        // read from database (Room)
    } catch (e: CancellationException) { // 方案二,利用try-catch代码块捕获取消异常

    }
    // write data to file
}

3. 与主业务无关的任务(如日志、埋点)

对于和主业务流程解耦的工作,比如日志打印、埋点上传等,可以用 launch(NonCancellable) 启动独立协程,确保它不会因为业务协程被取消而终止(不推荐在关键业务中这样做,但日志类场景可用):

launch(NonCancellable) {
    // Log
}

小结

让协程不被取消,实际上就两种常见用法:

// 用法一:独立启动不会被取消的协程
launch(NonCancellable) {
    // Log
}

// 用法二:在当前协程内局部区域不响应取消
withContext(NonCancellable) {
    // Write to database (Room)
    delay(1000)
}

补充说明:

  • 尽量只在确实需要的场合使用 NonCancellable,保证结构化并发和协程取消链条不会被随意破坏。
  • 非业务关键场景(如日志)用 launch(NonCancellable),收尾/不可中断任务用 withContext(NonCancellable)

协程的结构化异常管理

协程中的异常无法用传统的 try-catch 包裹 launch 或 async 等构建器来捕获,因为异常只会在协程体内部传播,无法直接穿透到外部调用栈。例如:

try {
    launch { }
} catch (e: Exception) {
    // 这里无法捕获协程体内的异常
}

协程的异常具有传播性

  • 当一个协程发生异常时,以该协程为节点的所有父子协程都会被取消,这是结构化并发的核心特性之一。

异常与取消的本质:

  • 协程的异常管理与取消本质上是相同的:都是通过抛出 CancellationException 或其它异常来中止协程树。
  • 如果协程内部抛出的不是 CancellationException(比如 NullPointerException、IOException 等),那么同样会导致父协程和所有子协程被取消。
  • 不同之处在于,抛出 CancellationException 被认为是“正常取消流程” ,而其他异常是“异常终止流程”。二者在底层流程上类似,但对开发者来说,CancellationException 被视为一种“温和的终止”,不会打印堆栈信息,也不会触发标准异常处理逻辑。

普通异常流程相比 CancellationException 多了什么?

  • 错误报告与堆栈跟踪:普通异常(非 CancellationException)会被日志系统记录,会打印完整的异常堆栈信息,便于排查 bug 和错误追踪。
  • 触发异常处理器:协程的异常处理器(如 CoroutineExceptionHandler)会对未捕获的普通异常(非 CancellationException)进行处理,而不会处理 CancellationException。
  • 协程完成状态不同:被普通异常终止的协程,其 Job 状态为“已完成-异常”;被 CancellationException 终止的协程,其 Job 状态为“已取消”。
  • 资源回收与清理:虽然 finally 代码块都会执行,但普通异常会额外触发上层异常处理流程,如 SupervisorJob 仅取消出错的子协程,其余子协程不受影响。

1. 取消与异常的传播范围

  • CancellationException 或 job.cancel() 的取消

    • 只会“内向”传播——从当前节点起,递归取消它的所有子协程以及它自己,不会影响父协程和兄弟协程。

    • 例如:

      parentJob.cancel() // 只影响 parentJob 及其子协程
      
  • 普通异常(如 RuntimeException)

    • 会“外向”传播——以当前抛出异常的节点为中心,父协程、所有兄弟协程及其子协程,全部会被取消

    • 例如:

      scope.launch(handler) {
          launch { 
              launch {  }
              launch {  }
          }
          launch {
              launch {  
                  launch { throw RuntimeException("Error!") } // 会导致所有的协程都被取消。
              }
              launch {  }
          }
      }
      
    • 设计原因:协程的父子关系是逻辑包含关系,一个子流程崩溃后,大流程失去完整性,必须全部终止。

    • 想要子协程异常不影响父协程?用 SupervisorJob


2. 实现机制

  • 在 JVM 中,线程的未捕获异常只会导致该线程崩溃,不会导致整个应用崩溃。安卓特意添加了“线程崩溃 = 应用崩溃”的机制。

  • 协程异常传播本质依赖于 JobSupportchildCancelled 方法:

    JobSupport:
    public open fun childCancelled(cause: Throwable): Boolean {
        if (cause is CancellationException) return true // 仅为取消流程,不影响父协程
        return cancelImpl(cause) // 普通异常时,触发父协程取消,并通过 cancelImpl 向上传播
            && handlesException
    }
    
  • cancelImpl(cause) 用于取消父协程。只有非 CancellationException 异常才会递归向上传播,实现父协程及兄弟协程的连带取消。


3. 取消和异常触发方式

  • 异常流程:只能通过抛出异常来触发(如 throw RuntimeException()),不能用 cancel() 方法。

  • 取消流程:有两种方式

    1. 抛出 CancellationException(如 throw CancellationException())。
    2. 直接调用协程的 cancel() 方法。
  • 区别

    • CancellationException 只负责取消,不会进入线程未捕获异常处理。
    • 普通异常(如 RuntimeException)不仅取消协程,还会暴露到线程世界(表现为未捕获异常),可能导致应用崩溃。

4. 如何处理协程未捕获异常?

  • 可通过 CoroutineExceptionHandler 拦截未捕获异常,避免协程异常进入线程、进而影响整个应用:

    val handler = CoroutineExceptionHandler { _, exception ->
        println("Caught exception: $exception")
    }
    scope.launch(handler) {
        // ...
    }
    

小结

  • 取消流程是递归取消自己和子协程,异常流程则会取消整棵协程树(包括父协程和兄弟)。
  • 协程异常传播机制是结构化并发的设计体现,保障业务流程的一致性和可靠性。
  • 日常开发如需对子协程异常“断链”,请用 SupervisorJob
  • CoroutineExceptionHandler 可用于协程的异常“兜底”处理,避免异常冒泡至线程。
  • 普通异常 = 完整的错误流程(会被日志、异常处理器捕获、打印堆栈),也会导致父子协程取消。
  • CancellationException = 简化版取消流程(仅做取消,不记录异常、不触发处理器),用于协程“温和”地终止和取消。

CoroutineExceptionHandler 与 Java 的 UncaughtExceptionHandler

CoroutineExceptionHandler 与 Java 的 setDefaultUncaughtExceptionHandler 行为模式非常相似,本质上都是为未捕获异常兜底、做善后处理(如记录日志、重启服务等)。
不同点在于,CoroutineExceptionHandler 作用于协程树,而 UncaughtExceptionHandler 作用于线程。不过,由于协程最终依赖线程调度,二者在运行时是可以连接起来的。

协程是有结构化父子关系的。

  • CoroutineExceptionHandler 主要是针对单个协程树设置善后机制,可以用来重启协程、记录日志或其它自定义操作。

为什么 try-catch 捕获不到线程/协程体内的异常?

try {
    thread { 
        throw Exception()
    }
} catch (e: Exception) {
    // 捕获不到 thread 内部的异常
}

原因:抛异常的线程和 try-catch 所在的线程不是同一个,异常无法跨线程传播。

同理,下面的协程代码 try-catch 也无法捕获协程体内异常,只能捕获“启动过程”的异常,协程启动流程与其内部代码执行流程是两个完全不同的流程

try {
    scope.launch { 
        throw Exception()
    }
} catch (e: Exception) {
    // 只能捕获 launch 启动异常,不能捕获协程体内的异常
}

CoroutineExceptionHandler 的生效范围

利用 CoroutineExceptionHandler 可以捕获协程树中所有未处理的异常(包括该父协程及所有子协程),但只能设置在最外层的父协程上

  • 如果将 handler 设置在内部子协程或与主协程结构无关的协程上,是无法生效的。
val handler = CoroutineExceptionHandler { _, exception ->
    println("Caught $exception")
}
scope.launch(handler) {
    launch {
        launch { }
        launch { }
    }
    CoroutineScope(EmptyCoroutineContext).launch { // 这个协程异常无法被捕获,因为它不是任何协程的子协程。
        launch { throw RuntimeException("Error!") }
    }
    launch {
        launch(handler) { // 这种设置方式没有效果,这个子协程也无法捕获任何异常。
            launch { throw RuntimeException("Error!") }
        }
        launch { }
    }
}

为什么会这样?

  • 因为协程的异常是按协程树(父子结构)向上传递的,只有树根(最外层父协程)设置的 handler 能兜底整个树。
  • 用独立 CoroutineScope(EmptyCoroutineContext) 启动的协程,已经脱离原有树结构,所以外层 handler 也捕获不到。

Java UncaughtExceptionHandler 机制对比

在 Java 中,可以为线程设置未捕获异常的处理逻辑:

  • 全局方式(对所有线程生效):

    Thread.setDefaultUncaughtExceptionHandler { t, e ->
        println("Caught default: $e")
        exitProcess(1)
    }
    
  • 单线程方式(只对该线程生效):

    val thread = Thread {
        throw RuntimeException("Thread error!")
    }
    thread.setUncaughtExceptionHandler { t, e ->
        println("Caught $e")
        // 可以在这里决定是否重启线程
    }
    

注意:

  • JVM 默认只会让崩溃线程退出,安卓系统则会让整个应用因线程崩溃而直接退出(未捕获异常的统一“自杀”逻辑)。
  • UncaughtExceptionHandler 只负责做收尾,无法让已崩溃线程恢复运行。

小结

  • 协程异常处理要靠 CoroutineExceptionHandler,只能放在最外层父协程上生效。
  • 线程异常只能通过 UncaughtExceptionHandler,可以全局或单线程设置。
  • try-catch 只能捕获本地流程的异常,无法跨线程/跨协程捕获。
  • Android 应用线程崩溃会导致进程崩溃,需额外小心。

总结:协程的结构化异常管理

  • 子协程的异常会导致父协程被取消,进而取消整个协程树。这样设计的原因在于:协程树通常表示一个完整的业务流程。只要其中任意一个子协程出现致命异常,整个流程便失去了继续运行的意义。

  • 子协程抛出的非 CancellationException 异常,如果只给该子协程设置 handler,是没有效果的。异常只会被树最外层的父协程上的 handler 捕获。这是因为异常的善后目标是面向整个协程树,所以必须交给根节点统一处理。

  • 为什么异常交由父协程的 handler 处理?
    因为 handler 的目的是对整个协程树进行善后(如统一重启、收集日志等),设置在根节点最为高效、方便。

  • 结构化异常管理的本质,就是用于协程树中未知异常的收尾和善后

    • 对于已知、可控的异常,应在协程体内部使用 try-catch 处理,不进入结构化异常流程。
    • 结构化异常处理只负责协程树中的未知异常和统一善后工作。

async() 对异常的处理

async 的异常处理方式和 launch 基本一致,但由于 async 的设计初衷是并行后串行(先独立工作,后 await 串行),因此它的异常传播机制存在一些区别。


代码分析

scope.async/*(handler)*/ {
    val defferd = async { // (1)
        throw RuntimeException("Error!")
        delay(1000)
    }
    launch(/*Job()*/) { // (2)
        defferd.await() // 拿到协程执行的结果。如果 defferd 内部异常,这里也会抛出异常。
    }
    // cancel() // 如果这里执行 cancel,会导致 (2) 中的 await 抛出 CancellationException
    delay(1000) // 在 RuntimeException 抛出后,这里依然会因为结构化协程机制触发 CancellationException
}

结论

  1. 异常影响范围

    • 首先会影响以该 async 协程为父协程的整个协程树
    • 因为 async 的 deferred 结果可以被 await,所以所有 await 该 deferred 的代码也会受到影响(即 await 处也会抛出异常)。
    • 若 await 发生在同一协程树内,还会伴随结构化取消的传播(CancellationException)。
  2. 异常传播优先级

    • 如果 async 内部抛出的是普通异常(如 RuntimeException),await 处首先抛出该异常。
    • CancellationException 的传播是结构化、延迟的,普通异常优先。
  3. 与 launch 的区别

    • async 抛出的异常,不仅会影响原有协程树,还会让所有 await 的地方都抛出同样异常,而且无论 await 发生在哪棵协程树
    • 若 await 的 launch 是独立 Job(不在原协程树下),它不会被结构化取消(CancellationException),但 await 依然会抛出 RuntimeException。
scope.async {
    val defferd = async {
        throw RuntimeException("Error!")
        delay(1000)
    }
    launch(Job()) { // 独立于原协程树
        defferd.await() // 依然会在 await 抛出异常,但不受结构化取消影响
    }
    delay(1000)
}
  1. async 的异常不会抛给线程世界

    • 如果最外层用 launch,未捕获的异常会传递到线程级别(表现为应用崩溃或由 CoroutineExceptionHandler 捕获)。
    • 如果最外层用 async,则异常不会传递到线程或全局异常处理器,而是由 await 的调用点去处理。这也是给 async 注册 CoroutineExceptionHandler 没有效果的原因。

总结

  • async 内部抛出的异常会导致整个协程树被取消(如结构化取消机制),并且所有 await 该 deferred 的地方都会抛出同样的异常
  • 如果 await 发生在不同协程树中,依然会感知异常,只是不会连带取消其它兄弟协程(即无结构化 CancellationException)。
  • async 的异常不会抛给线程或全局异常处理器,只能在 await 的地方被捕获和处理;CoroutineExceptionHandler 专用于 launch,无效于 async。

SupervisorJob 的理解

  • SupervisorJob 的核心特性:子协程因非 CancellationException(如 RuntimeException)抛出异常被取消时,不会连带取消父协程。SupervisorJob 只在取消流程上有父子关系,在异常传播时则无效。

    • 换句话说:SupervisorJob 取消时,子协程也被取消;但子协程抛异常时,父协程(SupervisorJob)不会自动被取消
private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
    override fun childCancelled(cause: Throwable): Boolean = false
}

这里重写了 childCancelled 方法,直接返回 false,阻断了普通 Job 的异常向上传播。


常见用法 1:SupervisorJob 作为内部协程的父协程

SupervisorJob 是里层协程的父协程,但对外还是普通协程:

scope.launch {
    launch(SupervisorJob(coroutineContext.job)) {
        launch {
            throw RuntimeException("Error!")
        }
    }
}
  • 这里,内层 SupervisorJob 的子协程抛异常不会取消 SupervisorJob 本身,也不会影响外层 scope

常见用法 2:用 SupervisorJob 创建独立作用域

用 SupervisorJob 创建的 scope,内部所有子协程互不影响。只要 scope 未被主动取消,某个子协程异常并不会取消其它兄弟协程或整个 scope。

val scope = CoroutineScope(SupervisorJob())
  • 用这个 scope 启动的所有协程,互不影响。scope 主动 cancel 时,其所有子协程也会被取消。

需要注意

  • 如果父协程是 SupervisorJob,当其子协程抛出异常,该异常会直接抛到线程世界(比如 Android 里就是崩溃)。
  • 并且这个异常是由第一级子协程抛到线程世界的,如果要处理异常,需在最接近出错点的子协程上注册 CoroutineExceptionHandler
scope.launch {
    launch(SupervisorJob(coroutineContext.job) + handler/*可以用来处理异常*/) {
        launch {
            throw RuntimeException("Error!")
        }
    }
}
// 此时异常由带 handler 的 launch 送到线程世界,不会影响 scope

总结:

  • SupervisorJob 只对子协程的取消负责,不对子协程的异常传播负责,子协程异常不会导致父协程和兄弟协程被取消
  • 适合需要让子协程间故障隔离的场景(如 UI 多个独立并发请求、服务端并行任务等)。
  • 父协程为 SupervisorJob 时,异常需要在子协程中单独处理,否则会直接冒泡到线程。