第092篇 协程异常处理:从 try-catch 到 CoroutineExceptionHandler

简介: 协程最危险的Bug不是崩溃,而是“静默不干活”——根源常是误吞`CancellationException`。本文详解异常传播路径:`launch`向上抛、`async`延迟至`await`;强调`CancellationException`必须原样重抛,否则协程卡在Cancelling态;明确`CoroutineExceptionHandler`仅对根协程生效。附分层错误建模与避坑实践。

协程里最贵的 bug 不是崩溃,是"静默地不干活"。上一节把 Job 的取消传播讲清了,本节顺着异常这条线往下走:异常从协程抛出后经过哪些环节、到哪儿被接住、CancellationException 为什么要特殊对待、以及 CoroutineExceptionHandler 究竟在什么位置生效。这题在面试里的分层非常明显——只答"用 try/catch"是入门,能把传播路径和取消语义讲完整,才算过了这一关。

先把结论放在前面:协程异常处理有三条主线。①传播规则:launch 的异常会取消父 Job 并向上抛,async 的异常被 Deferred 持有、需 await() 才会重新抛出;②取消语义:CancellationException 是协程的正常终止信号,不该被 catch 吞掉,吞掉会导致 Job 一直停在 Cancelling 状态(协程"卡住不结束");③兜底位置:CoroutineExceptionHandler 只对根协程(没有父 Job 的)生效,给子协程装它不起作用。

机制拆解

先看异常是怎么移动的。协程体里 throw 一个异常 → 异常被包装成 JobCancellationException 之类的形式交给当前 Job → Job 调 cancel(cause) → 沿子节点向下广播取消信号、同时通知父 Job → 父 Job 按"自己有没有父"决定:没有父就交给 CoroutineExceptionHandler,有父就继续往上抛,直到根协程由 handler 兜底。

这里有个容易误解的点:CoroutineScope(Dispatchers.IO + CoroutineExceptionHandler()) 看起来像"给整个作用域装了 handler",但它只对直接以该 scope 为根的协程生效。如果在 scope 里 launch 出子协程,子协程的异常走的是父 Job 的传播链,而父 Job 是 scope 自己的 Job,最终仍会到 handler——但如果是 launch 内部自己 try/catch 住了,handler 根本看不到。所以 handler 是"最后一道网",不是"每条协程的错误日志"。

第二条线是取消语义。CancellationException 继承自 IllegalStateException,如果写成 catch (e: Exception),它会被一起捕获。后果是:协程体跑完了但 Job 状态没走到 Cancelled,父 Job 等它"完成"等不到,withTimeout 也可能超时异常。规范写法是先判后吞:

try {
   
    doWork()
} catch (e: CancellationException) {
   
    throw e            // 取消信号原样抛出,保持协作式取消语义
} catch (e: Exception) {
   
    report(e)          // 真正的业务异常才上报
}

工程落地:真实项目里怎么用

场景一:详情页用 viewModelScope.launch { repository.load() } 拉数据,网络异常直接抛出去,App 崩了。修法不是"到处 try/catch",而是分层:Repository 层把网络异常翻译成 Result<T> 密封类型(用 runCatching 兜住 IOException),ViewModel 层用 when 处理 Success/Failure 两种分支去驱动 UI。好处是错误处理变成类型的一部分,编译器帮你检查有没有漏。

场景二:uploadFlow() 里 flow { emit(...) }.collect { upload(it) },某次 upload 抛异常。默认行为是异常取消整个收集器,界面上"正在上传"的进度条一直停住,没有任何提示——因为 LaunchedEffect 的异常不会自动显示。这里需要在 catch 里把状态置为失败态并提示,同时保留 CancellationException 的抛出。

场景三:supervisorScope { launch { a() }; launch { b() } } 里 a() 抛异常且没兜底。子协程异常会交给根协程的 handler,如果没装 handler 就走默认 Thread.UncaughtExceptionHandler 崩溃。所以用 supervisorScope 的地方,块内每条协程都要有兜底——这一点上一节已经强调过,本节从异常视角再确认一次。

这些坑的正确绕法

在 catch 里把 CancellationException 一并吞掉,取消流程被打断出现幽灵协程。 表现是协程不再响应取消、Job 长期停在 Cancelling、viewModelScope 的任务越积越多。修法:catch 块第一行先 if (e is CancellationException) throw e。

其次是用 runCatching 一次性兜住所有 Throwable,把 CancellationException 一起包进 Result.failure,导致取消传播断链。修法:runCatching 的结果要在使用处再判一次类型,或在封装层做 onFailure { if (it is CancellationException) throw it }。

还有一个更隐蔽的坑:给子协程单独装 CoroutineExceptionHandler 却以为能收到日志,实际它对有父 Job 的协程无效,异常已被父链路接管。修法:handler 只装在根协程/scope 上,子协程用 try/catch 自行处理。

代码里见真章

看一段能直接跑的代码,把上面的机制落到具体写法上:

// 1) 分层错误模型:Repository 翻译异常,ViewModel 决定 UI
sealed interface DataResult<out T> {
   
    data class Ok<T>(val value: T) : DataResult<T>
    data class Err(val code: Int, val msg: String) : DataResult<Nothing>
}

class DetailRepo(private val api: Api) {
   
    // 非 CancellationException 才归一化为 Err;取消信号原样上抛
    suspend fun load(id: String): DataResult<Detail> = try {
   
        DataResult.Ok(api.detail(id))
    } catch (e: CancellationException) {
   
        throw e
    } catch (e: IOException) {
   
        DataResult.Err(code = e.message?.hashCode() ?: -1, msg = "网络不可用")
    }
}

// 2) 根协程装 handler(只这里有效)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO +
    CoroutineExceptionHandler {
    _, e -> log("uncaught", e) })

// 3) 子协程自己兜底:流式上传逐条处理,单条失败不影响整条流
fun CoroutineScope.upload(items: List<Item>) = launch {
   
    items.forEach {
    item ->
        try {
   
            uploadOne(item)
        } catch (e: CancellationException) {
   
            throw e
        } catch (e: Exception) {
   
            _state.value = Progress(item.id, failed = true, error = e.message)
        }
    }
}

关键行解读:DataResult.Err(val code: Int, ...) 里的 Nothing 让 Err 可以和任意 Ok<T> 统一成 DataResult<T>,这是 sealed 层次的关键;CoroutineExceptionHandler 装在 scope 上只兜根协程;forEach 里的 try/catch 保证单条失败不终止整批。

这题在面试里怎么问、怎么答

"launch 和 async 的异常处理差别?" 答:launch 异常会取消父并向上传播(未捕获时最终由根协程 handler 或崩溃处理);async 异常被 Deferred 持有,只有 await() 时才抛出,不 await 就等于吞掉。

"SupervisorJob 的异常怎么上报?" 答:子协程之间不传播,每个子协程要么自己 catch,要么由根协程的 CoroutineExceptionHandler 处理;不 catch 就会走到默认的未捕获异常处理(Android 上崩溃)。

"为什么 CancellationException 要特殊对待?" 答:它代表协作式取消的正常终止,不是错误。吞掉它会让 Job 状态机停在中间态,破坏父对子完成的等待,也让超时/取消语义失效。

"怎么让协程异常可观测?" 答:根协程装 CoroutineExceptionHandler + 第三方 crash SDK 捕获;业务层用 Result/sealed 显式建模;再配合结构化并发日志打点(自定义 CoroutineContext 元素或拦截 Thread.setDefaultUncaughtExceptionHandler)。

给正在准备面试的你

1. 定一条项目级错误规范:Repository 层返回 DataResult<T>(或 Kotlin Result),不允许把原始异常直接抛到 UI 层。这一条比任何 try/catch 数量都重要。 2. 在 code review 里把"catch 块首行是否处理 CancellationException"列为固定检查项。可以写脚本/规则把裸 catch (e: Exception) 标出来人工确认。 3. 用 kotlinx.coroutines 提供的 SupervisorJob + handler 做全局兜底时,明确它的作用范围只覆盖根协程,别指望它兜住所有子协程。 4. 自动化预防:为并发块写"单点失败"单测,断言其他分支仍完成;为上传/同步这类批处理写"取消后能否及时退出"的单测,这类 bug 在手工测试里几乎发现不了。

把这条链画成一条竖着的箭头,从上到下依次写:throw → 当前 Job 的 cancel(cause) → 向下广播取消(子协程退出) → 通知父 Job → 有父则继续上抛 / 无父则交给 CoroutineExceptionHandler。在"通知父 Job"旁边分一条支线画 async:被 Deferred 拦下,等 await() 才抛。最后在旁边贴一张红框写"取消语义:CancellationException 原样上抛"。

再补一个高频追问的完整答案:CoroutineExceptionHandler 为什么对子协程无效——因为异常在子协程里抛出后,先被其 Job 捕获并交给父 Job 链路,handler 只在异常抵达根协程、且无人处理时作为最后兜底被调用。给子协程装 handler 属于"错误的位置",正确位置是根 scope。

复习时别孤立刷题:Job 与 SupervisorJob——上一节讲取消如何在 Job 树里传播,本节讲失败与异常如何在这棵树上流动,两者合起来才是完整的异常模型。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:Job-与-SupervisorJob:异常传播的分水岭

下一篇预告:Flow-入门:冷流、热流与背压

有任何问题欢迎在评论区留言交流。

相关文章
|
18天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
8618 25
|
16天前
|
人工智能 并行计算 PyTorch
秋叶 ComfyUI 2026 整合包 v3.2 完整部署教程:Python 3.13 + Torch 2.13 全栈升级
秋叶aaaki ComfyUI 2026年8月整合包v3.2正式发布!全面升级Python 3.13.11、PyTorch 2.13.0+cu130及ComfyUI v0.30.2,原生支持MiniMax H3、Wan 2.2、Qwen-Image-2.1等2026主流音视频/图像模型,解压即用,无需环境配置。
3040 14
|
16天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
2110 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
5天前
|
人工智能 JSON Linux
【全网最详细】ComfyUI使用教程:下载+本地部署+配置+工作流搭建一篇搞定(2026最新版)
ComfyUI是一款免费开源的本地AI绘图工具,采用节点式工作流设计,支持文生图、图生图、局部重绘、放大、换脸等多种功能。可离线运行,依赖显卡加速,无需联网。支持自定义流程保存与分享,插件生态丰富,适合进阶用户。(239字)
|
16天前
|
云安全 人工智能 安全
|
11天前
|
人工智能 Linux 开发者
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
Codex是OpenAI推出的AI编程智能体,可读取本地项目、理解需求并自动修改代码。支持桌面GUI、命令行(CLI)及VS Code/Cursor插件三种形态,覆盖可视化操作、终端高效开发与编辑器无缝集成场景,助开发者用自然语言驱动编码全流程。(239字)
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
|
11天前
|
人工智能 JSON 编解码
【2026最新版】ComfyUI本地部署教程,新手也能看懂!
ComfyUI是本地运行的AI绘画工具,采用节点式工作流设计:通过拖拽连接“加载模型”“提示词编码”“采样”“解码”等模块,实现高度可控的文生图。新手推荐使用秋叶整合包,一键启动、内置模型管理与插件安装器,轻松上手。(239字)

热门文章

最新文章