协程粉碎计划 | 挂起函数

·  阅读 543
协程粉碎计划 | 挂起函数

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第29天,点击查看活动详情

本系列专栏 # Kotlin协程专栏

前言

本篇文章就来介绍协程框架中最重要的特性:挂起函数,挂起函数最大的特点就是"挂起和恢复",而且这里的"挂起"是非阻塞式的挂起,不会阻塞线程;"恢复"也是协程框架帮我们恢复,减少我们代码执行操作。

同时Kotlin也是首次把挂起函数功能暴露给开发者,开发者可以定义和实现挂起函数,给了开发者最大的限度,这部分我们后面说挂起函数原理时细说。

同时在前面文章其实也提及过,launch启动的协程,其中block的高阶函数类型就是挂起函数类型,所以协程的核心也是挂起函数,只是加了结构化并发和灵活启动方式的特性,这部分我们后面说launch函数原理时细说。

所以挂起函数必须要理解透彻。

正文

先来看一下挂起函数在我们平时工作使用时的优点,再介绍其原理。

解决"地狱回调"

挂起函数的最大好处就是可以使用同步的方式来写出异步的代码,比如下面是一段简单的Java代码:

getUserInfo(new CallBack() {
    @Override
    public void onSuccess(String response) {
        if (response != null) {
            System.out.println(response);
        }
    }
});
复制代码

这段代码中,发起了一个异步请求,然后通过CallBack返回response。这代码看起来很正常,但是在实际项目中可能很复杂,即可能要连续执行好几个异步任务,比如查询用户信息 -> 查询该用户好友列表 -> 查找某位好友的动态,那代码会变成:

// 代码段2
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关键字定义为挂起函数:

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..}"
}
复制代码

会发现所谓的挂起函数,其实就是比普通函数多一个suspend关键字修饰而已,如果去掉这个suspend关键字,函数就会变成普通函数。

挂起和恢复

前面例子中,挂起函数可以以"同步的方式实现异步任务"所依赖的就是它的挂起和恢复功能

从字面意思上来说suspend这个词就是挂起的意思,而挂起就是将代码执行流程切换到其他线程,等任务完成之后,再恢复回来。

比如下图是上面代码执行的线程切换情况:

image.png

这里可以看出上面同步方式写的代码在执行过程中进行了多次线程切换,下面来说一下关于挂起和恢复的知识点:

  1. 在IDE中,挂起函数会有一个特殊的箭头标记,这样便于分辨出当前调用的函数是否是挂起函数,调用挂起函数的位置叫做挂起点

  2. 比如一行代码"val user = getUserInfo"却发生了线程切换,即等号左边的代码在主线程等号右边的代码在IO线程

  3. 挂起和恢复是挂起函数的能力,普通函数没有

  4. 挂起只是将程序执行流程转移到了其他线程,不会阻塞线程。

那挂起函数一行代码切换线程的操作是如何做到呢 这就是suspend关键字的作用了。

深入理解suspend

首先我们要注意一点就是当一个函数被suspend修饰时,它的类型是会发生变化的

我们之前知道,一个高阶函数类型只和它的参数、返回值、接收者类型相关,那现在不同了,还会和suspend相关。就比如suspend (Int) -> Double与(Int) -> Double是不一样的,不能互相赋值。

直接想一下文章开始的回调,再想一下Java和Kotlin都是运行在JVM上的,所以它不会有什么特别神奇的地方,所以看下图:

image.png

可以得出结论:挂起函数的本质就是CallBack

为什么挂起函数可以简洁这么多呢,这是因为Kotlin编译器帮我们干了很多事,当Kotlin编译器检测到suspend关键字修饰的函数以后,就会自动将挂起函数转换带有CallBack的函数

如果将上面的挂起函数反编译为Java代码,结果如下:

//                              Continuation 等价于 CallBack
//                                         ↓         
public static final Object getUserInfo(Continuation $completion) {
  ...
  return "BoyCoder";
}
复制代码

从反编译的代码来看,原来的suspend关键字不见了,挂起函数变成了一个带有CallBack参数的函数,只是这个CallBack比较特殊,叫做Continuation,我们看看这个类的定义:

public interface Continuation<in T> {

//      相当于 CallBack的onSuccess   结果   
//                 ↓                 ↓
    public fun resumeWith(result: Result<T>)
}

interface CallBack {
    void onSuccess(String response);
}
复制代码

可以发现这个Continuation本质就是一个带有泛型参数的CallBack,而这个从挂起函数转换为CallBack函数的过程,被叫做CPS转换,是由编译器帮我们干的。

CPS(Continuation-Passing-Style Transfrom)

所以当我们定义的函数前面加了suspend关键字的时候,编译器在编译时就会对挂起函数做特殊处理,而这个特殊处理就是CPS,下面是一张动图来简单说明是如何变化的:

CPS.gif

在这个过程中我们会发现我们在Kotlin代码中的函数类型为"suspend () -> String"变成了"(Continuation) -> Any?",这里其实就把suspend关键字给解析了,反编译的Java代码将不会出现suspend关键字了。

同时我们可以验证一下,使用Java代码访问Kotlin的挂起函数getUserInfo(),来看一下类型:

image.png

所以这里压力就来到了Continuation这个类上面了,既然挂起本质就是CallBack,那这个CallBack的变种Continuation就是关键了。

Continuation

从这个词的词源Continue就可以猜的出来,是继续的意思,而Continuation代表的就是"接下来要做的事情",或者在代码中就代表"持续继续运行下去需要执行的代码",或者"接下来要执行的代码",或者"剩下的代码"。

比如还是以getUserInfo()为例,下图红框中的代码就表示Continuation:

image.png

是不是突然就理解CPS了,它其实就是将程序接下来要执行的代码进行传递的一种模式。

CPS转换就是将原本的同步挂起函数转换为CallBack异步代码的过程,这个转换过程是编译器做的,对程序员是无感知的:

CPS1.gif

这里只是简单的示意图,真实过程要复杂得多。

到这里,我们就知道了挂起函数本质就是CallBack。

挂起函数和协程

说完挂起函数,我们再来回顾一下协程的思维模型:

协程挂起恢复模型.gif

我们可以得出一个线索:协程之所以是非阻塞,是因为它支持"挂起和恢复";而挂起和恢复的能力,主要是源自于挂起函数;而挂起函数是由CPS实现的,其中的Continuation,本质上就是CallBack

看完这个是不是感觉挂起函数和协程是一个东西呢,其实不是的。

关于协程和挂起函数的关系,先不从底层分析,我们先来看个简单例子:

fun main() {
    getUserInfo() // 报错
}

suspend fun getUserInfo(): String {
    withContext(Dispatchers.IO) {
        delay(1000L)
    }
    return "BoyCoder"
}
复制代码

这里我们都知道会报错,因为挂起函数只能在协程中被调用或者被其他挂起函数调用,所以我们可以写出下面代码:

// 在协程中调用getUserInfo()
fun main() = runBlocking {
    val user = getUserInfo()
}

// 在另一个挂起函数中调用getUserInfo()
suspend fun anotherSuspendFunc() {
    val user = getUserInfo()
}
复制代码

这里我们就可以找出他们的共性了,再看一下runBlocking的函数签名:

public actual fun <T> runBlocking(
    context: CoroutineContext, 
    block: suspend CoroutineScope.() -> T
): T {
}
复制代码

会发现原来block也是一个挂起函数类型,所以在block中调用挂起函数一点也不奇怪了。

所以说,虽然协程和挂起函数都可以调用挂起函数,但是协程的lambda也是挂起函数,本质都是因为挂起函数可以调用挂起函数

从目前阶段来看,我们可以认为:挂起和恢复,是协程的一种底层能力;而挂起函数,是这种底层能力的一种表现形式,通过暴露出来的suspend关键字,我们开发者可以在上层,非常方便地使用这种底层能力

总结

挂起函数可以极大地简化异步编程,能够以同步的方式写异步任务代码,避免回调地狱。关于挂起函数有如下小节:

  • 定义挂起函数,只需要在普通函数前面加个suspend关键字即可。
  • 挂起函数,由于它有挂起和恢复的能力,所以切换线程非常方便。
  • 挂起函数的本质就是CallBack,只不过这个CallBack叫做Continuation。
  • 挂起函数,只能在协程中被调用,或者被其他挂起函数调用,协程中的blcok,就是挂起函数。
分类:
Android
标签:
分类:
Android
标签:
收藏成功!
已添加到「」, 点击更改