—— 从 launch、async、SupervisorJob 到 CoroutineExceptionHandler,彻底讲透 Kotlin 协程异常传播机制

前面四篇

Kotlin 协程设计思想(一):CoroutineContext 到底是什么?为什么 Job 和 Dispatcher 可以直接相加?-CSDN博客
Kotlin 协程设计思想(二):Job 到底是什么?为什么协程能被取消?-CSDN博客
Kotlin 协程设计思想(三):Dispatchers 到底是什么?切线程真的只是切线程吗?-CSDN博客Kotlin 协程设计思想(四):launch、async、withContext 到底有什么区别?-CSDN博客
我们已经讲了:

CoroutineContext
↓
Job
↓
Dispatcher
↓
launch / async / withContext

本来以为协程已经学得差不多了。

结果真正做项目的时候,最容易把人搞懵的却是:

异常处理

例如:

try-catch

有时候能捕获:

launch {
}

里面的异常。

有时候:

又捕获不到

例如:

CoroutineExceptionHandler

有时候生效:

有时候又不生效

再比如:

async

明明已经抛异常了。

结果:

程序没崩

日志没打印

Handler没收到

直到:

await()

才突然爆出来。


于是很多人开始觉得:

协程异常处理太诡异了

其实并不是。

只是你还没有理解:

Kotlin协程的异常传播模型

一、先记住一句话

如果让我一句话概括协程异常:

异常永远沿着 Job 树向上传播。

这句话能解释:

90%
协程异常问题

二、先看 launch

例如:

viewModelScope.launch {

    throw RuntimeException("error")
}

会发生什么?


很多人以为:

异常在当前协程结束

其实不是。

结构:

ViewModelScope
│
└── launch

异常:

launch
↓
ViewModelScope

向上传播。


如果父协程没有处理:

整个作用域取消

三、为什么 launch 的异常会直接崩?

因为:

launch

返回:

Job

没有结果。

所以:

异常不能藏起来

只能:

立即上报父协程

这也是:

launch {

    throw RuntimeException()
}

容易直接看到异常的原因。


四、再看 async

例如:

val deferred = async {

    throw RuntimeException("error")
}

很多人第一次都会懵:

怎么没崩?

因为:

async

返回:

Deferred<T>

而:

Deferred
本来就是拿结果的

于是 Kotlin 设计者认为:

异常也是结果的一部分

所以:

async 不立即上报异常

而是:

先存起来

放进:

Deferred

里面。


五、await 才是真正的爆点

例如:

val deferred = async {

    throw RuntimeException("error")
}

delay(5000)

此时:

不会抛异常

直到:

deferred.await()

执行。


异常才真正出现:

try {

    deferred.await()

} catch (e: Exception) {

}

所以:

launch
立即传播异常

async
延迟传播异常

六、为什么这样设计?

因为:

async

的设计目标是:

并发计算

例如:

val user = async {

}

val order = async {

}

val banner = async {

}

最终:

await()

时统一拿结果。


如果:

其中一个异常
立即上报

那么:

并发模型就乱了

所以:

async
必须把异常缓存起来

等:

await()

统一处理。


七、CoroutineExceptionHandler 为什么总感觉不生效?

这是面试高频。


很多人:

val handler =
    CoroutineExceptionHandler { _, e ->

        Log.e("TAG", e.message ?: "")
    }

然后:

scope.launch(handler) {

}

没问题。


但是:

scope.async(handler) {

}

发现:

Handler没回调

原因:

async的异常
已经被Deferred接管

不会立即传播。


所以:

CoroutineExceptionHandler
处理不了async内部异常

必须:

await()

时处理。


这是无数人踩过的坑。


八、为什么 try-catch 有时候不生效?

例如:

try {

    launch {

        throw RuntimeException()
    }

} catch (e: Exception) {

}

很多人觉得:

应该捕获

实际上:

捕获不到

为什么?

因为:

launch {

}

已经开新协程了。


结构:

当前协程
│
└── launch

异常发生在:

launch子协程

里面。


而:

try-catch

只包住:

当前协程

所以:

根本捕获不到

九、什么时候 try-catch 能捕获?

例如:

launch {

    try {

        throw RuntimeException()

    } catch (e: Exception) {

    }
}

这里:

异常发生地
=
捕获地

当然能捕获。


或者:

val deferred = async {

    throw RuntimeException()

}

try {

    deferred.await()

} catch (e: Exception) {

}

也能捕获。


因为:

异常最终在await抛出

十、协程异常为什么会导致整个作用域取消?

回到上一篇:

Job树

例如:

Parent
│
├── Child1
│
├── Child2
│
└── Child3

Child1:

抛异常

普通 Job:

Child1异常
↓
Parent取消
↓
Child2取消
↓
Child3取消

于是:

全家陪葬

十一、SupervisorJob 为什么出现?

Google发现:

很多业务场景不合理。

例如:

首页加载

包含:

用户信息

Banner

推荐商品

Banner失败:

为什么用户信息也没了?

不合理。


于是:

SupervisorJob()

出现。


十二、SupervisorJob 的异常传播

结构:

Parent
│
├── Child1 崩
│
├── Child2 正常
│
└── Child3 正常

此时:

Child1取消

不会:

影响兄弟节点

所以:

SupervisorJob
=
异常隔离器

十三、coroutineScope 与 supervisorScope

又是经典面试题。


coroutineScope

结构:

一家人

一个孩子异常:

全家取消

例如:

coroutineScope {

    async {

    }

    async {

    }
}

supervisorScope

结构:

兄弟独立

一个孩子异常:

其它继续

例如:

supervisorScope {

    async {

    }

    async {

    }
}

这其实和:

Job

SupervisorJob

是一脉相承的。


十四、项目里的最佳实践

对于:

launch

推荐:

launch {

    try {

    } catch (e: Exception) {

    }
}

对于:

async

推荐:

runCatching {

    deferred.await()

}

或者:

try {

    deferred.await()

} catch (e: Exception) {

}

对于:

多个并发请求

推荐:

supervisorScope

避免:

一处失败
全局崩盘

十五、终于串起来了

现在回头看:

CoroutineContext
↓
Job
↓
Dispatcher
↓
launch
↓
async
↓
Exception

其实全是一条线。


异常传播:

不是魔法

而是:

Job树
上的传播规则。

十六、最终总结

如果让我一句话解释:

launch

的异常:

立即向父协程传播。

如果让我解释:

async

的异常:

先缓存到Deferred

await时再抛出。

如果让我解释:

CoroutineExceptionHandler

为什么经常不生效:

因为async的异常根本没传播出来。

如果让我解释:

SupervisorJob

存在的意义:

隔离异常传播。

真正理解协程异常,

本质上就是理解:

Job树

如何传播异常。


下篇预告

到这里:

CoroutineContext
✓

Job
✓

Dispatcher
✓

launch/async
✓

Exception
✓

基本串起来了。

那么最后一个问题来了:

为什么Google一直强调:

Structured Concurrency(结构化并发)?

为什么不推荐GlobalScope?

为什么CoroutineScope这么重要?

下一篇我们继续:

《Kotlin 协程设计思想(六):结构化并发到底是什么?为什么 Google 一直强调 Scope?》

从 GlobalScope、CoroutineScope、LifecycleScope 到 ViewModelScope,

彻底讲透 Kotlin 协程最核心的设计哲学。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐