Kotlin协程的理解

4,128 阅读22分钟

关于Kotlin协程的文章特别多,多数是按照官方教程翻译一遍,很多概念理解起来比较困惑,特别是协程的异常处理部分,看的是一头雾水。所以打算跟着官方文档及优秀的Kotlin协程文章,来系统学习一下。

首先来看Android官方对协程的定义:协程是一种并发设计模式,您可以在 Android 平台上使用它来简化异步执行的代码。协程是在版本 1.3 中添加到 Kotlin 的,它基于来自其他语言的既定概念。

特点

协程是我们在 Android 上进行异步编程的推荐解决方案。值得关注的特点包括:

  • 轻量:您可以在单个线程上运行多个协程,因为协程支持挂起,不会使正在运行协程的线程阻塞。挂起比阻塞节省内存,且支持多个并行操作。
  • 内存泄漏更少:使用结构化并发机制在一个作用域内执行多项操作。
  • 内置取消支持:取消操作会自动在运行中的整个协程层次结构内传播。
  • Jetpack 集成:许多 Jetpack 库都包含提供全面协程支持的扩展。某些库还提供自己的协程作用域,可供您用于结构化并发。

上面是关于协程的概念和特点,概念很简单,理解起来却有些生涩,那我们我们带着两个简单的问题开始学习

协程是什么

协程并不是Kotlin创造的概念, 在其他语言层面看到协程的实现,协程是一种编程思想,并不局限于任何语言。

协程最核心的作用就是用来简化异步执行的代码,说的直白一点,协程将原本复杂的异步线程做了简化处理,逻辑更清晰,代码更简洁

这里,就必须拿出线程和协程一起对比,站在Android开发者的角度,去理解它们直接的关系:

  • 我们的代码是在线程中运行的,而线程是在进程中运行
  • 协程不是线程,它也是在线程中运行的,不论是单线程还是多线程
  • 单线程中,使用协程并不能减少线程的执行时间

那么协程到底是怎么来简化异步代码的呢?下面从协程最经典的使用场景来切入---线程控制

callback

在Android中,如果要处理异步任务,最常见的就是使用callback

    public interface Callback<T> {
      
        void onSucceed(T result);

        void onFailed(int errCode, String errMsg);
    }

callback的特点很明显

  • 优势:使用简单
  • 缺点:如果业务多,很容易陷入回调地狱,嵌套逻辑复杂,维护成很高

RxJava

那么有什么方法能够解决呢?这时候很自然想到大名鼎鼎的RxJava

  • 优势:RxJava使用链式调用,实现线程切换,消除回调

  • 劣势:RxJava上手难度较大,而且各种操作符,很容易滥用,复杂度较高

而协程作为Kotlin自身的拓展库,使用更简单,更方便

下面使用协程来进行网络请求

launch {
      val result = get("https://developer.android.com")
      print(result)
       }                                      
}

suspend fun get(url: String) = withContext(Dispatchers.IO) {
            //network request
          }   

这里展示了代码片段, launch并不是顶层函数,我们先不关注,只关注{}内的具体逻辑

通常做网络请求,都是使用callback,回调结果后处理,而上面的两行代码,分别执行在两个线程里,但是看起来和单线程一样。

这里的get("https://developer.android.com")就是一个挂起函数,能保证请求结束后,才开始打印结果,这就是协程中最核心的非阻塞式挂起

协程怎么用

那么协程中的挂起,到底挂起了什么呢?我们先来看看协程怎么用,跟着用法来分析

协程基础知识

上面提到,launch不是顶层函数,那么真正创建协程的方式是什么呢?

// 方法一,使用 runBlocking 顶层函数
runBlocking {
    get(url)
}

// 方法二,自行通过 CoroutineContext 创建一个 CoroutineScope 对象,通过launch开启协程
val coroutineScope = CoroutineScope(context)
coroutineScope.launch {
    get(url)
}

// 方法三,使用 GlobalScope 单例对象,GlobalScope 实际是CoroutineScope的子类,本质是CoroutineScope
GlobalScope.launch {
    get(url)
}

//方法四,使用async开启协程
GlobalScope.async {
      get(url)
}
  • 方法一通常适用于单元测试的场景,而业务开发中不会用到这种方法,因为它是线程阻塞的。
  • 方法二是标准用法,我们可以通过 context 参数去管理和控制协程的生命周期(这里的 context 和 Android 里的不是一个东西,是一个更通用的概念,会有一个 Android 平台的封装来配合使用),CoroutineScope来创建协程的作用域
  • 方法三GlobalScopeCoroutineScope的子类,使用场景先不考究。
  • 方法四和方法三的区别,就在于launchasync的区别,这个稍后再分析

CoroutineScope

CoroutineScope是协程的作用域,所有协程都需要在作用域中启动

CoroutineContext

协程的持久上下文, 定义协程以下的行为:

下面就是一个标准的协程

val ctxHandler = CoroutineExceptionHandler {context , exception ->
}
val context = Job() + Dispatchers.IO + EmptyCoroutineContext + ctxHandler
CoroutineScope(context).launch {
  get(url)
}

suspend fun get(url: String) {

}

这个 launch 函数,它具体的含义是:我要创建一个新的协程,并在指定的线程上运行它。这个被创建、被运行的所谓「协程」是谁?就是你传给 launch 的那些代码,这一段连续代码叫做一个协程

我们也能换个思路理解,协程的概念由三方面组成: CoroutineScope + CoroutineContext+ 协程

协程是抽象的概念, 而协程launch 或者 async 函数闭包的代码块,是并发的具体实现,我们提到的协程就是它

使用协程

协程最常用的功能是并发,而并发的典型场景就是多线程。可以使用 Dispatchers.IO 参数把任务切到 IO 线程执行:

coroutineScope.launch(Dispatchers.IO) {
    ...
}

使用Dispatchers.Main 切换到主线程

coroutineScope.launch(Dispatchers.Main) {
    ...
}

什么时候使用协程呢?当你需要切线程或者指定线程的时候。你要在后台执行任务?切!

coroutineScope.launch(Dispatchers.IO) {
   val result = get(url)
}

然后需要在前台更新界面?再切!

coroutineScope.launch(Dispatchers.IO) {
    val result = get(url)
    launch(Dispatchers.Main) {
        showToast(result)
    }
}

乍一看,还是有嵌套啊

如果只是使用 launch 函数,协程并不能比线程做更多的事。不过协程中却有一个很实用的函数:withContext 。这个函数可以切换到指定的线程,并在闭包内的逻辑执行结束之后,自动把线程切回去继续执行。

coroutineScope.launch(Dispatchers.Main) {  //主线程启动    val result = withContext(Dispatchers.IO) { //切换到IO线程,执行完毕自动切回主线程    		get(url)  //在IO线程执行    }		showToast(result) //恢复到主线程}

这种写法看上去好像和刚才那种区别不大,但如果你需要频繁地进行线程切换,这种写法的优势就会体现出来。可以参考下面的对比:

// 第一种写法coroutineScope.launch(Dispatchers.IO) {    ...    launch(Dispatchers.Main){        ...        launch(Dispatchers.IO) {            ...            launch(Dispatchers.Main) {                ...            }        }    }}// 通过第二种写法来实现相同的逻辑coroutineScope.launch(Dispatchers.Main) {    ...    withContext(Dispatchers.IO) {        ...    }    ...    withContext(Dispatchers.IO) {        ...    }    ...}

根据withContext自动切回的特性,可以将withContext 抽取到一个单独的函数

coroutineScope.launch(Dispatchers.Main) {  //主线程启动    val result = get(url)  //在IO线程执行		showToast(result) //恢复到主线程}fun get(url: String) = withContext(Dispatchers.IO) {        // to do network request        url    }

这样代码逻辑就清晰多了

与基于回调的等效实现相比,withContext() 不会增加额外的开销。此外,在某些情况下,还可以优化 withContext() 调用,比使用回调表现更好。例如,如果某个函数对一个网络调用十次,您可以使用外部 withContext() 让 Kotlin 只切换一次线程。这样,即使网络库多次使用 withContext(),它也会留在同一调度程序上,并避免切换线程。

细心的你会发现,我们上面的示例,都缺少一个关键字suspend, 真正执行时,会报错:

fun get(url: String) = withContext(Dispatchers.IO) {    // IDE 报错 Suspend function'withContext' should be called only from a coroutine or another suspend funcion}

意思是说,withContext 是一个 suspend 函数,调用 suspend 函数,只能从其他 suspend 函数进行调用,或通过使用协程构建器(例如 launch)来启动新的协程

suspend

suspend 是 Kotlin 协程最核心的关键字,代码执行到 suspend 函数的时候会挂起,并且这个挂起是非阻塞式的,它不会阻塞你当前的线程。

所以上面代码: 加上suspend就能通过编译:

suspend fun get(url: String) = withContext(Dispatchers.IO) {    ...}

suspend 具体是什么?它又是如何实现非阻塞式挂起的呢?

协程的挂起

协程到底挂起的是什么呢?是如何将线程挂起吗?

实际上挂起的就是协程本身,具体一点呢?

前面讲过,协程其实就是 launch 或者 async 函数中闭包的代码块。

当协程执行到suspend函数时,协程会被suspend,也就是被挂起。

那协程从哪里挂起呢?当前的线程

挂起后做了什么呢?离开当前运行的线程,在指定的线程开始执行,执行完毕后再恢复协程。

协程并不是停下来了,是脱离当前线程,兵分两路,互不干扰,那么脱离后各自做了什么呢?

  • 线程

    当线程中代码执行到协程的suspend函数,暂时不执行协程剩余代码,跳出协程代码块,继续运行

    • 如果线程是后台线程:

      • 如果有其他后台任务,则执行
      • 如果没有其他任务,则无事可做,等待被回收
    • 如果是主线程:

      则继续执行工作,刷新界面

  • 协程

    线程的代码在到达 suspend 函数的时候被掐断,接下来协程会从这个 suspend 函数开始继续往下执行,不过是在指定的线程

    谁指定的?是 suspend 函数指定的,比如函数内部的 withContext 传入的 Dispatchers.IO 所指定的 IO 线程。

    Dispatchers 调度器,它可以将协程限制在一个特定的线程执行,或者将它分派到一个线程池,或者让它不受限制地运行,关于 Dispatchers后续再详细讲解

    suspend 函数执行完成之后,协程为我们做的最爽的事就来了:会自动帮我们把线程再切回来

    我们的协程原本是运行在主线程的,当代码遇到 suspend 函数的时候,发生线程切换,根据 Dispatchers 切换到了对应线程执行;

    当这个函数执行完毕后,线程又切了回来,也就是协程会帮我再 post 一个 Runnable,让我剩下的代码继续回到主线程去执行。

这里借用Google官方的图片,很形象的表达出挂起的效果

协程挂起的实质:就是切个线程

不过协程的挂起,比起我们使用Handler或者Rxjava的区别在于, 挂起函数执行完毕后,协程会自动切回原来的线程。

这个切回来的动作,也就是协程中的恢复resume, 必须在协程中,才能实现恢复功能

这也说明为什么挂起函数需要在协程或者另一个挂起函数中调用,最终都是为了让suspend 函数切换线程之后能够再切回来

协程怎么挂起

suspend 函数是怎么被挂起的呢? 是 suspend 指令做到的吗?下面写个 suspend 函数尝试一下:

suspend fun printThreadInfo() {    print(Thread.currentThread().name)}I/System.out:main

显示在主线程, 有点奇怪,明明定义了 suspend 函数,为什么协程没有挂起呢?

对比之前的例子:

suspend fun get(url: String) = withContext(Dispatchers.IO) {    ...}

发现区别在于withContext函数。查看 withContext 源码可以发现,它本身就是suspend函数,它接收一个 Dispatcher 参数,依赖这个 Dispatcher 参数的指示,你的协程被挂起,然后切到别的线程。

所以 suspend并不能挂起协程,真正挂起协程的,是协程框架,要想挂起协程,必须要直接或间接使用协程框架的 suspend函数

suspend的作用

suspend 关键字,不是真正实现挂起,那它的作用是什么?

它其实是一个提醒。

对函数的使用者的提醒:我是一个耗时函数,我被我的创建者用挂起的方式放在后台运行,所以请在协程里调用我。

为什么 suspend 关键字并没有实际去操作挂起,但 Kotlin 却把它提供出来?

因为它本来就不是用来操作挂起的。

挂起的操作 —— 也就是切线程,依赖的是挂起函数里面的实际代码,而不是这个关键字。

所以这个关键字,只是一个提醒

并且, 定义了suspend函数,但不包含挂起逻辑时,会提醒:redundant suspend modifier,告诉你这个 suspend 是多余的、

所以,创建一个 suspend 函数,为了让它包含真正挂起的逻辑,要在它内部直接或间接调用 Kotlin 自带的 suspend 函数,你的这个 suspend 才是有意义的。

自定义 suspend 函数的使用原则: 某个函数只要是耗时的,就可以写成suspend 函数

学习了协程的挂起,还有一个概念有疑惑,那就是协程的非阻塞式挂起,其中非阻塞式到底是什么

非阻塞式挂起

非阻塞式是相对阻塞式来说的

阻塞式很容易理解,一条马路堵车了,前面车辆不开动,后面车辆全部被阻塞,后面车想开过去,要么等前车离开,要么开辟一条路,从新路开走

这和代码中线程很相似:

道路被阻塞—耗时任务 等前车离开—耗时任务结束 开新的道路—切换到其他线程

从语义上理解非阻塞式挂起,讲的是非阻塞式是挂起的一个特点,协程的挂起是非阻塞式的,没有表达其他概念

阻塞的本质

首先,所有的代码本质上都是阻塞式的,而只有比较耗时的代码才会导致人类可感知的等待,比如在主线程上做一个耗时 50 ms 的操作会导致界面卡掉几帧,这种是我们人眼能观察出来的,而这就是我们通常意义所说的「阻塞」。

举个例子,当你开发的 app 在性能好的手机上很流畅,在性能差的老手机上会卡顿,就是在说同一行代码执行的时间不一样。

视频中讲了一个网络 IO 的例子,IO 阻塞更多是反映在「等」这件事情上,它的性能瓶颈是和网络的数据交换,你切多少个线程都没用,该花的时间一点都少不了。

而这跟协程半毛钱关系没有,切线程解决不了的事情,协程也解决不了。

所以,总结一下协程

  • 协程就是切线程
  • 挂起就是可以自动切回来的切线程
  • 非阻塞式是用看起来阻塞的代码实现非阻塞的操作

协程并没有创造新的东西,只是将多线程开发变的更简单,原理依然是切换线程并回调到原本的线程

协程的进阶用法

launchasync

前面讲到的 launchasync,现在来对比一下

用法很相似,都能启动一个协程

  • launch启动新协程但不返回结果。任何被视为“一劳永逸”的工作都可以使用 launch 来启动
  • async会启动一个新的协程,并使用一个名为 await 的挂起函数并在稍后返回结果

举例:例如我们要显示一个列表,数据源从两个接口获取,如果用launch启动协程,我们会启动两个请求,在任一请求结束时,检查另一个请求的结果,等两个请求结束和,开始合并数据源,进行显示

如果我们使用async

        val listOne = async { fetchList(1) }        val listTwo = async { fetchList(2) }        mergeList(listOne.await(), listTwo.await())// mergeList 为自定义合并函数        

通过对每个延迟引用调用 await(),我们可以保证这两项 async 完成之后,开始合并,而不需要考虑任何先后问题

还可以对集合使用 awaitAll()

        val deferreds = listOf(                 async { fetchList(1)},             async { fetchList(2)}           )        mergeList(deferreds.awaitAll())

常规情况,只需要使用launch启动协程,当使用async时,需要注意: async 期望您最终会调用 await 来获取结果(或异常),因此默认情况下它不会抛出异常。

Dispatchers

Kotlin 提供了三个可用于线程调度的 Dispatcher。

ab
Dispatchers.MainAndroid主线程,用于和用户交互
Dispatchers.IO适合 IO 密集型的任务,比如:读写文件,操作数据库以及网络请求
Dispatchers.Default针对 CPU 密集型工作进行了优化,比如计算/JSON解析等

异常传播及处理

JobSupervisorJob

通常情况,我们使用launch或者async创建协程,会默认创建使用Job来处理,一个任务失败,会影响他的子协程和父协程。如图所示

异常会到达层级的根部,而且当前 CoroutineScope 启动的所有协程都会被取消。

如果我们不想因为一个任务的失败而影响其他任务, 子协程运行失败不影响其他子协程和父协程,那么可以在创建协程时在 CoroutineScopeCoroutineContext 中使用 Job 的另一个扩展: SupervisorJob

当子协程任务出错或失败时,SupervisorJob 不会取消它和它自己的子级,也不会传播异常并传递给它的父级,它会让子协程自己处理异常

coroutineScopesupervisorScope

通过 launchasync, 能很轻松启动一个线程,请求网络并获取数据

但是有时候,你的需求比较复杂,需要在一个协程中执行多个网络请求,那就意味着你要启动更多协程。

在挂起函数中创建更多的协程,可以使用名为 coroutineScope 的构建器或 supervisorScope 来启动更多的协程。

 suspend fun fetchTwoDocs() {    coroutineScope {        launch { fetchList(1) }        async { fetchList(2) }    }}

注意:coroutineScopeCoroutineScope 是不同的东西,尽管它们的名字只有一个字符不同,CoroutineScope 是协程作用域,而coroutineScope 是在挂起函数中创建新协程的一个挂起函数,它接受CoroutineScope 作为参数,并在CoroutineScope 中创建协程

coroutineScopesupervisorScope 最主要的不同在哪呢?在于子协程出错时的处理

coroutineScope 是继承外部Job的上下文创建作用域,其内部的取消操作是双向传播的,子协程未捕获的异常也会向上传递给父协程。任何一个子协程异常退出,那么整体都将退出。

supervisorScope 同样继承外部作用域的上下文,但其内部的取消操作是单向传播的,父协程向子协程传播,反过来则不然,这意味着子协程出了异常并不会影响父协程以及其他兄弟协程。

所以,当处理多并发任务时,如果不想因为一个任务的失败而影响其他任务,就可以使用supervisorScope 创建协程,反之使用coroutineScope

注意: SupervisorJob 只有作为 supervisorScopeCoroutineScope(SupervisorJob()) 的一部分时,才会按照上面的描述工作。

协程异常处理

协程的异常,一般使用try/catch或者runCatching内置函数来处理(内部也是使用try/catch),在try中编写请求代码,catch负责捕获异常。

例如

        GlobalScope.launch {            val scope = CoroutineScope(Job())                scope.launch {                    try {                        throw Exception("Failed")                    } catch (e: Exception) {                      //捕获到异常                    }                }        }

正常来说,try-catch块中只有代码块存在异常,都将被捕获到catch中。但是协程中的异常却存在特殊情况。

例如在协程中开启一个失败的子协程,则无法捕获。还是上面的例子:

        GlobalScope.launch {            val scope = CoroutineScope(Job())            try { //try catch 在launch 作用域之外                scope.launch {                    throw Exception("Failed")                }            } catch (e: Exception) {                e.printStackTrace()              	//无法捕获异常,程序崩溃            }        }

try-catch块中创建了一个子协程,抛出一个异常,这个时候我们期望的是能将异常捕获至catch中,但是真正运行后却发现App崩溃退出了。这也验证了try-catch作用无效。

这就涉及到协程中异常传播问题

异常传播

在kotlin的协程中,每个协程是一个作用域,新建的协程与它的父作用域存在一个层次结构。而这级联关系主要在于:

协程中的任务,一旦因为异常而运行失败,它会立即将这个异常传递给它的父级,由父级来决定处理:

  • 取消它自己的子级;
  • 取消它自己;
  • 将异常传播并传递给它的父级

这也是为什么我们try-catch子协程为什么会失败,因为子协程中异常会向上传播,但父任务未处理异常,导致父任务失败。

如果将上面例子再次修改:

        GlobalScope.launch {            val scope = CoroutineScope(Job())            val job = scope.async { //将launch改为async                throw Exception("Failed")            }            try {                job.await()            } catch (e: Exception) {                e.printStackTrace()                //成功捕获异常            }        }

为什么async使用try-catch能捕获异常呢?当 async 被用作根协程时在调用 **.await() **时会抛出异常。这里的根协程指的是CoroutineScope(SupervisorJob()) 实例或 supervisorScope 的直接子协程

所以try-catch包裹.await()时可以捕获异常

如果 async 被不用作根协程,例如:

            val scope = CoroutineScope(Job())            scope.launch { //根协程                val job = async { //async 开启子协程                    throw Exception("Failed") //异常会立即抛出                }                try {                    job.await()                } catch (e: Exception) {                    e.printStackTrace()                    //无法捕获异常,程序崩溃                }            }       

这时候,try-catch无法捕获异常,程序崩溃,因为 launch 用作根协程,子协程的异常必定会传播给父协程,无论子协程是launch还是async,异常都不会抛出,所以无法捕获

如果async创建的子协程产生的异常不向上传递,是不是就可以避免异常影响父协程,导致应用崩溃呢?

        val scope = CoroutineScope(Job())        scope.launch {            supervisorScope { //在supervisorScope中创建子协程                    val job = async { //async 相当于                        throw Exception("Failed")                    }                try {                    job.await()                } catch (e: Exception) {                    e.printStackTrace()                    //成功捕获异常,程序无崩溃                }            }        }

或者

        val scope = CoroutineScope(Job())        scope.launch {            coroutineScope {                    val job = async(SupervisorJob()) { //async 开启子协程                        throw Exception("Failed")                    }                try {                    job.await()                } catch (e: Exception) {                    e.printStackTrace()                    //成功捕获异常,程序无崩溃                }            }        }

实际上,上面两个例子,分别使用 supervisorScopeCoroutineScope(SupervisorJob()) ,将异常不向上传递,由当前协程抛出,try-catch来捕获

那么如果未使用 supervisorScopeCoroutineScope(SupervisorJob()) ,异常未能捕获,一直向上传递到根层级的根部,导致父级失败,该如何处理?

CoroutineExceptionHandler

协程处理异常的第二个方法是使用CoroutineExceptionHandler

针对协程中,自动抛出的(launch创建的协程)未捕获的异常,我们可以使用CoroutineExceptionHandler来处理

CoroutineExceptionHandler是用于全局“捕获所有”行为的最后一种机制。您无法在CoroutineExceptionHandler中从异常中恢复。当处理程序被调用时,协程已经完成了相应的异常。通常,该处理程序用于记录异常、显示某种错误消息、终止和/或重新启动应用程序。

这段话读起来有点难以理解,换个思路理解 CoroutineExceptionHandler是全局捕获异常的方式,说明异常经子作用域一级级向上传递,到达最顶层的作用域,说明子作用域都全部取消了,CoroutineExceptionHandler 被调用时,所有子协程已经传递了相应异常,不会有新的异常传递了

所以CoroutineExceptionHandler必须设置在最顶层作用域才能捕获异常,不然捕获失败。

CoroutineExceptionHandler的使用

下面是如何声明一个 CoroutineExceptionHandler 的例子。

        val exHandler = CoroutineExceptionHandler{context, exception ->            println(exception)         }        val scope = CoroutineScope(Job())        scope.launch {            launch(exHandler) {                throw  Exception("Failed") //异常捕获失败            }        }

异常不会被捕获的原因是因为 exHandler 没有给父级。内部协程会在异常出现时传播异常并传递给它的父级,由于父级并不知道 handler 的存在,异常就没有被抛出。

改成下面例子,就能正常捕获异常

        val exHandler = CoroutineExceptionHandler{context, exception ->            println(exception)        }        val scope = CoroutineScope(Job())        scope.launch(exHandler) {//最上层协程捕获            launch {                throw  Exception("Failed")            }        }
CoroutineExceptionHandler的不足
  • 由于没有try-catch来捕获住异常,异常会向上传播,直到它到达根协程,根据协程的结构化并发的特性,异常向上传播时,父协程会失败,同时父协程所级联的子协程和兄弟协程也都会失败;

  • CoroutineExceptionHandler的作用在于全局捕获异常,CoroutineExceptionHandler无法在代码的特定部分处理异常,例如针对某一个失败接口,无法在异常后进行重试或者其他特定操作。

  • 如果你想在特定部分做异常处理的话,try-catch更适合。

总结

协程的异常捕获机制,主要就是两点: 局部异常捕获全局异常捕获

异常发生的作用域:

  • 作用域内,直接try-catch,则可以直接捕获异常,进行处理

  • 作用域外

    • launch启动的的作用域无法捕获异常,会立即双向传递,最终抛出

    • async启动的作用域:

      • 如果asyncCoroutineScope(SupervisorJob) 实例或 supervisorScope中启动协程,则异常不会向上传递,可以在async.await()时捕获异常
      • 如果async在非SupervisorJob实例或supervisorScope的直接子协程中启动,则异常双向传播,在async.await()时无法捕获异常

supervisorScope 中异常,不会向上传递,只会影响自己

coroutineScope中异常,会向双向传递,影响自己和父级

CoroutineExceptionHandler只能捕获launch中的异常,launch产生的异常会立即传递给父级,而且CoroutineExceptionHandler必须给最上层launch才会生效

参考文档

Kotlin coroutines on Android

Coroutines on Android (part I): Getting the background

Kotlin 的协程用力瞥一眼 - 学不会协程?很可能因为你看过的教程都是错的

Exceptions in coroutines

调度中出现了问题该如何处理-协程异常

Kotlin协程到底怎么处理异常?教你多种方案!