End

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 another suspend 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

posted @ 2017-02-25 16:24  白乾涛  阅读(8537)  评论(0编辑  收藏  举报