Kotlin协程异常处理

Kotlin的协程满足结构化语义:

  1. A parent-Coroutine finishes only after all its child-Coroutines have finished.

  2. When a parent-Coroutine or scope finishes abnormally, either through cancelation or through an exception, all its child-Coroutines, that are still active, are canceled and new child-Coroutines can no longer be launched.

  3. When a child-Coroutine finishes abnormally, its parent-Coroutine or scope finishes abnormally.

针对第三点,无论是CoroutineScope.asyncCoroutineScope.launch,以异常结束,只要是在一个scope中,他的父协程也会以异常结束。asynclaunch唯一不同的地方在于async需要调用await才会执行协程内容。即使我们对子协程进行了try-catch处理异常,父协程仍旧会拿着这个异常作为结果结束

async的异常处理

不管是哪个启动器(launch, async等),在应用了作用域之后,都会按照**作用域的语义**进行异常扩散,进而触发相应的取消操作,对于 async 来说就算不调用 await 来获取这个异常,它也会在 coroutineScope 当中触发父协程的取消逻辑,这一点请大家注意。

以CoroutineScope(Dispatchers.Main)作为根作用域为例,下面展示几个case

1
2
3
4
5
6
CoroutineScope(Dispatchers.Main).launch {
val foo = async {
throw IllegalStateException("test")
}
Log.d("Foo", "test")
}

上面的代码即使没有调用foo.await(), 也会扩散到Thread.UncaughtExceptionHandler中。这段代码的执行结果是

D/Foo: [, , 0]:test

同时app crash

这里我们应该这么理解,虽然是对await()进行了try-catch,但这里是对执行结果的try-catch, 这不影响协程自己的异常传递规则,在async中的协程scope抛出异常后,此时异常是未捕获状态;因此会向父协程scopecoroutineScope转播, coroutineScope继续向viewModelScope传播,最终来到UncaughtExceptionHandler处处理

那么考虑一下直接加入try-catch:

1
2
3
4
5
6
7
8
9
10
11
12
CoroutineScope(Dispatchers.Main).launch {
val foo = async {
throw IllegalStateException("test")
}
try {
foo.await()
} catch (e : IllegalStateException) {
Log.e("Foo", "CoroutineScope caught exception: $e")
}
Log.d("Foo", "test")
}

输出结果为:

E/Foo: [, , 0]:CoroutineScope caught exception: java.lang.IllegalStateException: test
D/Foo: [, , 0]:test

同时app crash

这里虽然我们对await进行了try-catch,打印了异常信息,但是根据作用域规则,这里我们的异常行为发生了扩散,从子协程扩散到根协程,最终扩散到Thread.UncaughtExceptionHandler中,对于安卓系统而言,就是引发了crash

那么如果我们在扩散的scope外层进行try-catch能否解决问题呢?尝试如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
CoroutineScope(Dispatchers.Main).launch {
try {
coroutineScope {
val foo = async {
throw IllegalStateException("test")
}
Log.d("Foo", "test")
}
} catch (e: IllegalStateException) {
Log.e("Foo", "catch expection from outerScope: $e")
}
}

输出结果为:

E/Foo: [, , 0]:CoroutineScope caught exception: java.lang.IllegalStateException: test
D/Foo: [, , 0]:test

此时app 不再crash

如果我们在添加await()同时进行try-catch:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CoroutineScope(Dispatchers.Main).launch {
try {
coroutineScope {
val foo = async {
throw IllegalStateException("test")
}
try {
foo.await()
} catch (e: IllegalStateException) {
Log.e("Foo", "catch expection from innerScope: $e")
}
Log.d("Foo", "test")
}
} catch (e: IllegalStateException) {
Log.e("Foo", "catch expection from outerScope: $e")
}
}

输出结果为:

E/Foo: [, , 0]:catch expection from innerScope: java.lang.IllegalStateException: test
D/Foo: [, , 0]:test
E/Foo: [, , 0]:catch expection from outerScope: java.lang.IllegalStateException: test

此时app 也不再crash,但我们会发现我们的两处try-catch均被触发

这也说明在协程中,异常的扩散并不遵循try-catch语法构成的作用域

另一种方式就是换用一个不进行扩散语义的协程作用域,即使用supervisorScope

1
2
3
4
5
6
7
8
9
10
CoroutineScope(Dispatchers.Main).launch {
supervisorScope {
val foo = async {
throw IllegalStateException("test")
}
Log.d("Foo", "test")
}

}

这段代码的输出结果为

D/Foo: [, , 0]:test

app 不发生crash, 同时没有IllegalStateException(“test”)的日志打印

这里的核心在于,我们的async是在supervisorScope作用域下,根据文档描述,这个作用域下的协程出现取消情况(异常抛出时)不会向外扩散

假如加上try-catch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CoroutineScope(Dispatchers.Main).launch {
supervisorScope {
val foo = async {
throw IllegalStateException("test")
}
try {
foo.await()
} catch (e: IllegalStateException) {
Log.e("Foo", "CoroutineScope caught exception: $e")
}
Log.d("Foo", "test")
}

}

我们得到结果

E/Foo: [, , 0]:CoroutineScope caught exception: java.lang.IllegalStateException: test
D/Foo: [, , 0]:test

app 不发生crash

同理,我们的async是在supervisorScope作用域下,根据文档描述,这个作用域下的协程出现取消情况(异常抛出时)不会向外扩散,因此不会向外扩散到Thread.UncaughtExceptionHandler。同时,try-catch对await()发生作用,我们打印出了异常的信息

withContext的异常处理

那么对比一下withContext的处理我们就会发现不同

1
2
3
4
5
6
7
8
9
viewModelScope.launch {
try {
withContext(Dispatchers.Default) {
throw IllegalArgumentException("Test")
}
} catch (e: Exception) {
}
}

这里withContext直接就是一个协程scope,我们的try-catch直接作用于整个withContext构造的scope,因此异常被捕获的同时,不再向viewModelScope传播

supervisorScope

如何能够让子协程抛出异常的情况下,父协程不会终止所以其子协程和自己呢?这就引入了supervisorScope

1
2
3
4
5
6
7
val result = supervisorScope {
...
val supervisedChild1 = this.launch { ... }
...
val supervisedChild2 = this.async { ... }
...
}

此时,若child1或child2任意一个抛出异常,也不会使另一个child和supervising parent停止

小结

最终我们得到了协程的Structured Concurrency含义:

  1. A parent-Coroutine finishes only after all its child-Coroutines have finished.

  2. When a parent-Coroutine or scope finishes abnormally, either through cancelation or through an exception, all its child-Coroutines, that are still active, are canceled and new child-Coroutines can no longer be launched.

  3. When a child-Coroutine finishes abnormally, its parent-Coroutine or scope (a) finishes abnormally if the parent is not a supervisor or (b) keeps running if the parent is a supervisor.

P.S 协程中的异常最佳实践

通过上文中对await,withContext的异常处理的方式,我们会发现在协程中处理异常其实是一件非常麻烦的事情,其异常的扩散规则并不合try-catch的作用域对应。因此这里简单提一下如何在实际项目中处理这种异常

Kotlin中对异常处理有两种推荐方式:

  1. default value
  2. Wrapper Seal class

第一种方式就是返回一个跟函数签名同类型的默认值,例如

1
2
3
4
5
6
7
8
fun toIntSafely(defaultValue: Int) : Int {
return try {
parseInt(this)
} catch (e: NumberFormatException) {
return defaultValue
}
}

第二种方式则是在API设计上使用一个Wrapper Class来包装出现的异常,使用这个Wrapper Class来保证Type-Check,举例来说:

1
2
3
4
5
6
7
8
9
10
11
12

sealed class ParsedDate {
data class Success(val date: Date) : ParsedDate()
data class Failure(val errorOffset: Int) : ParsedDate()
}

fun DateFormat.tryParse(text: String): ParsedDate =
try {
ParsedDate.Success(parse(text))
} catch (e: ParseException) {
ParsedDate.Failure(e.errorOffset)
}

那么对调用者而言,tryParse的结果一定是一个ParsedDate对象,而对异常无感知

结合协程使用来说,我们应该直接在一个作用域内,就返回一个default value或Wrapper Seal class,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

sealed class ParsedDate {
data class Success(val date: Date) : ParsedDate()
data class Failure(val errorOffset: Int) : ParsedDate()
}


CoroutineScope(Dispatchers.Main).launch {
val foo = async {
try {
throw IllegalStateException("test")
} catch (e: IllegalStateException) {
Log.e("Foo", "catch exception inside: $e")
ParsedDate.Failure(-1)
}
}
val rst = foo.await()
Log.d("Foo", "return rst: ${rst.errorOffset}")
}

输出结果:

E/Foo: [, , 0]:catch exception inside: java.lang.IllegalStateException: test
D/Foo: [, , 0]:return rst: -1

同时app无crash,无异常日志打印

ref:

https://www.bennyhuo.com/2019/04/23/coroutine-exceptions/#4-%E5%BC%82%E5%B8%B8%E4%BC%A0%E6%92%AD

https://johnnyshieh.me/posts/kotlin-coroutine-exception-handling/

https://medium.com/@elizarov/structured-concurrency-722d765aa952

https://medium.com/the-kotlin-chronicle/coroutine-exceptions-supervision-15056802998b

https://medium.com/the-kotlin-chronicle/coroutine-exceptions-3378f51a7d33

https://github.com/Kotlin/kotlinx.coroutines/issues/753