一个协程到底是什么
从技术角度来说,协程就像线程一样,代表了一条独立的业务执行线。但在 Kotlin 协程的实现中,"协程" 其实可以从多个视角来理解:
- 管理和父子关系的视角:
通过launch
或async
等协程构建器启动协程时,会返回一个Job
(或Deferred
,它继承自 Job)。这个 Job 对象就是对该协程的一个直接管理者,负责其生命周期控制(启动、取消、完成等)以及父子协程的层级关系。 - 作用域的视角:
每个协程代码块(即 launch/async 等的大括号内)都拥有自己的CoroutineScope
。从这个角度看,CoroutineScope 也可以被视为一个协程的“外壳”或环境,管理该范围内启动的所有协程。 - 代码块的视角:
更直观的说法是,把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)、异常处理器等。
- 协程构建器:提供如
launch
、async
等构建器,用于在该作用域内启动新的协程。
二者的关联与区别
关联
- 每个 CoroutineScope 都有一个关联的 Job,它是 CoroutineScope 上下文的一部分。这个 Job 控制着该作用域内启动的所有协程。
- 在 CoroutineScope 内启动新协程时,这些协程会继承作用域的上下文(包括这个 Job),因此它们的生命周期由该 Job 管控。
- 可以通过
coroutineScope
或supervisorScope
显式创建新的 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
(即 StandaloneCoroutine
或 LazyStandaloneCoroutine
实例)既实现了 Job
,也实现了 CoroutineScope
。
协程体 block
是在 coroutine
的上下文中执行,因此 block
里的 this
就是当前的 coroutine
对象。
总结下来:
outerJob === innerJob
为true
,因为innerJob
是通过coroutineContext.job
获取的,而这个coroutineContext
就对应outerJob
。outerJob === innerScope
也为true
,因为协程体block
的接收者this
,就是StandaloneCoroutine
或LazyStandaloneCoroutine
对象,也就是Job
本身。
即,launch 返回的 Job 实例同时也是 block 中的 CoroutineScope,因此这两个引用本质上是同一个对象。
父子协程
父子协程的绑定机制
- 继承上下文:当一个协程在另一个协程的作用域(CoroutineScope)内启动时,会继承父协程的上下文,包括父协程的
Job
。子协程的Job
会自动添加到父协程的Job
之下,形成父子关系。 - 取消传播:如果父协程被取消,所有子协程也会被递归取消。这样父协程可以完整控制所有子协程的生命周期。
- 独立子协程:如果使用
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
。这说明在协程体内启动新的协程时,的确可以通过 parent
或 children
属性获取父协程或子协程。
这里有一个核心问题: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)
}
结构化并发主要包括三方面:
- 结构化的取消:父协程取消时,会自动取消所有子协程,实现统一的生命周期管理。
- 结构化的异常管理:异常会在父子协程间按照特定规则传播,便于捕获和处理。
- 结构化的结束:父协程会等待所有子协程执行完成后才算真正结束。
需要注意的是,所有协程(不管是没有关系的、兄弟协程还是父子协程)在执行上都是并行的,即便指定了父子关系,默认情况下也不会变成串行执行。
在 Java 中,Thread.interrupt()
用于给线程设置一个中断标记,相比直接调用 Thread.stop()
(粗暴地立即终止线程),interrupt()
更加安全和友好。但即使如此,被中断的线程仍然需要做好资源清理工作。
Exception
(异常)字面意思是“例外”,但在实际开发中,它也是程序运行流程中的一种正常组成部分。在中断线程时,务必要注意善后处理(扫尾工作)。
以 sleep()
方法为例:当线程在 sleep()
状态下被中断时,会抛出 InterruptedException
,同时会将线程的中断标记重置为 false
。
在 Kotlin 中,不再强制要求捕获异常(如 Java 那样),但在 Java 里,所有涉及线程等待的方法(如 Object.wait()
等)都会抛出异常。
因此,在实际项目中,应在关键业务节点定期检查线程的中断标记,并在 sleep
、wait
等会被中断的等待方法中,及时捕获中断异常,执行必要的清理操作(如关闭数据库连接、关闭网络资源等)。
线程的中断与取消
在多线程编程中,通常推荐通过交互式方式结束线程,而不是直接强制终止。线程的“中断”本质上只是添加了一个中断标记。需要注意的是,对于处于阻塞状态(如 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
}
协程与线程的中断机制对比
在线程中,如果 sleep
或 wait
时被中断,会抛出 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
,协程就会中断执行。- 需要注意:只有常规的挂起函数(如
delay
、withContext
、yield
等)才会自动响应取消。如果你自己实现挂起函数(比如用suspendCoroutine
),一定要手动检查协程的存活状态,否则外部 cancel 无法生效。
- 需要注意:只有常规的挂起函数(如
-
取消的传播是递归的:无论是外部调用
cancel()
还是协程内部抛出CancellationException
,都会将当前 Job 的isActive
设为false
,并递归取消所有子 Job。- 为什么需要内部也能抛出异常取消?这样协程可以在代码内部发现业务失败时,主动通过抛出
CancellationException
终止自己和所有子协程,与外部调用 cancel 效果一致。
- 为什么需要内部也能抛出异常取消?这样协程可以在代码内部发现业务失败时,主动通过抛出
补充说明
- 父子协程抛出异常的实际时机并不确定,依赖于取消传播和检查点的执行时机。
- 如果协程内部没有挂起点、检查点或主动检测
isActive
,即使外部 cancel 也不会真正终止协程。
协程的取消:
- 协程的取消本质上需要内部代码主动响应(检查
isActive
或遇到挂起点)。 - 挂起函数会自动检测并响应取消,自己实现挂起函数要手动检查活跃状态。
- 抛出
CancellationException
能递归取消所有子协程,无论是外部还是协程自身发起取消,效果相同。
子协程可以拒绝取消吗?
协程的取消有两种方式:
- 外部调用
cancel()
。 - 协程内部主动抛出
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 中,线程的未捕获异常只会导致该线程崩溃,不会导致整个应用崩溃。安卓特意添加了“线程崩溃 = 应用崩溃”的机制。
-
协程异常传播本质依赖于
JobSupport
的childCancelled
方法: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()
方法。 -
取消流程:有两种方式
- 抛出
CancellationException
(如throw CancellationException()
)。 - 直接调用协程的
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
}
结论
-
异常影响范围:
- 首先会影响以该 async 协程为父协程的整个协程树。
- 因为 async 的 deferred 结果可以被 await,所以所有 await 该 deferred 的代码也会受到影响(即 await 处也会抛出异常)。
- 若 await 发生在同一协程树内,还会伴随结构化取消的传播(CancellationException)。
-
异常传播优先级:
- 如果 async 内部抛出的是普通异常(如 RuntimeException),await 处首先抛出该异常。
- CancellationException 的传播是结构化、延迟的,普通异常优先。
-
与 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)
}
-
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 时,异常需要在子协程中单独处理,否则会直接冒泡到线程。