找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2328

积分

1

好友

321

主题
发表于 7 天前 | 查看: 20| 回复: 0

协程中的异常处理,本质上体现了“结构化并发”的核心思想。这就像一个家庭分工:孩子(子协程)出了错,必须明确由谁来负责处理:是孩子自己解决,还是交给家长(父协程)?如果没人管,错误会沿着调用链一层层向上传播,最终可能导致整个任务失败。

下面我们将拆解实际开发中常见的 13 种 Kotlin 协程异常处理场景,帮助你进行一次无死角的全面梳理,避免踩坑。

场景 1:launch 协程的坑

launch 是“发后即忘”(fire-and-forget)型协程:你启动它,但通常不需要等待返回结果。然而,它的异常处理是新手容易犯错的地方。

错误用法:在 launch 外面包 try-catch

直接在 launch 调用外层包裹 try-catch 是完全无效的。为什么? launch 函数会立即返回一个 Job 对象,其内部的代码块是在另一个协程中异步执行的。异常发生在新启动的协程内部,外层的 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() 获取结果。它的异常处理方式与 launch 不同——异常不会立即抛出,而是被“隐藏”在返回的 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(“程序结束”)
}

场景 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,导致清理逻辑被中断。

解决方案:在清理块中使用 withContext(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的子协程”的日志正常打印了(说明没被内部异常影响),但“内部作用域的兄弟协程”没有打印后续内容(因为它被同作用域内的异常取消了)。另外,这个异常最终导致程序崩溃,因为它没有被任何 try-catchCoroutineExceptionHandler 捕获。

场景 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:我完成啦

可以看到:父作用域(runBlocking)直到 job1job2(以及 job2 的子 Job Grandchild)都完成后,才结束执行。

场景 11:supervisorScope vs CoroutineScope(SupervisorJob())

这是一个微妙但重要的架构区别,特别是在设计如 Android ViewModel 或后台服务组件时:

  • supervisorScope { ... }:创建一个“局部隔离块”。块内子协程的失败不会相互影响,也不会导致作用域立即取消。但是,如果块内有未捕获的异常,它仍然会向上传播到外层的父协程。
  • CoroutineScope(SupervisorJob()):创建一个长期存活的作用域对象,所有通过它启动的协程都是这个 SupervisorJob 的直接子 Job。这种模式非常适合“独立组件”——单个任务的失败既不会销毁作用域本身,也不会影响其他任务,为多线程和异步任务管理提供了隔离的环境。

示例:类似 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(它是 CancellationException 的子类),从而取消正在执行的协程。
  • 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 时的异常处理

当你启动多个 async 协程并通过 awaitAll() 等待它们全部完成时,awaitAll() 的行为遵循“快速失败”原则:一旦其中某个协程失败,它会立刻尝试取消所有其他尚未完成的协程,然后传播第一个抛出的异常——这与 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虽然已经完成了(日志打印了),但其返回的结果也没有被处理。

总结

掌握 Kotlin 协程的异常处理,关键在于理解“结构化并发”中错误传播的规则。牢记 launch 内部捕获、asyncawait 时捕获,根据场景选择 coroutineScope(一损俱损)或 supervisorScope(隔离失败),并善用 CoroutineExceptionHandler 作为最后防线。理解 Job 的层级和取消机制,能帮助你构建出更健壮、可维护的并发代码。希望这 13 个场景能帮助你在使用 Kotlin 协程时更加得心应手。如果你想深入探讨更多 Kotlin 或 Android 开发中的并发问题,欢迎到云栈社区与广大开发者交流。




上一篇:Java单例、模板方法与代理模式详解与实战场景对比
下一篇:SpringBoot项目中是否应该使用统一包装类?深入解析三种方案与选型策略
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-10 18:32 , Processed in 0.234880 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表