Kotlin 协程使用手册——挂起函数
回调与协程对比
开头先抛出一个 Android 开发中常见的例子:应用通过用户 uid 展示用户信息,步骤包括拉取用户信息和在主线程中展现信息。使用回调的写法如下所示:
typealias Callback = (Long) -> Unit
fun main() = run { getUser(123) }
// 展示用户信息函数
fun getUser(uid: Long) {
fetchUser(uid) {
showUser(uid)
}
}
// 拉取用户信息函数
fun fetchUser(uid: Long, cb: Callback) = thread(name = "work") {
log("start fetch user $uid info")
Thread.sleep(300)
log("end fetch user $uid info")
thread(name = "ui") {
cb(uid)
}
}
// 用户信息UI展示函数
fun showUser(uid: Long) {
log("show user $uid in ui thread")
}
运行的结果如图所示:
接着我们使用协程的写法来改造上述的代码:
fun main() = runBlocking<Unit> { getUser(123, this) }
fun getUser(uid: Long, scope: CoroutineScope) =
scope.launch {
// 这里没有回调
fetchUser(uid)
// 使用上述的UI展示函数
showUser(uid)
}
suspend fun fetchUser(uid: Long) = suspendCoroutine<Unit> { cont ->
// 使用上面的回调函数改造成挂起函数
fetchUser(uid) {
cont.resumeWith(Result.success(Unit))
}
}
运行结果如图所示:
通过上述两种写法的运行结果可以看出它们的实现效果是一致的,但是在协程写法的 getUser 函数中 fetchUser 函数和 showUser 函数之间采用的是看起来同步的写法,这是如何做到的? 在这里先引出结论,协程并没有完全消除回调,而是采用另一种方式来替代回调。答案藏在编译后的代码中,可以试着将上述代码编译成字节码再反编译成 Java 代码,并在其中去寻找答案。
编译后的协程代码
使用 Intellij 的 Kotlin 反编译工具将上面的协程代码转换成对等的 Java 代码,如下所示:
public class TestDecompiled {
public static Job getUser(final long uid, CoroutineScope scope) {
return BuildersKt.launch(scope, EmptyCoroutineContext.INSTANCE, CoroutineStart.DEFAULT, new Function2<CoroutineScope, Continuation<? super Unit>, Object>() {
@Override
public Object invoke(CoroutineScope scope, Continuation<? super Unit> continuation) {
return ((AnonymousStateMachine) create(continuation)).invokeSuspend(Unit.INSTANCE);
}
public Continuation<?> create(Continuation<?> continuation) {
return new AnonymousStateMachine(uid, continuation);
}
});
}
// 生成的状态机,label 为0时对应原代码 getUser(Long, CoroutineScope) 函数中的 fetchUser(Long) 函数;
// label 为1时对应的是 showUser(Long) 函数
// 标注1
public static class AnonymousStateMachine extends SuspendLambda {
int label;
long uid;
public AnonymousStateMachine(long uid, Continuation<?> cont) {
super(1);
this.uid = uid;
}
@Nullable
@Override
protected Object invokeSuspend(@NotNull Object o) {
switch (label) {
case 0:
label = 1;
if (fetchUser(uid, this) == IntrinsicsKt.getCOROUTINE_SUSPENDED()) {
return IntrinsicsKt.getCOROUTINE_SUSPENDED();
}
break;
case 1:
break;
}
showUser(uid);
return Unit.INSTANCE;
}
}
public static Object fetchUser(long uid, Continuation<? super Unit> completion) {
SafeContinuation cont = new SafeContinuation(IntrinsicsKt.intercepted(completion));
fetchUser(uid, new FetchUserLambda(cont));
Object result = cont.getOrThrow();
if (result == IntrinsicsKt.getCOROUTINE_SUSPENDED()) {
DebugProbesKt.probeCoroutineSuspended(cont);
}
return result;
}
private static class FetchUserLambda implements Function1<Object, Unit> {
Continuation<?> cont;
public FetchUserLambda(Continuation<?> cont) {
this.cont = cont;
}
public final void invoke(long it) {
cont.resumeWith(Result.constructor-impl(Unit.INSTANCE));
}
@Override
public Unit invoke(Object var) {
invoke((long) var);
return Unit.INSTANCE;
}
}
public static Thread fetchUser(final long uid, Function1<? super Long, Unit> cb) {
return ThreadsKt.thread(true, false, null, "work", 0, new Function0<Unit>() {
@Override
public Unit invoke() {
LogKt.log("start fetch user " + uid + " info");
try {
Thread.sleep(300L);
} catch (InterruptedException e) {
e.printStackTrace();
}
LogKt.log("end fetch user " + uid + " info");
ThreadsKt.thread(true, false, null, "ui", 0, new Function0<Unit>() {
@Override
public Unit invoke() {
cb.invoke(uid);
return Unit.INSTANCE;
}
});
return Unit.INSTANCE;
}
});
}
public static void showUser(long uid) {
LogKt.log("show user " + uid + " in ui thread");
}
}
以上代码对反编译后的代码进行了一定的简化和修正,因为反编译后存在命名复杂、同一类中出现相同函数签名的方法以及错误的参数等问题,如果有好的查看反编译后的代码的方法,欢迎私信😁
转换后的代码中,最需要注意的是 AnonymousStateMachine 类,这个类是本文讨论的重点,其对应着原 kotlin 代码中的:
fun getUser(uid: Long, scope: CoroutineScope) =
scope.launch {
// label0
fetchUser(uid)
// label1
showUser(uid)
}
编译器将 launch 函数中的内容转换成了一个内部类 AnonymousStateMachine:
// 生成的状态机,label 为0时对应原代码 getUser(Long, CoroutineScope) 函数中的 fetchUser(Long) 函数;
// label 为1时对应的是 showUser(Long) 函数
// 标注1
public static class AnonymousStateMachine extends SuspendLambda {
int label;
long uid;
public AnonymousStateMachine(long uid, Continuation<?> cont) {
super(1);
this.uid = uid;
}
@Nullable
@Override
protected Object invokeSuspend(@NotNull Object o) {
switch (label) {
case 0:
label = 1;
if (fetchUser(uid, this) == IntrinsicsKt.getCOROUTINE_SUSPENDED()) {
return IntrinsicsKt.getCOROUTINE_SUSPENDED();
}
break;
case 1:
break;
}
showUser(uid);
return Unit.INSTANCE;
}
}
AnonymousStateMachine 类中 label 对应着当前的状态,label 的初始状态为0,当 AnonymousStateMachine.invoke(Object) 被调用的时候,会先执行 fetchUser(Long, Continuation<? super Unit>):
public static Object fetchUser(long uid, Continuation<? super Unit> completion) {
SafeContinuation cont = new SafeContinuation(IntrinsicsKt.intercepted(completion));
fetchUser(uid, new FetchUserLambda(cont));
Object result = cont.getOrThrow();
if (result == IntrinsicsKt.getCOROUTINE_SUSPENDED()) {
DebugProbesKt.probeCoroutineSuspended(cont);
}
return result;
}
可以看到 fetchUser(Long, Continuation<? super Unit>) 会返回 SafeContinuation.getOrThrow() 计算出的值并直接返回,需要留意的是,fetchUser(long, Function1<? super Long, Unit>) 函数是一个异步函数,所以此时 fetchUser(Long, Continuation<? super Unit>) 函数并不能返回实际的值,而是返回的 COROUTINE_SUSPENDED,AnonymousStateMachine 中的 label0 处会直接返回,可是剩下的 showUser(Long) 何时执行呢? 不用担心,AnonymousStateMachine 的继承关系如下:
BaseContinuationImpl (kotlin.coroutines.jvm.internal)
ContinuationImpl (kotlin.coroutines.jvm.internal)
SuspendLambda (kotlin.coroutines.jvm.internal)
AnonymousStateMachine in TestDecompiled ()
由其继承关系可以得出,AnonymousStateMachine 也间接实现了 Continuation 接口(BaseContinuationImpl 实现了),且当 fetchUser(long, Function1<? super Long, Unit>) 函数执行完毕时并切回 ui 线程时,会执行 Continuation.resumeWith(Object) 函数,最后间接调用了 AnonymousStateMachine.invokeSuspend(Object) 函数,此时对应的label 值为1,所以会接着执行 showUser(Long) 函数。这里点出了开头给出的结论。多亏了编译器的功劳,可以将回调转换成了一个类似于状态机的类。(状态机除了储存 label 外还会储存计算的一些临时变量,为了提供给下一个状态使用)
什么是挂起?
在学习和使用 kotlin 协程的过程中,挂起点和挂起函数等词汇的出现频率很高,kotlin 中的挂起函数需要被关键字 suspend 修饰。协程中经常使用的 delay 函数就是典型的挂起函数,当在协程中调用 delay(200) 函数时,当前协程会被挂起 200ms,200ms 后会恢复协程的运行,在当前协程被挂起的时候不会阻塞当前线程的执行。同时需要注意的是挂起函数不一定会挂起,正如上面提到的内容,getUser 的状态机中提到的,协程是否被挂起决定于 fetchUser 的返回值,根据判断返回的值是不是等于 COROUTINE_SUSPENDED 来决定是否挂起。 分析挂起函数的时候还会涉及到挂起点的概念,挂起点严格的定义是协程可能被挂起的位置。还是以 getUser 中的状态机为例子,fetchUser 为一个挂起函数,这个挂起函数就是一个挂起点,状态机中会对应两个状态,label 0 和 label 1,在 label 0 处挂起,完成相应计算工作后恢复协程调用,接着调用 lable 1对应的操作(此处无挂起),结束执行。
CPS 转换和 Continuation 接口
刚才在解释协程编译后的源码可能没有注意到一点,fetchUser 原本的函数是 fetchUser(Long) 但是编译后的函数变成了 fetchUser(Long, Contiuation),它的参数列表增加了一个 Continuation 函数。(当然编译后函数参数变化的原因有很多,比如很常见的拓展方法,会把被调用类的对象作为参数传入函数中) 这就是协程说明文档所指的 CPS 转换,CPS 是 Continuation-Passing-Style 的缩写,翻译过来叫作续体传递风格,挂起函数和挂起 Lambda 表达式后面附加一个 Continuation 表达式。
suspend fun fetchUser(uid: Long)
经过 CPS 转换后的函数变化为:
fun fetchUser(uid: Long, cont: Continuation): Any?
我们在上文中提到,状态机中根据判断 fetchUser 函数是否返回 COROUTINE_SUSPENDED 来决定是否挂起协程,因为 fetchUser 函数本身也可以写作有返回值的挂起函数,所以就会存在两种类型的返回值的情况,此时经过 CPS 变化后,函数的返回值编程 Any? 接着谈到为什么要进行 CPS 变化?前面说明了协程之所以可以把一个普通的回调转换成协程中同步的写法,需要借助实现了 Continuation 接口的状态机,所以这里挂起函数经过 CPS 转换的目的就是将该挂起函数的状态机传递给另一个挂起函数,有另一个挂起函数来控制状态机的状态变化,进而将原本的回调隐藏在对应状态的处理中。(这里挂起函数包括挂起 Lambda 表达式)
将普通回调转换成挂起函数
如何将普通的包含回调的函数转换成一个挂起函数,进而可以达到想要的同步式的写法。kotlin 协程为我们提供了一个协程内建的挂起函数 suspendCoroutine :
suspend fun <T> suspendCoroutine(block: (Continuation<T>) -> Unit): T
当改函数在协会中被调用时,他只能在协程或者其它挂起函数中被调用,这里需要注意的是 suspendCoroutine 是一个挂起函数,它的 Continuation 是由外部传入的,这里唯一的参数 block,是用来处理这个 Continuation 续体的。这里以上文例子中suspend fun fetchUser(uid: Long)
作为例子,内部依然是调用的回调的请求形式,我们把实现的细节隐藏在 fetchUser 的挂起函数中。当然除了 suspendCoroutine 函数之外还有 suspendCancellableCoroutine 函数,由它封装的挂起函数支持响应 CancellationException。这一点涉及到 kotlin 协程的另一个特性结构化并发并且支持 Cancel,这一点会在之后的文章中详谈。suspendCoroutine 和 suspendCancellableCoroutine 是 Kotlin 协程提供给我们获取当前协程 Continuation 的唯一方式,其它方式存在内存泄漏的风险。
总结 & 引用出处
官方对挂起函数的定义是被关键字 suspend 修饰的函数,支持挂起且不会阻塞线程,这些解释是出于描述挂起函数的功能的,但是会为学习者造成挂起函数究竟是干什么的疑惑。根据本文的探讨,我们引出了和挂起函数息息相关的 Continuation 这个接口,以及 CPS 转换规则和状态机等一系列的实现细节,通过上面的这些实现要素可以得出 suspend 修饰的挂起函数的神秘之处都隐藏在编译阶段,所以并不是没有回调,而是对同一状态机的递归调用,挂起函数的核心作用就是写法上异步 => 同步的转换。
引用出处: