Kotlin 协程基础知识总结三 —— 协程上下文与异常处理
本篇主要分为两部分:
- 协程上下文:
- 包括上下文中的元素
- 协程上下文的继承
- 协程的异常处理:
- 异常的传播特性
- 异常捕获
- 全局异常处理
- 取消与异常
- 异常聚合
1、协程上下文
协程的上下文与异常传播以及其他很多协程内容都有关联,因此在学习协程的异常处理之前,先学习协程上下文。
1.1 什么是协程上下文
(P38)CoroutineContext 是一组用于定义协程行为的元素,由如下几项构成:
- Job:控制协程的生命周期
- CoroutineDispatcher:向合适的线程分发任务
- CoroutineName:协程名称,调试时很有用
- CoroutineExceptionHandler:处理未被捕获的异常
1.2 组合协程上下文的元素
(P39)CoroutineContext 重载了 + 运算符,可以将上文提到的几种元素通过 + 号组合到一起:
/**
* Returns a context containing elements from this context and elements from other [context].
* The elements from this context with the same key as in the other one are dropped.
*/
public operator fun plus(context: CoroutineContext): CoroutineContext =
if (context === EmptyCoroutineContext) this else // fast path -- avoid lambda creation
context.fold(this) { acc, element ->
val removed = acc.minusKey(element.key)
if (removed === EmptyCoroutineContext) element else {
// make sure interceptor is always last in the context (and thus is fast to get when present)
val interceptor = removed[ContinuationInterceptor]
if (interceptor == null) CombinedContext(removed, element) else {
val left = removed.minusKey(ContinuationInterceptor)
if (left === EmptyCoroutineContext) CombinedContext(element, interceptor) else
CombinedContext(CombinedContext(left, element), interceptor)
}
}
}
比如我们给 launch 指定一个协程上下文对象,然后打印线程名称:
fun test01() = runBlocking<Unit> {
launch(Dispatchers.Default + CoroutineName("test")) {
println("thread:${Thread.currentThread().name}")
}
}
输出如下:
thread:DefaultDispatcher-worker-1 @test#2
使用 Default 调度器运行,因此线程是 DefaultDispatcher-worker-1;又因为指定协程名字为 test,所以输出的协程名字为 @test#2。
1.3 协程上下文的继承
(P40)协程上下文的继承规则是:新创建的协程,它的 CoroutineContext 会包含一个全新的 Job 对象用于控制协程的生命周期。剩下的上下文元素会从 CoroutineContext 的父类中继承,该父类可以是另一个协程,或者是创建该协程的 CoroutineScope。
示例代码:
fun test02() = runBlocking<Unit> {
val scope = CoroutineScope(Job() + Dispatchers.IO + CoroutineName("test"))
// 使用 scope 启动协程,launch 未指定协程上下文,则使用 scope 的上下文
val job = scope.launch {
println("${coroutineContext[Job]} ${Thread.currentThread().name}")
val result = async {
println("${coroutineContext[Job]} ${Thread.currentThread().name}")
"OK"
}.await()
}
job.join()
}
输出结果:
"test#2":StandaloneCoroutine{Active}@3eae895c DefaultDispatcher-worker-1 @test#2
"test#3":DeferredCoroutine{Active}@37e71b6f DefaultDispatcher-worker-3 @test#3
说明:
- 可以看到,先输出协程上下文的 Job,它们的哈希值不同,证明不是同一个对象,进一步说明新创建的协程的 Job 也是全新的
- 第二部分输出的线程名字都是 DefaultDispatcher-worker-x,协程名字都是 test#x,说明 launch 继承了 scope 的上下文,async 继承了父协程 launch 的上下文
coroutineContext[Job] 这种形式是因为 CoroutineContext 重写了 get 运算符:
/** * Returns the element with the given [key] from this context or `null`. */ public operator fun <E : Element> get(key: Key<E>): E?
Job 作为一个固定的 key,通过 coroutineContext[Job] 这种形式获得该上下文对象中的 Job 对象。
(P41)协程上下文继承公式,协程上下文 = 默认值 + 继承的 CoroutineContext + 参数:
- 一些元素具有默认值:如 Dispatchers.Default 的默认值是 DefaultScheduler,CoroutineName 默认值是 “coroutine”
- 继承的 CoroutineContext 是 CoroutineScope 或其父协程的 CoroutineContext
- 传入协程构建器的参数的优先级高于继承的上下文参数,会覆盖对应的参数值
示例代码:
@OptIn(ExperimentalStdlibApi::class)
@Test
fun test03() = runBlocking<Unit> {
// 工厂模式方法创建 CoroutineExceptionHandler,第一个参数用不到可以用 _ 表示,不会赋值,节省空间
val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception ->
println("Caught $exception")
}
// CoroutineScope 指定调度器为 Main
val scope = CoroutineScope(Job() + Dispatchers.Main + coroutineExceptionHandler)
println("scope:${scope.coroutineContext[CoroutineDispatcher]} ${Thread.currentThread().name}")
// 子协程指定调度器为 IO
val job = scope.launch(Dispatchers.IO) {
println("job:${coroutineContext[CoroutineDispatcher]} ${Thread.currentThread().name}")
}
job.join()
println("parent:${coroutineContext[CoroutineDispatcher]} ${Thread.currentThread().name}")
println("scope:${scope.coroutineContext[CoroutineDispatcher]} ${Thread.currentThread().name}")
}
大致结果如下(做了精简,不然 Dispatcher 和 CoroutineExceptionHandler 信息太长,影响查看):
scope:Dispatchers.Main[...] xxx$CoroutineExceptionHandler$1@29215f06 Test worker @coroutine#1
job:Dispatchers.IO xxx$CoroutineExceptionHandler$1@29215f06 DefaultDispatcher-worker-1 @coroutine#2
parent:BlockingEventLoop@2e8c1c9b null Test worker @coroutine#1
scope:Dispatchers.Main[...] xxx$CoroutineExceptionHandler$1@29215f06 Test worker @coroutine#1
能明显看出:
- 子协程并没有继承 scope 的调度器 Main,而是参数传入的 IO 覆盖了 Main,但是 CoroutineExceptionHandler 是继承了 scope 的
- 没有指定父协程 runBlocking 的调度器和异常处理,所以调度器不是 Main 也不是 IO,异常处理为 null
- 在 join 之后第二次输出 scope 的信息与第一次一样,结合上一点说明不论是 job 的父协程还是创建 job 使用的作用域 scope,它们的上下文内容都没有被子协程 job 覆盖,这一点与视频中讲解到的子协程 job 指定调度器为 IO 会覆盖父级上下文 CoroutineContext 的说法是有出入的。目前认为课程讲错了,因为子协程从父协程继承上下文,它的修改与扩展只针对子协程内部,不会覆盖或直接影响父协程的上下文
2、协程异常处理
(P42)异常处理的必要性:应用出现意外状况时,给用户合适的体验非常重要。一方面,目睹应用崩溃是一个很糟糕的体验;另一方面,用户操作失败时,必须要能给出正确的提示。
2.1 异常的传播
(P43)在异常传播方面,协程的构建器有两种类型:
- 自动传播异常:launch 与 actor,当协程内部发生未捕获的异常时,该异常会自动向上传播,直到最顶层的协程范围进行处理。这种异常传播是协程框架内置的机制,会确保异常能够在协程层级中传递并最终被处理
- 向用户暴露异常:async 与 produce,在协程中,您可以通过捕获异常并在需要时重新抛出来向用户传播异常。这种方式可以让您在协程内部控制异常的传播方式,以便更好地管理异常情况
简言之就是自动传播异常方式会向上级协程传播异常直到顶级协程,而向用户暴露异常的方式是捕获异常之后重新抛给用户。
根协程的异常传播
当构建器创建一个根协程(即不是其他协程的子协程)时,第一种构建器会在异常发生的第一时间被抛出,第二种则依赖使用者来最终消费异常(如通过 await 或 receive)。
我们先看对于根协程,两种类型的异常抛出情况:
@OptIn(DelicateCoroutinesApi::class)
@Test
fun test04() = runBlocking<Unit> {
val job = GlobalScope.launch {
throw IndexOutOfBoundsException()
}
job.join()
val deferred = GlobalScope.async {
throw ArithmeticException()
}
deferred.await()
}
异常信息如下:
Exception in thread "DefaultDispatcher-worker-1 @coroutine#2" java.lang.IndexOutOfBoundsException
at com.coroutine.basic.CoroutineTest02$test04$1$job$1.invokeSuspend(CoroutineTest02.kt:65)
...
java.lang.ArithmeticException
at com.coroutine.basic.CoroutineTest02$test04$1$deferred$1.invokeSuspend(CoroutineTest02.kt:70)
(Coroutine boundary)
at com.coroutine.basic.CoroutineTest02$test04$1.invokeSuspend(CoroutineTest02.kt:72)
...
IndexOutOfBoundsException 是在 65 行发生的,也就是 throw IndexOutOfBoundsException()
这句话;而 ArithmeticException 首先是在 72 行发生的,也就是 deferred.await()
这句话。所以我们在捕获异常时需要在这两个位置进行捕获:
@OptIn(DelicateCoroutinesApi::class)
@Test
fun test04() = runBlocking<Unit> {
val job = GlobalScope.launch {
try {
throw IndexOutOfBoundsException()
} catch (e: Exception) {
println("Caught IndexOutOfBoundsException")
}
}
job.join()
val deferred = GlobalScope.async {
throw ArithmeticException()
}
try {
deferred.await()
} catch (e: Exception) {
println("Caught ArithmeticException")
}
}
输出结果:
Caught IndexOutOfBoundsException
Caught ArithmeticException
我们还进行了其他测试,发现 async 内如果抛出异常,但是不通过 await 去获取结果的话,程序还能正常运行不会被异常中断:
@OptIn(DelicateCoroutinesApi::class)
@Test
fun test04() = runBlocking<Unit> {
val job = GlobalScope.launch {
try {
throw IndexOutOfBoundsException()
} catch (e: Exception) {
println("Caught IndexOutOfBoundsException")
}
}
job.join()
val deferred = GlobalScope.async {
throw ArithmeticException()
}
deferred.join()
}
输出结果:
Caught IndexOutOfBoundsException
有的人可能会问是不是因为 async 内的 ArithmeticException 异常没有抛出才使得程序仍能运行的?那我们加上 try-catch:
@OptIn(DelicateCoroutinesApi::class)
@Test
fun test04() = runBlocking<Unit> {
val job = GlobalScope.launch {
try {
throw IndexOutOfBoundsException()
} catch (e: Exception) {
println("Caught IndexOutOfBoundsException")
}
}
job.join()
val deferred = GlobalScope.async {
try {
throw ArithmeticException()
} catch (e: Exception) {
println("Caught ArithmeticException")
}
}
deferred.await()
deferred.join()
}
从结果中能看出 ArithmeticException 还是抛出了的:
Caught IndexOutOfBoundsException
Caught ArithmeticException
有没有注意到 test04 函数上有一个注解
@OptIn(DelicateCoroutinesApi::class)
,这是因为在构造顶级协程时使用了 GlobalScope。Kotlin 协程中的
GlobalScope
,它是一个顶层的协程作用域,通常情况下不建议在应用程序中随意地使用它。因为:
GlobalScope
的微妙性:
GlobalScope
提供了一个全局的协程作用域,它的生命周期与整个应用程序的生命周期相同。在某些情况下,使用GlobalScope
可能会导致一些微妙的问题,比如难以管理协程的生命周期、可能导致内存泄漏等。非常规使用可能出现问题:
- 如果不小心地在应用程序中非常规地使用
GlobalScope
,比如在不合适的地方启动协程或者忽略协程的取消操作,可能会导致一些难以调试和解决的问题。少数合法用途之一:
- 尽管不建议随意使用
GlobalScope
,但是在某些情况下,如在整个应用程序级别创建根协程时,可以将GlobalScope
视为一种合法的选项。必须使用
@OptIn(DelicateCoroutinesApi::class)
显式选择:
- 为了使用
GlobalScope
,您需要显式地选择使用@OptIn(DelicateCoroutinesApi::class)
注解。这样做可以提醒开发者,标明他们正在使用一个被认为是微妙的、可能会出现问题的 API。这种方式强调了谨慎使用GlobalScope
的重要性。综上所述,这段话强调了在 Kotlin 协程中使用
GlobalScope
时需要小心谨慎,最好避免在应用程序中随意使用,而应该在确保了解潜在问题的情况下,有意识地选择使用GlobalScope
。
非根协程的异常传播
(P44)非根协程中,产生的异常总是会被传播:
fun test05() = runBlocking<Unit> {
val job = launch {
async {
throw IllegalArgumentException()
}
}
job.join()
}
这里直接在 throw IllegalArgumentException()
这句话就抛出异常了。也就是说,async 抛出异常,launch 就会立即抛出异常,不论是否调用了 await。
异常传播特性
(P45)异常传播特性:当一个协程由于一个异常而运行失败时,它会将这个异常传递给它的父级,然后父级协程做如下操作:
- 取消它自己的子级
- 取消它自己
- 将异常传播给它的父级
2.2 SupervisorJob 与 supervisorScope
SupervisorJob
(P47)为了避免协程内部发生异常时上传给父协程导致所有兄弟协程都被取消,可以使用 SupervisorJob。
使用 SupervisorJob 函数时,子协程运行失败不会影响到其他子协程(父协程的其他子协程,也就是兄弟协程)。SupervisorJob 也不会传播异常给它的父级,它会让子协程自己处理异常。
这种需求常见于作用域内定义作业的 UI 组件,如果任何一个 UI 的子作业执行失败,它并不总是有必要取消整个 UI 组件。但是如果 UI 组件被销毁了,由于它的结果不再被需要,它就有必要使所有子作业执行失败。
结合具体的业务场景解释上面这段话,比如一个 View 中有三个动画,分别由三个协程控制。如果其中一个动画发生问题,正常情况下肯定不能影响另外两个,这就需要 SupervisorJob。此外,当 View 被移除时,这三个动画以及控制它们的协程也要停止。
示例代码:
fun test06() = runBlocking<Unit> {
val supervisor = CoroutineScope(SupervisorJob())
val job1 = supervisor.launch {
delay(100)
println("child1")
throw IllegalArgumentException()
}
val job2 = supervisor.launch {
try {
delay(1000)
} finally {
println("child2 finished.")
}
}
joinAll(job1, job2)
}
在 job1 抛出异常后,job2 仍然正常执行了:
child1
Exception in thread "DefaultDispatcher-worker-1 @coroutine#2" java.lang.IllegalArgumentException
at com.coroutine.basic.CoroutineTest02$test06$1$job1$1.invokeSuspend(CoroutineTest02.kt:104)
...
child2 finished.
说明 job1 表示的协程虽然因为异常失败,但是由于其使用了 SupervisorJob,因此异常没有向上级协程传递,进而也就没有影响到上级协程的所有子协程,也就是 job1 的兄弟协程 job2。
如需取消 supervisor 下的所有协程,可以 supervisor.cancel()
。
supervisorScope
(P47)supervisorScope 与 SupervisorJob 的效果类似,只不过,如果 supervisorScope 自身执行失败时,作用域内的所有作业将会被全部取消。
先看一个正常的例子:
fun test07() = runBlocking<Unit> {
supervisorScope {
launch {
delay(100)
println("child1")
throw IllegalArgumentException()
}
try {
delay(Long.MAX_VALUE)
} finally {
println("supervisorScope finished.")
}
}
}
输出结果:
child1
Exception in thread "Test worker @coroutine#2" java.lang.IllegalArgumentException
at com.coroutine.basic.CoroutineTest02$test07$1$1$1.invokeSuspend(CoroutineTest02.kt:124)
输出上述结果后,程序仍未停止运行,说明 delay(Long.MAX_VALUE)
仍在运行中,也就是 supervisorScope 内的子协程内部抛出异常没有上传给 supervisorScope,因此 supervisorScope 得以继续运行。
假如 supervisorScope 自身执行的任务抛出异常,就会取消该作用域内的所有任务:
fun test08() = runBlocking<Unit> {
supervisorScope {
launch {
try {
println("Child coroutine started.")
delay(Long.MAX_VALUE)
} finally {
println("Child coroutine is cancelled.")
}
}
// 暂停当前协程的执行,让其他协程(launch)有执行的机会
yield()
println("Throwing an exception from the scope.")
throw AssertionError()
}
}
输出结果如下:
Child coroutine started.
Throwing an exception from the scope.
Child coroutine is cancelled.
java.lang.AssertionError
at com.coroutine.basic.CoroutineTest02$test08$1$1.invokeSuspend(CoroutineTest02.kt:150)
(Coroutine boundary)
at com.coroutine.basic.CoroutineTest02$test08$1.invokeSuspend(CoroutineTest02.kt:138)
可以看到由于 supervisorScope 自身作业抛出异常导致取消了其作用域内的所有协程(launch)。
这里用到了 yield(),顺带说一下加深对 yield() 的理解。假如上面的示例代码不写 yield(),代码运行结果如下:
Throwing an exception from the scope.
java.lang.AssertionError
at com.coroutine.basic.CoroutineTest02$test08$1$1.invokeSuspend(CoroutineTest02.kt:149)
at com.coroutine.basic.CoroutineTest02$test08$1$1.invoke(CoroutineTest02.kt)
可以看到 launch 这个协程没有被执行。这是因为,父协程与子协程是相互独立的执行单元,它们之间的执行顺序是不确定的。上面的结果就是先执行父协程的任务,抛出异常了还没有执行子协程 launch 的情况。这时就需要用到 yield(),在父协程中调用它,会使得父协程出让协程的执行权,才能使得其他协程(子协程 launch)得以执行。
2.3 异常捕获
异常捕获的时机与位置
(P48)异常捕获的时机与位置
使用 CoroutineExceptionHandler 可以对协程的异常进行捕获,但需要满足以下条件:
- 时机:异常是被自动抛出异常的协程所抛出的(如使用 launch 时,async 则不行)
- 位置:在 CoroutineScope 的 CoroutineContext 中或在一个根协程中,根协程指 CoroutineScope 或 supervisorScope 的直接子协程
示例代码:
@OptIn(DelicateCoroutinesApi::class)
@Test
fun test09() = runBlocking<Unit> {
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
println("Caught $exception")
}
// 根协程中的 launch 抛出的异常可以捕获
val job = GlobalScope.launch(exceptionHandler) {
throw AssertionError()
}
// async 抛出的异常不能捕获
val deferred = GlobalScope.async(exceptionHandler) {
throw ArithmeticException()
}
job.join()
deferred.await()
}
输出结果:
Caught java.lang.AssertionError
java.lang.ArithmeticException
at com.coroutine.basic.CoroutineTest02$test09$1$deferred$1.invokeSuspend(CoroutineTest02.kt:169)
(Coroutine boundary)
at com.coroutine.basic.CoroutineTest02$test09$1.invokeSuspend(CoroutineTest02.kt:173)
异常捕获常见错误
(P49)异常捕获常见错误
看几个代码示例:
fun test10() = runBlocking<Unit> {
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
println("Caught $exception")
}
val scope = CoroutineScope(Job())
val job = scope.launch(exceptionHandler) {
launch {
throw IllegalArgumentException()
}
}
job.join()
}
输出结果:
Caught java.lang.IllegalArgumentException
结果证明该异常可以被捕获,因为:
- 时机:launch 构建的是自动抛出异常的协程(async 不是,因此这里如果是 async 就无法捕获)
- 位置:是在一个根协程(CoroutineScope 或 supervisorScope 的直接子协程)中,由于 scope.launch {} 的闭包是 CoroutineScope 环境,launch 是该 CoroutineScope 的直接子协程,因此抛出异常的 launch 是根协程,它抛出的异常会向父协程传递,因此 scope.launch 指定的 exceptionHandler 会捕获这个异常
但如果将 exceptionHandler 放到抛出异常的 launch 上,异常就不会被捕获:
fun test10() = runBlocking<Unit> {
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
println("Caught $exception")
}
val scope = CoroutineScope(Job())
val job = scope.launch {
// CoroutineExceptionHandler 需要放在父级协程捕获,放在抛出协程的启动器上是不捕获的
launch(exceptionHandler) {
throw IllegalArgumentException()
}
}
job.join()
}
这种情况下,子协程抛出的异常不能被它自己捕获,只能被父协程捕获。
这里他没有说明白,是不是因为抛异常的子协程还有父协程所以在子协程上捕获才不行。因为此前演示的 launch 都是可以直接捕获它自己抛出的异常的。
异常捕获阻止应用闪退
(P50)异常捕获阻止应用闪退,实际上就是将 CoroutineExceptionHandler 设置给可能发生异常的协程:
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
Log.d(TAG, "Caught $exception")
}
binding.btnStart.setOnClickListener {
// 指定 CoroutineExceptionHandler 捕获异常
GlobalScope.launch(exceptionHandler) {
Log.d(TAG, "onClick")
"abc".substring(10)
}
}
Android 全局异常处理
(P51)Android 全局异常处理可以获取到所有协程未处理、未捕获的异常,不过它并不能对异常进行捕获。虽然不能阻止程序崩溃,但全局异常处理器在程序调试和异常上报等场景中仍有很大用处。
在 classpath 下面创建 META-INF/services 目录,并创建 kotlinx.coroutines.CoroutineExceptionHandler 的文件,文件内容是全局异常处理器的全类名。
具体操作步骤:
-
创建一个全局异常处理器文件,需要继承 CoroutineExceptionHandler:
class GlobalCoroutineExceptionHandler : CoroutineExceptionHandler { override val key: CoroutineContext.Key<*> get() = CoroutineExceptionHandler override fun handleException(context: CoroutineContext, exception: Throwable) { Log.d("Frank", "Unhandled coroutine exception: $exception") } }
-
在模块的 main 目录下创建 resources 目录用来创建
META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler
,将上一步创建的 GlobalCoroutineExceptionHandler 的全类名写入该文件中:com.coroutine.basic.GlobalCoroutineExceptionHandler
这样在应用运行时如果有未捕获的异常发生,就会输出相应的日志:
2024-08-21 17:40:32.523 1968-2577 Frank com.coroutine.basic D Unhandled coroutine exception: java.lang.StringIndexOutOfBoundsException: length=3; index=10
2024-08-21 17:40:32.529 1968-2577 AndroidRuntime com.coroutine.basic E FATAL EXCEPTION: DefaultDispatcher-worker-1
Process: com.coroutine.basic, PID: 1968
java.lang.StringIndexOutOfBoundsException: length=3; index=10
2.4 取消与异常
(P52)取消与异常密切相关,协程内部使用 CancellationException 来进行取消,这个异常会被忽略。
子协程被取消时,虽然会抛出 CancellationException,但是这并不会取消它的父协程。但如果子协程发生 CancellationException 以外的异常,子协程会使用该异常取消它的父协程,在父协程将所有子协程结束后,再处理该异常。
fun test11() = runBlocking<Unit> {
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
println("Caught $exception.")
}
val job = GlobalScope.launch(exceptionHandler) {
launch {
try {
delay(Long.MAX_VALUE)
} finally {
// 协程被取消时,如果 finally 块中要执行挂起操作,需要使用 NonCancellable 环境
withContext(NonCancellable) {
println("Children are cancelled, but exception is not handled until all children terminate.")
delay(100)
println("The first child finished its non cancellable block.")
}
}
}
launch {
delay(10)
println("Second child throws an exception.")
throw ArithmeticException()
}
}
job.join()
}
输出结果:
Second child throws an exception.
Children are cancelled, but exception is not handled until all children terminate.
The first child finished its non cancellable block.
Caught java.lang.ArithmeticException.
第二个协程抛出异常导致第一个协程被取消,两个子协程都取消后,父协程才处理异常。
2.5 异常聚合
(P53)当协程的多个子协程因为异常而失败时,一般情况下取第一个异常进行处理,后续发生的所有异常都被绑定到第一个异常之上。
代码示例:
fun test12() = runBlocking<Unit> {
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
println("Caught $exception ${exception.suppressed.contentToString()}")
}
val job = GlobalScope.launch(exceptionHandler) {
launch {
try {
delay(Long.MAX_VALUE)
} finally {
throw ArithmeticException()
}
}
launch {
try {
delay(Long.MAX_VALUE)
} finally {
throw IllegalArgumentException()
}
}
// 第一个抛出的异常,其余两个协程为了保证在此协程之后抛异常,会一直等待,
// 直到此协程抛出异常导致它们被取消而执行 finally 时才抛出异常
launch {
delay(100)
throw IOException()
}
}
job.join()
}
输出结果:
Caught java.io.IOException [java.lang.IllegalArgumentException, java.lang.ArithmeticException]
默认情况下,发生多个异常会合并到第一个异常之上,因此通过 exception 只能拿到最先抛出的 IOException,如果想拿到后续抛出的异常对象,可以通过 exception.suppressed 数组。