前言
本文整理自一次内部分享,简要介绍了 Kotlin 协程的本质
适合 Kotlin 协程新手阅读
协程定义
介绍一个概念我们通常先介绍它的定义,不过协程并没有标准化的定义,简单来说:
协程就是一段可以 挂起(suspend)和恢复(resume)的程序
一般而言,就是一个支持 挂起 和 恢复 的函数
协程不是 Kotlin 独有的概念,其他语言的 标准库 也支持协程
-
Go(Goroutines)
-
Swift(5.5)
-
C++(C++ 20)
-
Lua(5.0)
-
Python(3.5)
-
...
异步(并发)逻辑怎么就这么难写?
首先明确一点:异步不一定发生线程切换
如:Hander(Looper.getMainLooper()).post {}
一旦线程发生切换,那么函数调用栈必然切换,自然产生了异步操作
而异步不一定需要切换线程
因此 切换线程是异步的充分不必要条件
异步(调用栈的切换)一定程度影响了我们对于逻辑执行的把控
并发编程带来的 可见性 ,原子性 ,有序性 问题让我们在编码时如履薄冰,稍有不慎便出现 bug
设计异步(并发)逻辑的注意事项
编写异步/并发逻辑我们需要注意:
- 结果传递
- 线程切换
- 异常处理
- 取消响应
- 复杂分支
Dream Code
😎 理想中的代码是这样的,不过在 Android 中我们通常不会采用这样的写法
❌ 如上图所示:networkRequest 在主线程调用会影响 屏幕刷新 与用户的 手势交互
回调实现异步
通常我们会使用 Callback 将上述代码改写成异步的形式
🍺 调用 networkRequest 时:网络线程请求网络数据,拿到结果后切换到主线程将数据回调出去
回调本身解决了很多问题,它实现了内部异步逻辑与外部调用逻辑的完美解耦。不过它也存在一些问题:
取消的处理比较困难,稍有不慎就落入坑中。
实践中我们一般配合 Lifecycle 实现自动取消
回调另一个问题就是「臭名昭著」的 回调地狱
有些带有回调且内部实现复杂的方法可能出现有些场景没被回调覆盖的情况
不要觉得诧异,现实中线上出现过由于回调问题导致的 bug
RxJava 实现异步
后来强大的 RxJava 出现了,开发者可以通过 observeOn 和 subscribeOn 来指定线程
配合 uber 开源的 AutoDispose,很好地实现了自动取消
👻 不知道各位注意到没有,上图中 observeOn 和 subscribeOn 写反了
不过 RxJava 也存在一些问题,它太强大了。
以至于存在一篇流传甚广的文章:我为什么不再推荐RxJava
纳尼(°ロ°)! 太强也是错?库是好库,奈何本人菜
所以 Android 的异步/并发逻辑咋就这么难写?
如果能异步代码能按同步的样式来写就好了
Kotlin 协程能解决什么问题?
有人说 Kotlin 在 JVM 的协程就是一个线程框架
这个说法就像在说 RxJava 就是个切线程框架
那么 Kotlin 协程能解决什么问题?先抛结论:
异步逻辑同步化,并发逻辑简单化
Kotlin 协程的本质
这看着和「同步-阻塞」的写法一样啊,有啥区别?
协程的写法保证在不阻塞线程的情况下保证了执行的顺序性
那么是如何做到的呢?
挂起(suspend)函数
suspend 关键字来表示挂起点,包含了 异步调用 和 回调 两层含义
它表示该函数支持 同步化的异步调用
第一张图中,networkRequest 方法用 suspend 标记
在执行 networkRequest 时,程序会被挂起(suspend),在网络请求结束在恢复(resume)
本质上就是一个 Callback,编译器的「黑魔法」使它看起来像同步代码
Continuation:你小子也是个回调啊
所谓编辑器的魔法:自动为 suspend 方法隐式添加一个 Continuation 类型的参数
换言之:所有 suspend 函数的方法签名里都默认存在一个 Continuation 的参数
Continuation 里面有一个 context 属性和 resumeWith 方法,其中 context 我们先按下不表
resumeWith 就是我们常见的 Callback 的写法
Kotlin 1.1 的代码更直观些:
成功回调:resume
失败回调:resumeWithException
Kotlin 1.3 统一为 resumeWith 方法,把结果使用 Result 包裹起来,之前的两个回调方法通过扩展函数提供
CPS(Continuation-Passing Style)
其实 Continuation 是一种处理异步操作通用的做法
有一个专业的术语来描述它:CPS(Continuation-Passing Style)
这里是 ChatGpt 给的答案
如何实现挂起和恢复
前面我们了解了协程的背后的 Continuation 就是个回调,那么它是如何挂起(suspend)和恢复(resume)的?
在发生异步调用时,被调用方通常存在结果未准备好和已准备好两种情情况
当结果未准备好时,代码会执行两次
第一次:结果未准备好,方法直接返回一个约定的状态标识挂起(suspend)
第二次:结果准备好后,将结果回调进行恢复(resume)
这就是挂起-恢复的本质
通过反编辑 Kotlin 的代码我们可以窥探其内部的的实现,下图中代码经过精简
- loadData 第一次调用时内部会创建自己的 Continuation(上图中的 InnerContinuation) 并将 loadData 参数中隐式的 Continuation 通过构造器传入
- InnerContinuation 使用 label 和 result 分别记录状态和结果
- label 初始状态为 0,执行第一个分支的逻辑,第一次调用结束后赋值为 1
- 由于 networkRequest 结果未准备好,因此会返回 COROUTINE_SUSPENDED 作为标识
- 当 networkRequest 结果准备好后会触发 loadData 的 InnerContinuation 的 resumeWith 回调方法,其内部调用 invokeSuspend 并将 networkRequest 的结果作为参数传入
- invokeSuspend 保存 networkRequest 的结果并触发 loadData 的第二次调用
- 此时 label 状态为 1,执行第二个分支的逻辑并继续向下执行调用 show 方法
CoroutineScope
我们如果在普通函数中调用 loadData 会报错
我们需要一个 Scope 来启动协程
启动协程的两种方式
CoroutineContext
前文我们介绍了协程恢复的本质:在结果数据准备好后将结果回调进行恢复
那么回调的环境从哪里获取呢?比如回调需要执行的线程
Continuation 中除了 Callback 还有一个属性:context,同时 CoroutineScope 也有该属性
协程中用 CoroutineContext 来表达协程运行所需要的上下文信息
常见的上下文实现
常用的 Context 有四种,分别用于处理切线程,生命周期,异常捕获,调试
Job
Job 用于描述协程并提供生命周期
Job 可以有子 Job
CoroutineDispatcher
官方提供了三种 Dispatcher 用于处理不同场景,也提供了将线程池转换成 Dispatcher 的扩展函数
CoroutineDispatcher 本质上是基于 ContinuationInterceptor 实现的,限于篇幅原因本文不再展开
CoroutineName
CoroutineName 方便我们调试,其默认值是 "coroutine"
👉 小技巧:
如果在启动时虚拟机选项配置了:-Dkotlinx.coroutines.debug
Thread.currentThread().name 尾部会加入 @CoroutineName#CoroutineId
上下文的数据结构
你可能会在代码里看到 CoroutineContext 使用 + 连接,或者像 set 一样通过 key 获取数据
我们来看下 CoroutineContext 定义
- 1 处是 CoroutineContext 的声明,是一个 interface,其内部两个 interface:Key 和 Element(见 2 处)
- context 使用 + 连接或者通过 context.[key] 取值的写法源自 CoroutineContext 内部的 plus 和 get 方法
- 多个 Context 的组合使用 CombinedContext(见 3)表达
CombinedContext 的结构有点像链表,其 left 关联着其他 CoroutineContext
上下文传递
协程的上下文 = 默认值 + parent 遗传 + 自定义
小结
还记得前文我们提到的设计异步并发逻辑的注意事项吗?让我们看看 Kotlin 协程是如何解决的
-
结果传递: CPS 变换,本质和 Callback 传递结果一样
-
线程切换: 引入了协程上下文,拦截器 + 调度器
-
异常处理 + 取消响应:协程作用域,结构化并发,异常向上传播,协作式取消
Kotlin 的学习曲线十分陡峭,笔者认为不亚于 RxJava
团队开发中很容易出现由于理解不到位导致不合理代码的出现
还是那句话:库是好库,就看怎么用
团队接入
官方提供了这样一个库,可以将 Rx 风格与协程互转
以下是我的个人观点:
-
一个团队里最好有 2-3 个「专家」,理解底层原理,在遇到疑难问题时可以协助解决
-
建立代码规范,完善业务封装
-
使用官方提供的转换工具按需对已有 RxJava 形式的 API 进行改造
-
不要寄希望 Kotlin 协程能解决一切问题,避免由于认知不足导致使用错误
资源推荐
入门
进阶
- Benny Huo:破解 Kotlin 协程系列
- 微信读书-《深入理解 Kotlin 协程》-作者 Benny Huo
- B 站-Kotlin 协程太难学不会?《深入理解 Kotlin 协程》作者亲自对本书做解读!
取消与异常
- Cancellation and Exceptions in Coroutines (Part 1)
- Cancellation and Exceptions in Coroutines (Part 2)
- Cancellation and Exceptions in Coroutines (Part 3)
- Cancellation and Exceptions in Coroutines (Part 4)