Kotlin Jetpack 实战: 图解协程原理 | 开发者说·DTalk
本文原作者: 朱涛,原文发布于: 朱涛的自习室
https://mp.weixin.qq.com/s/fN4cSg6jcFZo3Wb2_xcJVw
协程 (Coroutines),是 Kotlin「最神奇」的特性,没有之一。
本文会以图解 + 动画的形式解释 Kotlin 协程的原理。看完本文后,您会发现,原来协程也没有那么难。
一边看文章,一边跑 Demo
本文的 Demo:
线程 & 协程
说了这么多协程的好,但就凭它的 "高效","轻量" 我们就要用吗?汇编语言也很高效啊。C 语言也能写出轻量的程序啊。
高效和轻量,都不是 Kotlin 协程的核心竞争力。
异步代码 & 回调地狱
getUserInfo(new CallBack() {
@Override
public void onSuccess(String response) {
if (response != null) {
System.out.println(response);
}
}
});
到目前为止,我们的代码看起来并没有什么问题,但如果我们的需求变成了这样呢?
查询用户信息 --> 查找该用户的好友列表 --> 拿到好友列表后,查找该好友的动态
getUserInfo(new CallBack() {
@Override
public void onSuccess(String user) {
if (user != null) {
System.out.println(user);
getFriendList(user, new CallBack() {
@Override
public void onSuccess(String friendList) {
if (friendList != null) {
System.out.println(friendList);
getFeedList(friendList, new CallBack() {
@Override
public void onSuccess(String feed) {
if (feed != null) {
System.out.println(feed);
}
}
});
}
}
});
}
}
});
地狱到天堂: 协程
val user = getUserInfo()
val friendList = getFriendList(user)
val feedList = getFeedList(friendList)
使用协程的要点
以上代码的关键,在于那三个请求函数的定义,它们都被 suspend 修饰,这代表它们都是: 挂起函数。
// delay(1000L)用于模拟网络请求
//挂起函数
// ↓
suspend fun getUserInfo(): String {
withContext(Dispatchers.IO) {
delay(1000L)
}
return "BoyCoder"
}
//挂起函数
// ↓
suspend fun getFriendList(user: String): String {
withContext(Dispatchers.IO) {
delay(1000L)
}
return "Tom, Jack"
}
//挂起函数
// ↓
suspend fun getFeedList(list: String): String {
withContext(Dispatchers.IO) {
delay(1000L)
}
return "{FeedList..}"
}
那么,挂起函数到底是什么?
挂起函数
挂起函数 (Suspending Function),从字面上理解,就是可以被挂起的函数。suspend 有: 挂起,暂停的意思。在这个语境下,也有点暂停的意思。暂停更容易被理解,但挂起更准确。
挂起函数,能被「挂起」,当然也能「恢复」,他们一般是成对出现的。
我们来看看挂起函数的执行流程,注意动画当中出现的闪烁,这代表正在请求网络。
「一定要多看几遍,确保没有遗漏其中的细节。」
从上面的动画,我们能知道:
表面上看起来是同步的代码,实际上也涉及到了线程切换。
一行代码,切换了两个线程。
= 左边: 主线程
= 右边: IO 线程
每一次从主线程到 IO 线程,都是一次协程挂起 (suspend)
每一次从 IO 线程到主线程,都是一次协程恢复 (resume)。
挂起和恢复,这是挂起函数特有的能力,普通函数是不具备的。
挂起,只是将程序执行流程转移到了其他线程,主线程并未被阻塞。
如果以上代码运行在 Android 系统,我们的 App 是仍然可以响应用户的操作的,主线程并不繁忙,这也很容易理解。
suspend 的本质
suspend fun getUserInfo(): String {
withContext(Dispatchers.IO) {
delay(1000L)
}
return "BoyCoder"
}
有的小伙伴要问了,哪来的 CallBack?明明没有啊。确实,我们写出来的代码没有 CallBack,但 Kotlin 的编译器检测到 suspend 关键字修饰的函数以后,会自动将挂起函数转换成带有 CallBack 的函数。
如果我们将上面的挂起函数反编译成 Java,结果会是这样:
// Continuation 等价于 CallBack
// ↓
public static final Object getUserInfo(Continuation $completion) {
...
return "BoyCoder";
}
public interface Continuation<in T> {
public val context: CoroutineContext
// 相当于 onSuccess 结果
// ↓ ↓
public fun resumeWith(result: Result<T>)
}
interface CallBack {
void onSuccess(String response);
}
函数类型的变化
挂起函数的返回值
挂起函数经过 CPS 转换后,它的返回值有一个重要作用: 标志该挂起函数有没有被挂起。
这听起来有点绕: 挂起函数,就是可以被挂起的函数,它还能不被挂起吗?是的,挂起函数也能不被挂起。
让我们来理清几个概念:
只要有 suspend 修饰的函数,它就是挂起函数,比如我们前面的例子:
suspend fun getUserInfo(): String {
withContext(Dispatchers.IO) {
delay(1000L)
}
return "BoyCoder"
}
当 getUserInfo() 执行到 withContext 的时候,就会返回 CoroutineSingletons.COROUTINE_SUSPENDED 表示函数被挂起了。
现在问题来了,请问下面这个函数是挂起函数吗:
// suspend 修饰
// ↓
suspend fun noSuspendFriendList(user: String): String{
// 函数体跟普通函数一样
return "Tom, Jack"
}
返回类型是 Any? 的原因
小结
suspend 修饰的函数就是挂起函数
挂起函数,在执行的时候并不一定都会挂起
挂起函数只能在其他挂起函数中被调用 (or 协程作用域)
挂起函数里包含其他挂起函数的时候,它才会真正被挂起
以上就是 CPS 转换过程中,函数签名的细节。
CPS 转换
Continuation 这个单词,如果您查词典和维基百科,可能会一头雾水,太抽象了。
词典
https://dictionary.cambridge.org/zhs/%E8%AF%8D%E5%85%B8/%E8%8B%B1%E8%AF%AD/continuation
维基百科
https://zh.wikipedia.org/wiki/%E8%AE%A1%E7%AE%97%E7%BB%AD%E4%BD%93
通过我们文章的例子来理解 Continuation,会更容易一些。
首先,我们只需要把握住 Continuation 的词源 Continue 即可。Continue 是继续的意思,Continuation 则是继续下去要做的事情,接下来要做的事情。
放到程序中,Continuation 则代表了,程序继续运行下去需要执行的代码,接下来要执行的代码或者剩下的代码。
以上面的代码为例,当程序运行 getUserInfo() 的时候,它的 Continuation 则是下图红框的代码:
字节码反编译
suspend fun testCoroutine() {
log("start")
val user = getUserInfo()
log(user)
val friendList = getFriendList(user)
log(friendList)
val feedList = getFeedList(friendList)
log(feedList)
}
// 没了 suspend,多了 completion
fun testCoroutine(completion: Continuation<Any?>): Any? {}
fun getUserInfo(completion: Continuation<Any?>): Any?{}
fun getFriendList(user: String, completion: Continuation<Any?>): Any?{}
fun getFeedList(friendList: String, completion: Continuation<Any?>): Any?{}
fun testCoroutine(completion: Continuation<Any?>): Any? {
class TestContinuation(completion: Continuation<Any?>?) : ContinuationImpl(completion) {
// 表示协程状态机当前的状态
var label: Int = 0
// 协程返回结果
var result: Any? = null
// 用于保存之前协程的计算结果
var mUser: Any? = null
var mFriendList: Any? = null
// invokeSuspend 是协程的关键
// 它最终会调用 testCoroutine(this) 开启协程状态机
// 状态机相关代码就是后面的 when 语句
// 协程的本质,可以说就是 CPS + 状态机
override fun invokeSuspend(_result: Result<Any?>): Any? {
result = _result
label = label or Int.Companion.MIN_VALUE
return testCoroutine(this)
}
}
}
// ↓
fun testCoroutine(completion: Continuation<Any?>): Any? {
...
val continuation = if (completion is TestContinuation) {
completion
} else {
// 作为参数
// ↓
TestContinuation(completion)
}
}
invokeSuspend 最终会调用 testCoroutine,然后走到这个判断语句 如果是初次运行,会创建一个 TestContinuation 对象,completion 作为了参数 这相当于用一个「新的」Continuation 包装了「旧的」Continuation 如果不是初次运行,直接将 completion 赋值给 continuation 这说明 continuation 在整个运行期间,只会产生一个实例,这能极大的节省内存开销 (对比 CallBack)
接下来是几个变量的定义,代码里会有详细的注释:
// 三个变量,对应原函数的三个变量
lateinit var user: String
lateinit var friendList: String
lateinit var feedList: String
// result 接收协程的运行结果
var result = continuation.result
// suspendReturn 接收挂起函数的返回值
var suspendReturn: Any? = null
// CoroutineSingletons 是个枚举类
// COROUTINE_SUSPENDED 代表当前函数被挂起了
val sFlag = CoroutineSingletons.COROUTINE_SUSPENDED
然后就到了我们的状态机的核心逻辑了,具体看注释吧:
when (continuation.label) {
0 -> {
// 检测异常
throwOnFailure(result)
log("start")
// 将 label 置为 1,准备进入下一次状态
continuation.label = 1
// 执行 getUserInfo
suspendReturn = getUserInfo(continuation)
// 判断是否挂起
if (suspendReturn == sFlag) {
return suspendReturn
} else {
result = suspendReturn
//go to next state
}
}
1 -> {
throwOnFailure(result)
// 获取 user 值
user = result as String
log(user)
// 将协程结果存到 continuation 里
continuation.mUser = user
// 准备进入下一个状态
continuation.label = 2
// 执行 getFriendList
suspendReturn = getFriendList(user, continuation)
// 判断是否挂起
if (suspendReturn == sFlag) {
return suspendReturn
} else {
result = suspendReturn
//go to next state
}
}
2 -> {
throwOnFailure(result)
user = continuation.mUser as String
// 获取 friendList 的值
friendList = result as String
log(friendList)
// 将协程结果存到 continuation 里
continuation.mUser = user
continuation.mFriendList = friendList
// 准备进入下一个状态
continuation.label = 3
// 执行 getFeedList
suspendReturn = getFeedList(friendList, continuation)
// 判断是否挂起
if (suspendReturn == sFlag) {
return suspendReturn
} else {
result = suspendReturn
//go to next state
}
}
3 -> {
throwOnFailure(result)
user = continuation.mUser as String
friendList = continuation.mFriendList as String
feedList = continuation.result as String
log(feedList)
loop = false
}
}
when 表达式实现了协程状态机
continuation.label 是状态流转的关键
continuation.label 改变一次,就代表协程切换了一次
每次协程切换后,都会检查是否发生异常
testCoroutine 里的原本的代码,被拆分到状态机里各个状态中,分开执行
getUserInfo(continuation),getFriendList(user, continuation),getFeedList(friendList, continuation) 三个函数调用传的同一个 continuation 实例
一个函数如果被挂起了,它的返回值会是: CoroutineSingletons.COROUTINE_SUSPENDED
切换协程之前,状态机会把之前的结果以成员变量的方式保存在 continuation 中。
「警告: 以上的代码是我用 Kotlin 写出的改良版反编译代码,协程反编译后真实的代码后面我也会放出来,请继续看。」
协程状态机动画演示
上面一大串文字和代码看着是不是有点晕?请看看这个动画演示,看完动画演示了,回过头再看上面的文字,您会有更多收获。
协程未挂起的情况
要验证也很简单,我们将其中一个挂起函数改成伪挂起函数即可。
// “伪”挂起函数
// 虽然它有 suspend 修饰,但执行的时候并不会真正挂起,因为它函数体里没有其他挂起函数
// ↓
suspend fun noSuspendFriendList(user: String): String{
return "Tom, Jack"
}
suspend fun testNoSuspend() {
log("start")
val user = getUserInfo()
log(user)
// 变化在这里
// ↓
val friendList = noSuspendFriendList(user)
log(friendList)
val feedList = getFeedList(friendList)
log(feedList)
}
testNoSuspend() 这样的一个函数体,它的反编译后的代码逻辑怎么样的?
答案其实很简单,「它的结构跟前面的 testCoroutine() 是一致的,只是函数名字变了而已,Kotlin 编译器 CPS 转换的逻辑只认 suspend 关键字」。就算是 "伪" 挂起函数,Kotlin 编译器也照样会进行 CPS 转换。
when (continuation.label) {
0 -> {
...
}
1 -> {
...
// 变化在这里
// ↓
suspendReturn = noSuspendFriendList(user, continuation)
// 判断是否挂起
if (suspendReturn == sFlag) {
return suspendReturn
} else {
result = suspendReturn
//go to next state
}
}
2 -> {
...
}
3 -> {
...
}
}
通过动画我们很清楚的看到了,对于 "伪" 挂起函数,suspendReturn == sFlag 是会走 else 分支的,在 else 分支里,协程状态机会直接进入下一个状态。
现在只剩最后一个问题了:
if (suspendReturn == sFlag) {
} else {
// 具体代码是如何实现的?
// ↓
//go to next state
}
答案其实也很简单: 如果您去看协程状态机的字节码反编译后的 Java,会看到很多 label。协程状态机底层字节码是通过 label 来实现这个 go to next state 的。由于 Kotlin 没有类似 goto 的语法,下面我用伪代码来表示 go to next state 的逻辑。
// 伪代码
// Kotlin 没有这样的语法
// ↓ ↓
label: whenStart
when (continuation.label) {
0 -> {
...
}
1 -> {
...
suspendReturn = noSuspendFriendList(user, continuation)
if (suspendReturn == sFlag) {
return suspendReturn
} else {
result = suspendReturn
// 让程序跳转到 label 标记的地方
// 从而再执行一次 when 表达式
goto: whenStart
}
}
2 -> {
...
}
3 -> {
...
}
}
@Nullable
public static final Object testCoroutine(@NotNull Continuation $completion) {
Object $continuation;
label37: {
if ($completion instanceof <TestSuspendKt$testCoroutine$1>) {
$continuation = (<TestSuspendKt$testCoroutine$1>)$completion;
if ((((<TestSuspendKt$testCoroutine$1>)$continuation).label & Integer.MIN_VALUE) != 0) {
((<TestSuspendKt$testCoroutine$1>)$continuation).label -= Integer.MIN_VALUE;
break label37;
}
}
$continuation = new ContinuationImpl($completion) {
// $FF: synthetic field
Object result;
int label;
Object L$0;
Object L$1;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
this.result = $result;
this.label |= Integer.MIN_VALUE;
return TestSuspendKt.testCoroutine(this);
}
};
}
Object var10000;
label31: {
String user;
String friendList;
Object var6;
label30: {
Object $result = ((<TestSuspendKt$testCoroutine$1>)$continuation).result;
var6 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(((<TestSuspendKt$testCoroutine$1>)$continuation).label) {
case 0:
ResultKt.throwOnFailure($result);
log("start");
((<TestSuspendKt$testCoroutine$1>)$continuation).label = 1;
var10000 = getUserInfo((Continuation)$continuation);
if (var10000 == var6) {
return var6;
}
break;
case 1:
ResultKt.throwOnFailure($result);
var10000 = $result;
break;
case 2:
user = (String)((<TestSuspendKt$testCoroutine$1>)$continuation).L$0;
ResultKt.throwOnFailure($result);
var10000 = $result;
break label30;
case 3:
friendList = (String)((<TestSuspendKt$testCoroutine$1>)$continuation).L$1;
user = (String)((<TestSuspendKt$testCoroutine$1>)$continuation).L$0;
ResultKt.throwOnFailure($result);
var10000 = $result;
break label31;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
user = (String)var10000;
log(user);
((<TestSuspendKt$testCoroutine$1>)$continuation).L$0 = user;
((<TestSuspendKt$testCoroutine$1>)$continuation).label = 2;
var10000 = getFriendList(user, (Continuation)$continuation);
if (var10000 == var6) {
return var6;
}
}
friendList = (String)var10000;
log(friendList);
((<TestSuspendKt$testCoroutine$1>)$continuation).L$0 = user;
((<TestSuspendKt$testCoroutine$1>)$continuation).L$1 = friendList;
((<TestSuspendKt$testCoroutine$1>)$continuation).label = 3;
var10000 = getFeedList(friendList, (Continuation)$continuation);
if (var10000 == var6) {
return var6;
}
}
String feedList = (String)var10000;
log(feedList);
return Unit.INSTANCE;
}
结尾
线程
线程是操作系统级别的概念
我们开发者通过编程语言 (Thread.java) 创建的线程,本质还是操作系统内核线程的映射
JVM 中的线程与内核线程的存在映射关系,有 "一对一","一对多","M 对 N"。JVM 在不同操作系统中的具体实现会有差别,"一对一" 是主流
一般情况下,我们说的线程,都是内核线程,线程之间的切换,调度,都由操作系统负责
线程也会消耗操作系统资源,但比进程轻量得多
线程,是抢占式的,它们之间能共享内存资源,进程不行
线程共享资源导致了多线程同步问题
有的编程语言会自己实现一套线程库,从而能在一个内核线程中实现多线程效果,早期 JVM 的 "绿色线程" 就是这么做的,这种线程被称为 "用户线程"
协程
Kotlin 协程,不是操作系统级别的概念,无需操作系统支持 Kotlin 协程,有点像上面提到的 "绿色线程",一个线程上可以运行成千上万个协程 Kotlin 协程,是用户态的 (userlevel),内核对协程「无感知」 Kotlin 协程,是协作式的,由开发者管理,不需要操作系统进行调度和切换,也没有抢占式的消耗,因此它更加「高效」 Kotlin 协程,它底层基于状态机实现,多协程之间共用一个实例,资源开销极小,因此它更加「轻量」 Kotlin 协程,本质还是运行于线程之上,它通过协程调度器,可以运行到不同的线程上
https://github.com/chaxiu/KotlinJetpackInAction
长按右侧二维码
查看更多开发者精彩分享
"开发者说·DTalk" 面向