让协程更健壮:全面的异常处理策略

"失效"的 try-catch

从一个典型的场景开始:

kotlin 复制代码
val scope = CoroutineScope(Dispatchers.Default)
try {
    scope.launch {
        println("Coroutine started")
        throw RuntimeException("Something went wrong!")
    }
} catch (e: RuntimeException) {
    println("Caught exception: $e")
}

运行发现,异常并没有被捕获,控制台还是打印了异常堆栈。

为什么呢?

很简单,这里的 try-catch 只是包住了协程启动的动作 launch,并没有包住协程内部 的异步代码块。启动过程(launch 函数)没有抛出异常,catch 块自然不会执行。

这就好比使用 try-catch 包裹 Thread().start() 一样,是无法捕获到线程内部抛出的异常的。

所以正确的捕获方式应该是:

kotlin 复制代码
val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
    try {
        println("Coroutine started")
        throw RuntimeException("Something went wrong!")
    } catch (e: RuntimeException) {
        println("Caught exception: $e")
    }
}

如果不对异常进行捕获,最终会导致整个父子协程树都被取消。

kotlin 复制代码
val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
    launch {
        launch { throw RuntimeException("Something went wrong!") }
        launch {}
    }
    launch {
        launch {}
        launch {}
    }
}

这种设计是合理的,因为子任务的失败,往往意味着整个父任务也失败了,有必要结束整个任务树。

核心原理

理解协程的异常机制之前,我们先来回顾一下协程的取消机制。

协程的取消机制

取消机制具体可以看我的上一篇博客:优雅地处理协程:取消机制深度剖析

协程的取消,本质上是内部通过抛出一个特殊的 CancellationException() 异常来实现的。注意:它是一个取消请求停止信号

当协程被取消时(无论是内部抛出 CancellationException 还是外部调用 job.cancel()),就会执行取消流程

  1. 停止自身的工作。
  2. 取消请求会向下传播,协程会取消其所有子协程,子协程还会再继续以同样的方式取消自己的子协程。
  3. 取消请求不会向上传播,子协程的取消并不会影响父协程的执行。
  4. CancellationException 异常最终会被协程静默处理,并不会导致程序崩溃。
kotlin 复制代码
val scope = CoroutineScope(Dispatchers.Default)
scope.launch { // 父协程
    launch { // 子协程
        launch { // 孙协程
            println("Grandson Job started")
            delay(3000)
            println("Grandson Job ended") // 不会执行
        }
        println("Child Job started")
        delay(1500)
        throw CancellationException() // 发出停止信号
        println("Child Job ended") // 不会执行
    }
    println("Parent Job started")
    delay(3000)
    println("Parent Job ended") // 正常执行
}

运行结果:

less 复制代码
Parent Job started
Child Job started
Grandson Job started
Parent Job ended

协程的异常机制

当协程内部抛出的异常是任何非 CancellationException 的普通异常(如 RuntimeException)时,就会执行异常流程

异常流程可以看作是取消流程的扩展,它意味着一次失败。

首先,这个普通异常会向上传播给它的父协程。父协程收到后,会因为这个异常取消自己。然后接着把这个异常传播给自己的父协程,直到协程树的根节点。

在上述传播过程中,每一个收到了失败信号的父协程,都会取消自己以及所有子协程 。这个流程是通过 CancellationException 来完成的。

这个机制,使得任何一个子任务的失败,都会导致整个协程树的终止。最终,那个导致失败的普通异常,会到达根协程,如果没有处理,还会抛给当前线程。

kotlin 复制代码
val scope = CoroutineScope(Dispatchers.Default)
scope.launch { // 父协程
    launch { // 子协程
        launch { // 孙协程
            println("Grandson Job started")
            delay(3000)
            println("Grandson Job ended") // 会被取消,不执行
        }
        println("Child Job started")
        delay(1500)
        throw RuntimeException("Something went wrong!") // 触发异常流程
        println("Child Job ended") // 不会执行
    }
    println("Parent Job started")
    delay(3000)
    println("Parent Job ended") // 会被取消,不执行
}

运行结果:

less 复制代码
Parent Job started
Child Job started
Grandson Job started
Exception in thread "DefaultDispatcher-worker-3" java.lang.RuntimeException: Something went wrong!
...

CoroutineExceptionHandler 异常处理工具

为了拿到所有协程抛出的未捕获异常,我们需要用到 CoroutineExceptionHandler

它是一个协程上下文元素(CoroutineContext.Element),使用如下:

kotlin 复制代码
// 创建 Handler
val handler = CoroutineExceptionHandler { context, exception ->
    println("Caught exception in handler: $exception")
}

val scope = CoroutineScope(EmptyCoroutineContext)
// 在启动协程时安装 Handler
val job = scope.launch(handler) {
    launch {
        throw AssertionError("My Custom Error")
    }
    launch {

    }
}
job.join()

运行结果:

less 复制代码
Caught exception in handler: java.lang.AssertionError: My Custom Error

注意:CoroutineExceptionHandler 只有安装到最外层协程上才有效(根协程),放在子协程上是无效的。

为什么要这么设计,请接着往下看。

善后工作

要理解 CoroutineExceptionHandler 的设计思想,先要来看看 Java 的 Thread.UncaughtExceptionHandler

它的作用是抓取线程中未捕获的异常,比如我们给一个线程对象设置:

kotlin 复制代码
val thread = Thread {
    throw AssertionError("My Custom Error")
}
thread.uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { _, e ->
    println("Caught exception in thread: $e")
}
thread.start()

这样,当线程内部抛出异常时,会进入到 UncaughtExceptionHandler 的回调中,将异常信息打印出来。

我们也可以给 Thread 类设置,这样就能捕获所有线程中未捕获的异常。

kotlin 复制代码
Thread.setDefaultUncaughtExceptionHandler { _, e ->
    println("Default Caught exception in thread: $e")
}

val thread = Thread {
    throw AssertionError("My Custom Error")
}
thread.start()

那么,它的作用是在这解决异常背后的问题、修复出错的线程吗?

不是的。首先我们未捕获的异常,说明这是未知的异常,我们并不知道该如何修复;其次,当异常出现在 UncaughtExceptionHandler 中时,意味着线程已经死亡了,没机会修复。

它的真正作用是善后,就是记录崩溃日志,然后让应用优雅地关闭。

发生异常后,程序已经进入不稳定的损坏状态,行为往往不可预期,这时让它关闭就行。

而协程的异常处理也是一样的:

  • 对于未知异常 ,我们会使用 CoroutineExceptionHandler 来处理;

  • 对于可预期的异常 ,我们就在协程内部使用 try-catch 来捕获,然后执行恢复逻辑。

当然,在使用协程的同时,还可以使用线程的 Thread.UncaughtExceptionHandler。这会形成双重保障: CoroutineExceptionHandler 会先在协程层拦截,如果没有设置拦截,异常会被抛到线程,这时 Thread.UncaughtExceptionHandler 又会拦截一次。

现在,我们来回答为什么 CoroutineExceptionHandler 要设计为只在根协程中生效?

这有两个原因:

  1. 首先子协程的异常会一路向上传播,最终到达根协程。所以,只有注册在根协程才能捕获得到异常。

  2. 其次,协程与线程不同,单个线程往往只是整个工作的一小部分。而协程是结构化的,一个协程树往往代表了完整的功能。

    当功能内部出现了未知异常,说明整个功能已经不可信。这时,我们应该在功能的最顶层(根协程)中进行统一的善后处理,比如记录日志、重启整个功能。

async/await 与 SupervisorJob 的特殊场景

async 的双重通道

async 是启动协程的另一个构建器,它的异常处理和 launch 构建器有些许区别。

async 的异常传播渠道:

  • 结构化渠道:首先,在它内部抛出普通异常时,这个普通异常会向上传播给父协程,从而破坏整个协程树。

  • 结果渠道 :其次,异常会存放在返回的 Deferred 对象中,当调用 Deferred.await() 时,异常会再次被抛出,影响调用处所在的协程。

kotlin 复制代码
val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
    val deferred = async {
        delay(1000)
        throw RuntimeException("error")
    }

    launch { 
        try {
            deferred.await()
        } catch (e: Exception) {
            when (e) {
                is CancellationException -> println("CancellationException")
                else -> println("error")
            }
        }
    }
}

那么,这段代码打印结果是什么?

其实,此处的协程会受到异常的双重影响。既会接收到从 async 中抛出的 RuntimeException 异常,又会从父协程中收到 CancellationException 异常。但由于结果渠道的传播速度更快,所以最终打印的是 "error"。

运行结果:

less 复制代码
error

证明也很简单,在后面再加上一个 try-catch 块来捕获稍后到达的 CancellationException

diff 复制代码
val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
    val deferred = async {
        delay(1000)
        throw RuntimeException("error")
    }

    launch {
        try {
            deferred.await()
        } catch (e: Exception) {
            when (e) {
                is CancellationException -> println("CancellationException")
                else -> println("error")
            }
        }

+        try {
+            delay(1000)
+        } catch (e: CancellationException) {
+            println("Caught async exception: $e")
+        }
    }
}

运行结果:

less 复制代码
error
Caught async exception: kotlinx.coroutines.JobCancellationException: Parent job is Cancelling; job=StandaloneCoroutine{Cancelling}@245da7ec

这证明了,在 await 抛出原始异常后,协程也确实收到了来自结构化传播的 CancellationException

因此,处理 async 异常的最佳实践,就是在调用 Deferred.await() 的地方使用 try-catch

另外,async 所启动的根协程,并不会将异常抛给线程,所以 CoroutineExceptionHandler 也是无效的。

异常会在 await() 调用时抛出,为什么呢?因为 launch 通常是一整个任务,它抛出异常意味着任务的失败。而 async 被用于返回一个结果,它抛出异常会被视为结果的一部分,应该让 await() 的调用方来处理。

SupervisorJob 隔绝内部异常

kotlin 复制代码
private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
    override fun childCancelled(cause: Throwable): Boolean = false
}

SupervisorJob 是一种特殊的 Job,它和 Job 的区别就在于 childCancelled() 方法的实现不同。不管子协程抛出的是普通异常还是 CancellationExceptionSupervisorJob 都会返回 false

子协程被取消时,父协程会调用 childCancelled() 方法。

这意味着异常将不再向上传播,SupervisorJob 的子协程抛出普通异常,不会取消 SupervisorJob,也不会取消子它的兄弟协程,SupervisorJob 切断了从子到父的异常传播链。

当然,取消 SupervisorJob 时,其所有子协程依然会被取消。

kotlin 复制代码
fun main() = runBlocking {
    // 异常不向上传播
    val supervisorJobFirst = SupervisorJob(parent = null)
    launch(supervisorJobFirst) {
        delay(1000)
        throw RuntimeException("error!")
    }
    println("SupervisorJob cancelled: ${supervisorJobFirst.isCancelled}") // false
    delay(1500)
    println("SupervisorJob cancelled: ${supervisorJobFirst.isCancelled}") // false,SupervisorJob 未被取消

    // 结构化取消
    var supervisorJobSecond: CompletableJob
    val job = launch {
        // 这是开发中一种常见的格式,SupervisorJob 作为连接内外的链条
        supervisorJobSecond = SupervisorJob(parent = coroutineContext.job)
        launch(supervisorJobSecond) {
            try {
                delay(1000)
            } catch (e: CancellationException) {
                println("Caught exception is $e") // Caught exception is kotlinx.coroutines.JobCancellationException...
            }
        }
    }
    delay(500)
    job.cancel()
    job.join()
}

我们常常会在不希望一个子协程的失败影响到父协程和兄弟协程时使用 SupervisorJob

最后,SupervisorJob 的子协程在处理异常时,表现得就像独立的根协程一样。比如我们可以直接在这些子协程上安装 CoroutineExceptionHandler,这是有效的。

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("Caught exception is $throwable")
    }
    launch {
        launch(SupervisorJob(parent = coroutineContext.job) + coroutineExceptionHandler) {
            throw Exception("Exception from child coroutine")
        }
    }
}

运行结果:

less 复制代码
Caught exception is java.lang.Exception: Exception from child coroutine
相关推荐
2501_915918414 小时前
iOS 混淆实战 多工具组合完成 IPA 混淆、加固与工程化落地(iOS混淆|IPA加固|无源码混淆|Ipa Guard|Swift Shield)
android·ios·小程序·https·uni-app·iphone·webview
Jeled5 小时前
AI: 生成Android自我学习路线规划与实战
android·学习·面试·kotlin
消失的旧时光-19436 小时前
@JvmStatic 的作用
java·开发语言·kotlin
游戏开发爱好者86 小时前
如何系统化掌握 iOS 26 App 耗电管理,多工具协作
android·macos·ios·小程序·uni-app·cocoa·iphone
shaominjin1236 小时前
android在sd卡中可以mkdir, 但是不可以createNewFile
android·开发语言·python
AI科技星7 小时前
垂直原理:宇宙的沉默法则与万物运动的终极源头
android·服务器·数据结构·数据库·人工智能
wb043072017 小时前
如何开发一个 IDEA 插件通过 Ollama 调用大模型为方法生成仙侠风格的注释
人工智能·语言模型·kotlin·intellij-idea
用户41659673693558 小时前
Kotlin Coroutine Flow 深度解析:剖析 `flowOn` 与上下文切换的奥秘
android
2501_915921438 小时前
运营日志驱动,在 iOS 26 上掌握 App 日志管理实践
android·macos·ios·小程序·uni-app·cocoa·iphone