关于Kotlin协程的文章特别多,多数是按照官方教程翻译一遍,很多概念理解起来比较困惑,特别是协程的异常处理部分,看的是一头雾水。所以打算跟着官方文档及优秀的Kotlin协程文章,来系统学习一下。
首先来看Android官方对协程的定义:协程是一种并发设计模式,您可以在 Android 平台上使用它来简化异步执行的代码。协程是在版本 1.3 中添加到 Kotlin 的,它基于来自其他语言的既定概念。
特点
协程是我们在 Android 上进行异步编程的推荐解决方案。值得关注的特点包括:
- 轻量:您可以在单个线程上运行多个协程,因为协程支持挂起,不会使正在运行协程的线程阻塞。挂起比阻塞节省内存,且支持多个并行操作。
- 内存泄漏更少:使用结构化并发机制在一个作用域内执行多项操作。
- 内置取消支持:取消操作会自动在运行中的整个协程层次结构内传播。
- Jetpack 集成:许多 Jetpack 库都包含提供全面协程支持的扩展。某些库还提供自己的协程作用域,可供您用于结构化并发。
上面是关于协程的概念和特点,概念很简单,理解起来却有些生涩,那我们我们带着两个简单的问题开始学习
协程是什么
协程并不是Kotlin创造的概念, 在其他语言层面看到协程的实现,协程是一种编程思想,并不局限于任何语言。
协程最核心的作用就是用来简化异步执行的代码,说的直白一点,协程将原本复杂的异步线程做了简化处理,逻辑更清晰,代码更简洁
这里,就必须拿出线程和协程一起对比,站在Android开发者的角度,去理解它们直接的关系:
- 我们的代码是在线程中运行的,而线程是在进程中运行
- 协程不是线程,它也是在线程中运行的,不论是单线程还是多线程
- 单线程中,使用协程并不能减少线程的执行时间
那么协程到底是怎么来简化异步代码的呢?下面从协程最经典的使用场景来切入---线程控制
callback
在Android中,如果要处理异步任务,最常见的就是使用callback
public interface Callback {
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
来创建协程的作用域 - 方法三
GlobalScope
是CoroutineScope
的子类,使用场景先不考究。 - 方法四和方法三的区别,就在于
launch
和async
的区别,这个稍后再分析
CoroutineScope
CoroutineScope
是协程的作用域,所有协程都需要在作用域中启动
CoroutineContext
协程的持久上下文, 定义协程以下的行为:
Job
:控制协程的生命周期。CoroutineDispatcher
:将工作分派到适当的线程。CoroutineName
:协程的名称,可用于调试。CoroutineExceptionHandler
:处理未捕获的异常。
下面就是一个标准的协程
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
,让我剩下的代码继续回到主线程去执行。
协程挂起的实质:就是切个线程
不过协程的挂起,比起我们使用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 阻塞更多是反映在「等」这件事情上,它的性能瓶颈是和网络的数据交换,你切多少个线程都没用,该花的时间一点都少不了。
而这跟协程半毛钱关系没有,切线程解决不了的事情,协程也解决不了。
所以,总结一下协程
- 协程就是切线程
- 挂起就是可以自动切回来的切线程
- 非阻塞式是用看起来阻塞的代码实现非阻塞的操作
协程并没有创造新的东西,只是将多线程开发变的更简单,原理依然是切换线程并回调到原本的线程
协程的进阶用法
launch
与 async
前面讲到的 launch
与 async
,现在来对比一下
用法很相似,都能启动一个协程
-
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。
a | b |
---|---|
Dispatchers.Main | Android主线程,用于和用户交互 |
Dispatchers.IO | 适合 IO 密集型的任务,比如:读写文件,操作数据库以及网络请求 |
Dispatchers.Default | 针对 CPU 密集型工作进行了优化,比如计算/JSON解析等 |
异常传播及处理
Job
和SupervisorJob
通常情况,我们使用launch
或者async
创建协程,会默认创建使用Job
来处理,一个任务失败,会影响他的子协程和父协程。
异常会到达层级的根部,而且当前 CoroutineScope 启动的所有协程都会被取消。
如果我们不想因为一个任务的失败而影响其他任务, 子协程运行失败不影响其他子协程和父协程,那么可以在创建协程时在 CoroutineScope
的 CoroutineContext
中使用 Job
的另一个扩展: SupervisorJob
当子协程任务出错或失败时,SupervisorJob
不会取消它和它自己的子级,也不会传播异常并传递给它的父级,它会让子协程自己处理异常
coroutineScope
和supervisorScope
通过 launch
与 async
, 能很轻松启动一个线程,请求网络并获取数据
但是有时候,你的需求比较复杂,需要在一个协程中执行多个网络请求,那就意味着你要启动更多协程。
在挂起函数中创建更多的协程,可以使用名为 coroutineScope
的构建器或 supervisorScope
来启动更多的协程。
suspend fun fetchTwoDocs() { coroutineScope { launch { fetchList(1) } async { fetchList(2) } }}
注意:coroutineScope
和 CoroutineScope
是不同的东西,尽管它们的名字只有一个字符不同,CoroutineScope
是协程作用域,而coroutineScope
是在挂起函数中创建新协程的一个挂起函数,它接受CoroutineScope
作为参数,并在CoroutineScope
中创建协程
coroutineScope
和supervisorScope
最主要的不同在哪呢?在于子协程出错时的处理
当coroutineScope
是继承外部Job
的上下文创建作用域,其内部的取消操作是双向传播的,子协程未捕获的异常也会向上传递给父协程。任何一个子协程异常退出,那么整体都将退出。
supervisorScope
同样继承外部作用域的上下文,但其内部的取消操作是单向传播的,父协程向子协程传播,反过来则不然,这意味着子协程出了异常并不会影响父协程以及其他兄弟协程。
所以,当处理多并发任务时,如果不想因为一个任务的失败而影响其他任务,就可以使用supervisorScope
创建协程,反之使用coroutineScope
注意: SupervisorJob
只有作为supervisorScope
或 CoroutineScope(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() //成功捕获异常,程序无崩溃 } } }
实际上,上面两个例子,分别使用supervisorScope
和CoroutineScope(SupervisorJob())
,将异常不向上传递,由当前协程抛出,try-catch
来捕获
那么如果未使用supervisorScope
或CoroutineScope(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
启动的作用域:- 如果
async
在CoroutineScope(SupervisorJob)
实例或supervisorScope
中启动协程,则异常不会向上传递,可以在async.await()
时捕获异常 - 如果
async
在非SupervisorJob
实例或supervisorScope
的直接子协程中启动,则异常双向传播,在async.await()
时无法捕获异常
- 如果
supervisorScope
中异常,不会向上传递,只会影响自己
coroutineScope
中异常,会向双向传递,影响自己和父级
CoroutineExceptionHandler
只能捕获launch
中的异常,launch
产生的异常会立即传递给父级,而且CoroutineExceptionHandler
必须给最上层launch
才会生效