协程(4) | 挂起函数

1,693 阅读11分钟

前言

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

这里所说的挂起函数的挂起和恢复,与之前协程所具备的挂起和恢复有相似地方,但是这2个不是一个东西,我们后面细说。

同时在前面文章其实也提及过,3种方法启动的协程,其中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. 挂起只是将程序执行流程转移到了其他线程,不会阻塞线程。

这里的不会阻塞线程需要好好说一下,首先挂起函数只能在协程(其他挂起函数)中运行,而协程可以看成是一个轻量级的线程,除非你是使用runBlocking会阻塞线程外,其他都不会。而runBlocking阻塞线程,是这个方法的特殊性所决定的,和里面有没有挂起函数无关。

一旦调用了挂起函数,我们就可以认为当前执行的代码暂停了,需要等待挂起函数执行完,才继续执行。

那挂起函数一行代码切换线程的操作是如何做到呢 这就是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

在这个图中,我们把每个协程都看成是Task,比如在主线程使用launch启动一个协程,这个协程就会被挂起,并且不会影响主线程后续的其他代码执行。而使用async启动的协程,不仅可以挂起,还可以有返回值,从这个角度来说既有挂起又有恢复特性。

我们可以得出一个线索:协程之所以是非阻塞,是因为它支持"挂起和恢复";而挂起和恢复的能力,主要是源自于挂起函数;而挂起函数是由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关键字,我们开发者可以在上层,非常方便地使用这种底层能力

这里说的可能比较费劲,我们之前所启动的协程(代码块)其实就是一个挂起函数,那为什么要搞个协程出来呢?在后面文章我们会分析,比如协程会增加结构化并发等其他特性。

同时为什么挂起函数需要在协程或者其他挂起函数中运行呢?我们可以通过Continuation的定义看出一点端倪:

public interface Continuation<in T> {
   
    public val context: CoroutineContext

    public fun resumeWith(result: Result<T>)
}

这里我们发现该接口有一个属性,而对于Kotlin接口的特性:可以有属性和默认实现,这个转换为Java后其实就是一个方法,所以这里要有CoroutineContext对象。

而这个CoroutineContext从哪里来呢?我们来看一下CoroutineScope定义:

public interface CoroutineScope {
    
    public val coroutineContext: CoroutineContext
}

可以发现在CoroutineScope接口中就有这个协程上下文,所以这一切都说的通了:启动协程方法的高阶函数类型参数的接收者都是CoroutineScope,说明在协程内部都可以调用这个CoroutineContext对象,而这个对象恰好就是挂起函数所需要的。

总结

挂起函数非常重要,但是我们把它理解为回调就没有什么难以理解的点了。挂起是从当前代码执行切换到其他代码执行,而恢复则是继续执行后续代码,这个Continuation是后续代码,这个理解非常关键。

同时,我们要明白挂起函数和协程不是一个东西,协程的启动只有3种方法,虽然协程的lambda代码块也是挂起函数,但是它有着其他特性。

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

  • 定义挂起函数,只需要在普通函数前面加个suspend关键字即可。
  • 挂起函数,由于它有挂起和恢复的能力,所以切换线程非常方便。
  • 挂起函数的本质就是CallBack,只不过这个CallBack叫做Continuation
  • 挂起函数,只能在协程中被调用,或者被其他挂起函数调用,协程中的blcok,就是挂起函数。