协程里的异常处理,本质上是“结构化并发”——就像一个家庭分工:孩子(子协程)出了错,得明确是谁来接盘:是孩子自己处理,还是交给家长(父协程)?如果没人管,错误会一层层往上“甩锅”,最后“全家遭殃”。

下面我们拆解实际开发中必踩的 13 种 Kotlin 协程异常场景,帮大家来一次无死角的全面梳理。
场景 1:launch 协程的坑
launch 是“fire-and-forget”(发后即忘)型协程:你启动它,但不需要返回结果。但它的异常处理是个坑,新手很容易写错。
错误用法:在 launch 外面包 try-catch
直接在 launch 调用外层加 try-catch 完全没用。
为啥? launch 会立刻返回,内部代码是在另一个协程里跑的。异常发生在新协程里,外层的 try-catch 根本“抓不到”,错误会直接往上抛,程序直接崩溃。
错误示例代码
import kotlinx.coroutines.*
fun main() = runBlocking {
try {
// 错误:在launch外面加try-catch没用
launch {
println("before launch")
throw RuntimeException("launch 操作失败...")
}
} catch (e: Exception) {
// 这里永远不会执行
println("捕获到异常:${e.message}")
}
println("after launch")
delay(100) // 等一下协程执行
}
输出:
before launch
after launch
Exception in thread "main" java.lang.RuntimeException: launch 操作失败...
...(程序崩溃)...
注意:“捕获到异常”这句话根本没打印,程序直接挂了。
正确用法:在 launch 内部加 try-catch
要处理 launch 协程的异常,必须把 try-catch 直接写在协程的 lambda 里。
为啥? 异常在哪发生,就在哪处理。这样能精准控制错误,避免程序崩溃。
正确示例代码
import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
try {
println("before launch")
throw RuntimeException("launch 操作失败...")
} catch (e: Exception) {
// 这里能捕获到异常
println("launch 内部捕获异常:${e.message}")
}
}
println("after launch")
delay(100)
println("程序正常结束")
}
输出:
before launch
after launch
launch 内部捕获异常:launch 操作失败...
程序正常结束
这次程序优雅地处理了异常,没有崩溃。
场景 2:async 协程的“延迟异常”
async 用于“需要返回结果”的协程:你启动它,之后通过 await() 获取结果。它的异常不会立即抛出,而是“藏”在返回的 Deferred 对象里——直到你调用 await() 才会爆发。
错误用法:在 async 外面包 try-catch
和 launch 一样,在 async 调用外层加 try-catch 没用。
为啥? async 会立即返回 Deferred 对象,异常被“存”在这个对象里,直到你调用 await() 才会抛出。
正确用法:在 await() 外面包 try-catch
async 内部的异常,只有在调用 await() 获取结果时才会抛出——这才是“抓异常的时机”。
为啥? 这种设计让你可以自主决定“什么时候、怎么处理异常”,异常是返回结果的一部分。
示例代码
fun main() = runBlocking {
println("before async")
// 启动async,异常会存在Deferred里
val deferredResult: Deferred<Unit> = async {
throw RuntimeException("async 操作失败...")
}
println("after async")
// 错误:这里没调用await,异常不会抛出
try {
println("还没调用await,异常不会触发")
} catch (e: Exception) {
println("这里抓不到异常")
}
// 正确:在await外面加try-catch
try {
deferredResult.await() // 调用await,异常爆发
} catch (e: Exception) {
println("捕获到async异常:${e.message}")
}
println("程序结束")
}
输出:
before async
after async
还没调用await,异常不会触发
捕获到async异常:async 操作失败...
程序结束
进阶:在 async 内部加 try-catch
如果在 async 内部捕获了异常,并且返回了结果,那么调用 await() 时就不会再抛出异常了——异常被“就地解决”。
fun main() = runBlocking {
println("Before async")
val deferredResult: Deferred<String> = async {
println("async内部: 即将发生异常...")
delay(500)
throw IllegalStateException("async 操作失败...")
"此处因为异常执行不到,永远无法返回"
}
println("After async")
try {
// 调用 await 时,异常会在此处抛出
val result = deferredResult.await()
println("Result: $result")
} catch (e: Exception) {
// 异常处理
println("捕获到async异常: ${e.message}")
}
println("程序结束")
}
输出:
Before async
After async
async内部: 即将发生异常...
捕获到async异常:async 操作失败...
Result: Exception occurred inside aync
程序结束
场景 3:父子协程“一损俱损”(coroutineScope)
当协程用 coroutineScope 创建“子协程”时,子协程的作用域会“继承”父协程:只要有一个子协程失败,整个作用域会立即取消所有其他子协程,然后自己也失败。
“一损俱损”的逻辑
为啥要这样? 当一个操作的某部分失败时,继续执行其他部分可能会导致“数据不一致”——这时候最好取消整个操作。
示例代码
fun main() = runBlocking {
try {
coroutineScope { // 创建一个子作用域
println("启动子作用域...")
// 子协程1:正常执行
launch {
println("子协程1:开始工作...")
delay(1000) // 模拟耗时
println("子协程1:完成工作") // 不会执行,因为子协程2先失败
println("子协程1:我没被取消") // 不会执行
}
// 子协程2:抛出异常
launch {
println("子协程2:开始工作...")
throw RuntimeException("子协程2 失败...")
}
}
} catch (e: Exception) {
println("父作用域捕获异常:${e.message}")
}
println("作用域结束")
}
输出:
启动子作用域...
子协程1:开始工作...
子协程2:开始工作...
父作用域捕获异常:子协程2 失败...
作用域结束
可以看到:子协程2失败后,子协程1被立即取消,没来得及完成工作。
嵌套作用域的表现
如果在 coroutineScope 里再嵌套一个 coroutineScope,那么“一损俱损”的规则会传递——内层作用域的失败会导致外层作用域也取消。
场景 4:隔离失败(supervisorScope)
如果想让“某个子协程失败不影响其他协程”,就用 supervisorScope。
supervisorScope 的逻辑
supervisorScope 会“覆盖”父协程的取消规则:某个子协程失败时,不会取消其他子协程,也不会让父作用域失败——失败被“隔离”在单个子协程内。
注意:supervisorScope 不会自动吞异常
如果直接在 supervisorScope 里抛异常,程序还是会崩溃——因为它只是组织“协程取消”的扩散,但不会“吞异常”。你得在子协程内部加 try-catch 处理异常。
错误示例(没处理子协程异常)
fun main() = runBlocking {
supervisorScope { // 启动监督作用域
println("启动监督作用域...")
// 子协程1:抛出异常
launch {
println("子协程1:开始工作...")
throw RuntimeException("子协程1 失败...")
}
// 子协程2:正常执行
launch {
println("子协程2:开始工作...")
delay(1000)
println("子协程2:完成工作") // 会执行,不受子协程1影响
println("子协程2:我没被取消")
}
}
println("监督作用域结束")
}
输出:
启动监督作用域...
子协程1:开始工作...
子协程2:开始工作...
Exception in thread "main" java.lang.RuntimeException: 子协程1 失败...
...(程序崩溃)...
正确用法:在子协程内部加 try-catch
要让 supervisorScope 真正“隔离失败”,必须在每个子协程内部处理异常。
正确示例代码
fun main() = runBlocking {
supervisorScope {
println("启动监督作用域...")
// 子协程1:内部处理异常
launch {
try {
println("子协程1:开始工作...")
throw RuntimeException("子协程1 失败...")
} catch (e: Exception) {
println("子协程1 捕获异常:${e.message}")
}
}
// 子协程2:不受影响
launch {
println("子协程2:开始工作...")
delay(1000)
println("子协程2:完成工作")
}
}
println("监督作用域结束")
}
输出:
启动监督作用域...
子协程1:开始工作...
子协程2:开始工作...
子协程1 捕获异常:子协程1 失败...
子协程2:完成工作
监督作用域结束
现在子协程1的失败被隔离,子协程2正常完成,程序也不会崩溃。
场景 5:最后的防线(CoroutineExceptionHandler)
CoroutineExceptionHandler 是“全局兜底”的异常处理器:它能捕获所有未被处理的协程异常,通常用于日志记录、错误上报或程序优雅退出。
适用场景
- 主要用于
launch这类“发后即忘”的协程; - 在
async里基本没用(因为异常会存在Deferred里,直到await()才抛出)。
不适用场景
- 不能处理
coroutineScope里的异常(父协程会被取消); - 不能处理
await()抛出的异常(需要在await()外层加try-catch)。
示例代码
fun main() = runBlocking {
// 定义全局异常处理器
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
println("全局异常处理器捕获:${throwable.message},协程ID:${coroutineContext[CoroutineId]}")
}
// 给launch指定异常处理器
launch(exceptionHandler) {
throw RuntimeException("launch 未捕获的异常")
}
delay(100) // 等待协程执行完毕
println("程序正常结束")
}
输出
全局异常处理器捕获:launch 未捕获的异常,协程ID:CoroutineId(1)
程序正常结束
场景 6:supervisorScope 里的 async
supervisorScope 能阻止“子协程失败导致其他协程取消”,但 async 的异常还是会存在 Deferred 对象里,直到调用 await() 才会抛出。
关键逻辑:supervisorScope 确保“一个 async 失败,其他 async 继续执行”,但失败的 async 的异常,还是得在 await() 时处理。
示例代码
fun main() = runBlocking {
supervisorScope {
// async1 抛出异常
val deferred1 = async {
println("async1 开始执行")
throw RuntimeException("async1 执行失败")
}
// async2 正常执行
val deferred2 = async {
println("async2 开始执行")
delay(500)
"async2 执行成功"
}
// 处理async1的异常
try {
deferred1.await()
} catch (e: Exception) {
println("捕获async1异常:${e.message}")
}
// 获取async2的结果
val result2 = deferred2.await()
println("async2结果:$result2")
}
println("supervisorScope 执行完毕")
}
输出
async1 开始执行
async2 开始执行
捕获async1异常:async1 执行失败
async2结果:async2 执行成功
supervisorScope 执行完毕
场景 7:取消操作是一种特殊的异常
当协程被取消时,它会抛出 CancellationException——这是一种特殊异常,协程机制通常会忽略它。
关键细节:取消异常是协程执行流程里的“常规操作”。虽然能用 try-catch 捕获它,但通常不建议这么做。
如果确实需要捕获它来执行某些操作(比如资源清理),处理完后一定要重新抛出,这样取消流程才能正常完成。而清理逻辑的最佳位置,其实是 finally 代码块。
示例代码
fun main() = runBlocking {
val job = launch {
try {
println("Job:我正在工作...")
delay(1000) // 模拟耗时任务
println("Job:这段内容不会被打印") // 取消后不会执行
} catch (e: CancellationException) {
println("Job:我被取消了,捕获到取消异常")
} catch (e: Exception) {
println("Job:捕获到其他异常:${e.message}")
} finally {
// 这里写资源清理逻辑
println("Job:finally 块执行(用于清理)")
}
}
delay(500) // 等 0.5 秒后取消
println("我不想等了,取消这个任务")
job.cancelAndJoin() // 取消并等待协程结束
println("Job 已经被取消啦")
}
输出
Job:我正在工作...
我不想等了,取消这个任务
Job:我被取消了,捕获到取消异常
Job:finally 块执行(用于清理)
Job 已经被取消啦
场景 8:用 NonCancellable 实现“必须执行的清理”
如果你的 finally 块里有挂起函数(比如写文件、网络请求),一旦协程已经被取消,块内的挂起函数会立刻抛出 CancellationException,导致清理逻辑中断。
解决方案:在清理块中切换到 NonCancellable 上下文。这样块内的代码就会在“不可取消”的环境中执行,确保清理逻辑能完整跑完。
示例代码
fun main() = runBlocking {
val job = launch {
println("Job:工作中...")
try {
delay(1000)
} finally {
// 切换到不可取消上下文执行清理
withContext(NonCancellable) {
println("Job:开始执行需要 500ms 的关键清理操作...")
delay(500) // 即使协程被取消,这里也会执行完
println("Job:关键清理完成")
}
println("Job:普通清理逻辑(可选)")
}
}
delay(500) // 等 0.5 秒后取消
println("取消这个任务")
job.cancelAndJoin()
println("Job 已被取消")
}
输出
Job:工作中...
取消这个任务
Job:开始执行需要 500ms 的关键清理操作...
Job:关键清理完成
Job:普通清理逻辑(可选)
Job 已被取消
可以看到:虽然 Job 被取消了,但 NonCancellable 上下文中的 delay(500) 还是完整执行完了。
场景 9:嵌套作用域的异常传播
coroutineScope(失败传播)和 supervisorScope(失败隔离)的规则,在嵌套时会变得更有意思。核心原则是:内部作用域的规则会覆盖外部作用域。
具体来说:内部 coroutineScope 里的异常,会取消它的“兄弟协程”,但不会影响外部 supervisorScope 的其他子协程。
示例代码
import kotlinx.coroutines.*
fun main() = runBlocking {
// 外部是 supervisorScope,会隔离直接子协程的失败
supervisorScope {
// 第一个子协程:不会被取消
launch {
println("Supervisor的子协程:我存活下来了")
}
// 第二个子协程:内部包含自己的 coroutineScope
launch {
delay(100)
// 内部 coroutineScope:失败会取消自己的子协程
coroutineScope {
println("内部作用域的兄弟协程:我会被下面的失败取消")
delay(100)
launch {
delay(100)
println("内部作用域的子协程:我要抛异常啦!")
throw RuntimeException("内部作用域的失败")
}
}
}
}
println("全部完成")
}
输出
Supervisor的子协程:我存活下来了
内部作用域的兄弟协程:我会被下面的失败取消
内部作用域的子协程:我要抛异常啦!
Exception in thread "main" java.lang.RuntimeException: 内部作用域的失败
注意:“Supervisor的子协程”的日志正常打印了(说明没被影响),但“内部作用域的兄弟协程”没打印后续内容(因为被异常取消了)。另外,这个异常最终导致程序崩溃,因为它没被捕获。
场景 10:深入理解 Job 层级结构
结构化并发的核心是“Job 层级”——可以把它想象成一棵“任务树”。
- 当你在协程(或作用域)内部启动新协程时,新协程的 Job 会成为“父 Job”的子 Job。
- 父 Job 有两个核心职责:
- 必须等所有子 Job 完成,自己才能完成;
- 如果某个子 Job 抛了未捕获的异常(且不是在
supervisorScope里),父 Job 会取消所有其他子 Job,然后自己也失败。
这种“父子关联”正是协程“结构化”的体现,能避免你丢失对任务的跟踪。
示例代码(结构可视化)
import kotlinx.coroutines.*
fun main() = runBlocking { // 根 Job(父)
println("Parent:我是父作用域")
// Job1:父作用域的子 Job
val job1 = launch {
println("Child 1:我是父作用域的子协程")
delay(1000)
println("Child 1:我完成啦")
}
// Job2:父作用域的另一个子 Job
val job2 = launch {
println("Child 2:我也是子协程")
// 内部启动 Grandchild:Job2 的子 Job
launch {
println("Grandchild:我的父是 Child 2")
delay(500)
println("Grandchild:我完成啦")
}
println("Child 2:我在等 Grandchild 完成")
}
// 父作用域会等 job1 和 job2 都完成才结束
}
输出
Parent:我是父作用域
Child 1:我是父作用域的子协程
Child 2:我也是子协程
Grandchild:我的父是 Child 2
Child 2:我在等 Grandchild 完成
Grandchild:我完成啦
Child 1:我完成啦
可以看到:父作用域直到 job1 和 job2(以及 job2 的子 Job Grandchild)都完成后,才结束执行。
场景 11:supervisorScope vs CoroutineScope(SupervisorJob())
这是一个微妙但重要的架构区别:
supervisorScope { ... }:创建一个“局部隔离块”,块内的失败不会扩散(用于隔离一组相关任务)。但如果块内有未捕获的异常,还是会扩散到外部。CoroutineScope(SupervisorJob()):创建一个作用域对象,所有子 Job 直接关联到这个作用域。这种模式适合“独立组件”(比如服务器、Android 的 ViewModel)——单个任务失败不会销毁作用域本身,也不会影响其他任务。
示例:类似 ViewModel 的作用域
import kotlinx.coroutines.*
// 创建一个用于组件的作用域:用 SupervisorJob 隔离失败
val componentScope = CoroutineScope(SupervisorJob())
// 这个任务会失败,但不会影响作用域内的其他任务
fun startRiskyTask() = componentScope.launch {
println("风险任务:启动中...")
delay(100)
throw RuntimeException("风险任务出问题了")
}
// 这个任务独立,不会被风险任务的失败影响
fun startSafeTask() = componentScope.launch {
println("安全任务:启动中...")
delay(200)
println("安全任务:成功完成!")
}
fun main() = runBlocking {
startRiskyTask()
startSafeTask()
delay(500) // 等一会儿
componentScope.cancel() // 最后销毁组件作用域
}
输出
风险任务:启动中...
安全任务:启动中...
Exception in thread "main" java.lang.RuntimeException: 风险任务出问题了
安全任务:成功完成!
可以看到:虽然“风险任务”失败了,但“安全任务”还是正常完成了——因为我们用了 SupervisorJob,作用域本身保持活跃,直到主动取消。
场景 12:处理超时
长时间运行的任务可能出问题,协程提供了简洁的超时处理方式:
withTimeout(timeMillis) { ... }:如果代码块执行超时,会抛出TimeoutCancellationException(属于取消异常),从而取消协程。withTimeoutOrNull(timeMillis) { ... }:超时后不会抛异常,而是返回null。这种方式通常更简洁易维护。
示例代码
import kotlinx.coroutines.*
fun main() = runBlocking {
// 方式1:withTimeout 抛异常
try {
withTimeout(1000) {
println("任务1:我需要 2 秒才能完成")
delay(2000)
println("任务1:这段不会被打印(超时了)")
}
} catch (e: TimeoutCancellationException) {
println("任务1:超时啦!")
}
// 方式2:withTimeoutOrNull 返回 null
val result = withTimeoutOrNull(1000) {
println("任务2:我也需要 2 秒")
delay(2000)
"任务2完成" // 超时后不会执行到这
}
println("任务2:超时了,结果是 $result")
}
输出
任务1:我需要 2 秒才能完成
任务1:超时啦!
任务2:我也需要 2 秒
任务2:超时了,结果是 null
场景 13:等待多个 Job 时的异常处理
当你启动多个协程并通过 awaitAll() 等待它们时,awaitAll() 会立刻取消所有其他 Job,然后传播第一个抛出的异常——这和 coroutineScope 的“快速失败”原则是一致的。
示例代码
import kotlinx.coroutines.*
fun main() = runBlocking {
println("启动多个异步任务")
val deferreds = listOf(
// 任务1:成功
async {
delay(100)
println("任务1:成功")
"结果1"
},
// 任务2:失败
async {
delay(50)
println("任务2:失败")
throw RuntimeException("任务2出错了")
},
// 任务3:会被取消
async {
delay(200)
println("任务3:成功(但会被取消)")
"结果3"
}
)
try {
deferreds.awaitAll()
} catch (e: Exception) {
println("捕获到异常:${e.message}")
}
}
输出
启动多个异步任务
任务2:失败
捕获到异常:任务2出错了
可以看到:任务2失败后,任务3被立刻取消(所以它的日志没打印),而任务1已经完成了(但结果也没被处理)。

872

被折叠的 条评论
为什么被折叠?



