盘点 13 种常见的“协程异常处理”踩坑场景

2025博客之星年度评选已开启 10w+人浏览 2k人参与

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

下面我们拆解实际开发中必踩的 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 有两个核心职责:
    1. 必须等所有子 Job 完成,自己才能完成;
    2. 如果某个子 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:我完成啦

可以看到:父作用域直到 job1job2(以及 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已经完成了(但结果也没被处理)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

fundroid

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值