Kotlin 朱涛-15 协程 挂起函数 suspend CPS Continuation
目录
15 | 挂起函数:Kotlin协程的核心
初步了解挂起函数
Kotlin 协程最大的优势,就在于它的挂起函数。
- 虽然很多编程语言都有协程的特性,但到目前为止,只有 Kotlin 引入了
挂起函数
的概念 - 尽管有些语言的协程底层,也存在
挂起恢复
的概念,但是 Kotlin 是唯一将这一概念直接暴露给开发者,直接用于修饰一个函数的
Java 的回调地狱
getUserInfo(new CallBack() { // 发起一个异步任务
@Override
public void onSuccess(String response) { // 通过 CallBack 返回 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);
}
}
});
}
}
});
}
}
});
以上代码存在诸多缺陷:可读性差、扩展性差、维护性差,极易出错!
如果让你基于以上代码再扩展出 超时取消
出错重试
进度展示
等相关功能,你会不会觉得头疼?
使用挂起函数重构
使用 Kotlin 协程的挂起函数,重构上面的代码:
val user = getUserInfo()
val friendList = getFriendList(user)
val feedList = getFeedList(friendList)
这就是 Kotlin 协程的魅力:以同步的方式完成异步任务。
以上代码之所以能写成类似同步的方式,关键在于这三个函数的定义:
suspend fun getUserInfo(): String { // 定义为【挂起函数】
withContext(Dispatchers.IO) { // 控制协程执行的线程池,后面再讲
delay(1000L) // 模拟网络请求耗时
}
return "BoyCoder" // 模拟网络请求结果
}
suspend fun getFriendList(user: String): String {...} // 挂起函数
suspend fun getFeedList(list: String): String {...} // 挂起函数
挂起函数执行流程
所谓的挂起函数,其实就是比普通的函数多了一个suspend
关键字而已。挂起函数最神奇的地方,就在于它的挂起和恢复
功能。
- 在 IntelliJ 中,挂起函数会有一个特殊的
箭头标记
,调用挂起函数的位置叫做挂起点
- 表面上看起来是同步的代码,实际上也涉及到了
线程切换
- 比如
val user = getUserInfo()
,其中=
左边的代码运行在主线程,而=
右边的代码运行在 IO 线程 - 每一次从主线程到 IO 线程,都是一次协程
挂起
,每一次从 IO 线程到主线程,都是一次协程恢复
- 比如
- 挂起,只是将程序执行流程转移到了其他线程,主线程不会被阻塞
挂起函数整体的执行流程:
那么,Kotlin 协程到底是如何做到一行代码切换两个线程的呢?
深入理解挂起函数
suspend 会影响函数的类型
函数类型除了跟参数
、返回值
、接收者
相关,还跟 suspend
相关。
后面会遇到的
@Composable
也可以改变函数的类型
同一个函数,加上 suspend
修饰以后,它的函数类型会发生改变,并且不能互相赋值。
fun func1(num: Int): Double = num.toDouble()
suspend fun func2(num: Int): Double = num.toDouble()
val f1: (Int) -> Double = ::func1
val f2: suspend (Int) -> Double = ::func2
val f3: (Int) -> Double = ::func2 // 报错 Type mismatch
val f4: suspend (Int) -> Double = ::func1 // 报错 Type mismatch
suspend 的本质是 Callback
挂起函数的本质,就是 Callback。
将上面的挂起函数 suspend fun getUserInfo()
反编译成 Java 后是这样的:
public static final Object getUserInfo(Continuation $completion) { // Continuation 就是一个 CallBack
// ...
return "BoyCoder";
}
public interface Continuation<in T> { // Continuation 本质上就是一个带有泛型参数的 CallBack
public fun resumeWith(result: Result<T>) // 相当于 CallBack 的 onSuccess
}
虽然我们写出来的挂起函数并没有任何 Callback 的逻辑,但是,当 Kotlin 编译器
检测到 suspend
关键字修饰的函数以后,就会自动将挂起函数转换成带有 CallBack 的函数。
- 从挂起函数转换成 CallBack 函数的过程,叫做
CPS
转换(Continuation-Passing-Style Transformation) - 挂起函数在 CPS 转换过后,
函数的类型
会变成了(Continuation) -> Any?
- 所以在 Java 中访问 Kotlin 挂起函数时,会看到它的参数是
Continuation
,返回值是Object
挂起函数的核心原理
Continuation 到底是指什么?
- Continue 是
继续
的意思,Continuation 则是接下来要做的事情
- 放到程序中,Continuation 就代表了
程序接下来要执行的代码
,或者是剩下的代码
以上面的代码为例,当程序运行 getUserInfo() 这个挂起函数的时候,它的Continuation
则是下图红框中的代码:
这样理解了 Continuation 以后,CPS 也就容易理解了:
- CPS,就是将程序接下来要执行的代码进行传递的一种模式
- CPS 转换,就是将原本的同步挂起函数转换成 CallBack 异步代码的过程
可以看到,当程序执行到 getUserInfo() 的时候,剩下的未执行代码都被一起打包了起来,以 Continuation 的形式,传递给了 getUserInfo() 的 Callback 回调当中。
以上就是 Kotlin 挂起函数的核心原理,它的挂起和恢复,其实也是通过 CPS 转换来实现的。
总结:协程之所以是非阻塞,是因为它支持挂起和恢复;而挂起和恢复的能力,主要是源自于挂起函数;而挂起函数是由 CPS 实现的,其中的 Continuation,本质上就是 Callback。
协程与挂起函数的关系
挂起函数调用条件案例
先看个简单的例子:
fun main() {
getUserInfo() // IDE 会报错:挂起函数只能被协程或其他挂起函数调用
}
上面直接在 main 中调用挂起函数时,IDE 会报错:
Suspend function 'getUserInfo' should be called only from a
coroutine
or anothersuspend function
提示信息很清晰:挂起函数只能被协程
或其他挂起函数
调用
fun main() = runBlocking { // 可以在协程中调用挂起函数
val user = getUserInfo()
}
suspend fun anotherSuspendFunc() { // 可以在另一个挂起函数中调用挂起函数
val user = getUserInfo()
}
挂起函数调用条件分析
实际上,以上两种方式之间是有共性的,我们看看 runBlocking
的函数签名:
public actual fun <T> runBlocking(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T // 也是一个挂起函数
): T {... }
可以看到,因为第二个参数 block 是一个挂起函数
的类型,所以,在 block 中也可以调用挂起函数!
所以说,虽然协程和挂起函数
都可以调用挂起函数
,但是协程的 Lambda 也是挂起函数。所以,它们本质上都是因为挂起函数可以调用挂起函数。
挂起函数调用条件本质
那么,为什么挂起函数可以调用挂起函数,而普通函数不能调用挂起函数呢?
- 挂起函数本身并不支持挂起,所以它没法在普通函数中调用,而它之所以能在挂起函数中调用,是因为挂起函数最终都是在
协程
中被调用的,是协程提供了挂起函数运行的环境。 - 具体的运行环境,就是
Continuation
还有上下文环境CoroutineContext
。 - 被调用的挂起函数需要传入一个
Continuation
,没有被suspend
修饰的函数是没有Continuation
参数的,所以被调用的挂起函数没有办法从普通函数中获取一个Continuation
。
站在目前的阶段来看,我们可以认为:
- 挂起和恢复,是协程的一种底层能力
- 而挂起函数,是这种底层能力的一种表现形式
- 通过暴露出来的 suspend 关键字,我们开发者可以在上层,非常方便地使用这种底层能力
小结
- 挂起函数可以让我们能够
以同步的方式写异步代码
。相比回调地狱
式的代码,挂起函数写出来的代码可读性更好、扩展性更好、维护性更好,也更不易出错。 - 要定义挂起函数,只需在普通函数的基础上,增加一个
suspend
关键字。suspend 关键字是会改变函数类型
的,suspend (Int) -> Double
与(Int) -> Double
并不是同一个类型。 - 由于挂起函数拥有
挂起和恢复
的能力,因此对于同一行代码来说,=
左右两边的代码可以执行在不同的线程之上。而这一切,都是因为Kotlin 编译器
这个幕后的翻译官在起作用。 - 挂起函数的本质就是 Callback。只是 Kotlin 用了一个更加高大上的名字
Continuation
。 - Kotlin 编译器将 suspend 翻译成 Continuation 的过程,称为
CPS
转换。这里的 Continuation 是代表了,程序接下来要执行的代码
,或者是剩下的代码
。 - 挂起函数,只能在协程当中被调用,或者是被其他挂起函数调用。协程中的 block,本质上仍然是挂起函数。
- 挂起和恢复是协程的一种底层能力,而挂起函数则是一种上层的表现形式。
2017-02-25
本文来自博客园,作者:白乾涛,转载请注明原文链接:https://www.cnblogs.com/baiqiantao/p/6442129.html