协程粉碎计划 | 挂起函数原理解析

·  阅读 1393
协程粉碎计划 | 挂起函数原理解析

本系列专栏 #Kotlin协程

前言

前面文章我们说了挂起函数是协程的精髓之一,而挂起函数特点就是可以挂起和恢复,通过前面我们对挂起函数的简单使用,我们知道挂起函数的本质还是Callback,只是这个转换由编译器帮我们完成。

这里可以查看一下前面的文章:

协程粉碎计划 | 挂起函数 - 掘金 (juejin.cn)

那本篇文章就来仔细说一下这个挂起函数里面具体的细节以及如何执行的。

正文

为了了解挂起函数是如何运行的,这里很多代码都是通过Android Studio把Kotlin代码反编译为Java代码来加强理解,至于编译器如何解析suspend函数,这个就涉及的太底层了,先不分析。

CPS

在前面那篇介绍挂起函数文章中,我们说过从挂起函数转换为CallBack函数的过程,叫做CPS转换(Continuation-Passing-Style Transformation),即Continuation风格转换,还是放个动图来加强理解:

fcf5b8eead81466bb7eacc017f0c1377_tplv-k3u1fbpfcp-zoom-in-crop-mark_1304_0_0_0.webp

这里的关键点就是Continuation,我们来看一下这个Continuation的源码:

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

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

这里可以理解为表示返回T类型值的挂起点之后的延续,这里比较难以理解,我们可以这样认为,当需要挂起时,就需要一个Continuation,而这个挂起点一般是一个函数,它有个返回值类型即这里的泛型T,同时resumeWith就是接下来要执行的流程。

这里我们用Java调用Kotlin的挂起函数先加强理解,比如下面是Kotlin代码,我定义一个挂起函数:

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

注意这个挂起函数的类型是"suspend () -> String"类型,然后我们在Java代码中进行调用,我们来看看如何调用:

public static void main(String[] args) throws InterruptedException {
    TestKtCoroutine.getUserInfo(new Continuation<String>() {
        @NonNull
        @Override
        public CoroutineContext getContext() {
            return EmptyCoroutineContext.INSTANCE;
        }

        @Override
        public void resumeWith(@NonNull Object o) {
            System.out.println("user = " + o.toString());
        }
    });
    Thread.sleep(2000);
}

会发现这里getUserInfo的参数需要传入一个Continuation实例,而这里使用匿名内部类实例来完成传值,需要注意的一点就是在Java代码看来,这时的函数类型是"(Continuation) -> Any?"类型,根据前面的Continuation的定义我们就知道这里调用了挂起函数,返回值是String类型,然后在resumeWith回调中继续后面的业务。

注意这里需要线程休眠2000ms,防止程序退出。

这里不仅要知道函数类型中Continuation的泛型参数代表挂起函数返回值,同时还需要了解这个返回值为什么是Any?,这里对后面理解原理至关重要。因为这个返回值标志挂起函数有没有被挂起

这里听着有点奇怪,挂起函数不就是要挂起吗 其实不然,当挂起函数内没有调用其他挂起函数或者实现挂起函数时,它就不需要挂起,比如下面代码:

image.png

这里IDE也会提醒suspend是多余的,当是普通函数时,通过CPS转换,返回值类型是"no suspend";而是挂起函数时,返回值是CoroutineSingletons.COROUTINE_SUSPEND,所以为了能够统一,这里返回值类型定义为Any?类型。

所以当定义的挂起函数是:

suspend fun getUserInfo(): String {}

经过编译器编译后就是:

fun getUserInfo(cont: Continuation<String>): Any?{}

那么就来看看反编译后的内部代码执行逻辑。

状态机

既然我们了解了CPS,以及其返回值,那我们不妨来搞个复杂点的代码来反编译一下看看,比如下面Kotlin代码:

fun main() = runBlocking {
    testCoroutine()
}

suspend fun testCoroutine() {
    println("start")
    val user = getUserInfo()
    println(user)
    val friendList = getFriendList(user)
    println(friendList)
    val feedList = getFeedList(friendList)
    println(feedList)
}

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

suspend fun noSuspendGetUserInfo(): String{
    return "Jack";
}

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..}"
}

这里我们使用挂起函数使用同步的方式写出异步的代码,我们都知道十分方便,但是上面我们是Java代码调用将是下面这样:

public static void main(String[] args) throws InterruptedException {
    System.out.println("start");
    TestKtCoroutine.getUserInfo(new Continuation<String>() {
        @NonNull
        @Override
        public CoroutineContext getContext() {
            return EmptyCoroutineContext.INSTANCE;
        }

        @Override
        public void resumeWith(@NonNull Object o) {
            System.out.println("user = " + o.toString());
            TestKtCoroutine.getFriendList(o.toString(), new Continuation<String>() {
                @NonNull
                @Override
                public CoroutineContext getContext() {
                    return EmptyCoroutineContext.INSTANCE;
                }

                @Override
                public void resumeWith(@NonNull Object o) {
                    System.out.println("friendList = " + o.toString());
                    TestKtCoroutine.getFeedList(o.toString(), new Continuation<String>() {
                        @NonNull
                        @Override
                        public CoroutineContext getContext() {
                            return EmptyCoroutineContext.INSTANCE;
                        }

                        @Override
                        public void resumeWith(@NonNull Object o) {
                            System.out.println("feedList = " + o.toString());
                        }
                    });
                }
            });
        }
    });
    
    Thread.sleep(4000);
}

看到上面的回调地狱真的又感觉协程很方便,但是kotlin编译后的代码真的像上面一样吗,我们来反编译一下Kotlin代码,把上面的testCoroutine()反编译得到如下Java代码:

public static final Object testCoroutine(@NotNull Continuation var0) {
   Object $continuation;
   label37: {
      if (var0 instanceof <undefinedtype>) {
         $continuation = (<undefinedtype>)var0;
         if ((((<undefinedtype>)$continuation).label & Integer.MIN_VALUE) != 0) {
            ((<undefinedtype>)$continuation).label -= Integer.MIN_VALUE;
            break label37;
         }
      }

      $continuation = new ContinuationImpl(var0) {
         // $FF: synthetic field
         Object result;
         int label;

         @Nullable
         public final Object invokeSuspend(@NotNull Object $result) {
            this.result = $result;
            this.label |= Integer.MIN_VALUE;
            return TestKtCoroutine.testCoroutine(this);
         }
      };
   }

   Object var10000;
   label31: {
      Object var6;
      label30: {
         Object $result = ((<undefinedtype>)$continuation).result;
         var6 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
         String user;
         switch(((<undefinedtype>)$continuation).label) {
         case 0:
            ResultKt.throwOnFailure($result);
            user = "start";
            System.out.println(user);
            ((<undefinedtype>)$continuation).label = 1;
            var10000 = getUserInfo((Continuation)$continuation);
            if (var10000 == var6) {
               return var6;
            }
            break;
         case 1:
            ResultKt.throwOnFailure($result);
            var10000 = $result;
            break;
         case 2:
            ResultKt.throwOnFailure($result);
            var10000 = $result;
            break label30;
         case 3:
            ResultKt.throwOnFailure($result);
            var10000 = $result;
            break label31;
         default:
            throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
         }

         user = (String)var10000;
         System.out.println(user);
         ((<undefinedtype>)$continuation).label = 2;
         var10000 = getFriendList(user, (Continuation)$continuation);
         if (var10000 == var6) {
            return var6;
         }
      }

      String friendList = (String)var10000;
      System.out.println(friendList);
      ((<undefinedtype>)$continuation).label = 3;
      var10000 = getFeedList(friendList, (Continuation)$continuation);
      if (var10000 == var6) {
         return var6;
      }
   }

   String feedList = (String)var10000;
   System.out.println(feedList);
   return Unit.INSTANCE;
}

会发现这里貌似没有像我们使用Java代码一样,创建了多个Continuation对象,这里的代码比较特殊,而且阅读起来有点费劲。

简化逻辑

上面反编译的代码,如果不是Java知识能力很强的开发者根本看不明白,因为这些代码毕竟是编译器生成的代码,所以下面我们来简化一下逻辑,注意这里只是简化逻辑而不是修改逻辑,先通过非反编译代码了解原理,再看反编译的代码就容易理解了。

还是以testCoroutine这个方法为例,首先会多出一个ContinuationImpl的子类,它是整个挂起函数的核心,代码如下:

fun testCoroutine(completion: Continuation<Any?>): Any? {
    // TestContinuation其实是匿名内部类,这里为了好理解起了一个名字
    class TestContinuation(completion: Continuation<Any?>?) : ContinuationImpl(completion) {
        // 表示挂起函数状态机当前的状态
        var label: Int = 0
        // 挂起函数返回结果
        var result: Any? = null

        // 用于保存之前挂起函数的计算结果
        var mUser: Any? = null
        var mFriendList: Any? = null

        // invokeSuspend 是挂起函数的关键
        // 它最终会调用 testCoroutine(this) 开启挂起函数状态机
        // 状态机相关代码就是后面的 when 语句
        // 挂起函数的本质,可以说就是 CPS + 状态机
        override fun invokeSuspend(_result: Result<Any?>): Any? {
            result = _result
            label = label or Int.Companion.MIN_VALUE
            return testCoroutine(this)
        }
    }
}

这里大概就能看出端倪,挂起函数内部其实就是一个状态机,状态机的代码后面再说,我们先来看一下姑且叫做TestContinuation类的情况,它定义了几个成员变量:

  • label 用来代表挂起函数状态机当前的状态
  • result 用来存储挂起函数执行结果
  • mUser、mFriendList 用来存储历史挂起函数执行结果。
  • invokeSuspend 是整个状态机的入口,它会将执行流程转交给testCoroutine()进行再次调用。

这里我们大概知道,这里是使用了递归调用testCoroutine()函数来实现在一个函数中调用多次调用挂起函数而不用创建多个CallBack的情况,接下来要判断testCoroutine()是不是初次运行,如果是初次运行,就要创建一个TestContinuation的实例对象,代码如下:

fun testCoroutine(completion: Continuation<Any?>): Any? {
    ...
    val continuation = if (completion is TestContinuation) {
        completion
    } else {
        //                作为参数
        //                   ↓
        TestContinuation(completion)
    }
}

这里会发现如果是初次运行testCoroutine()函数,则会把参数completion作为TestContinuation构造参数,否则就直接使用传入进来的continuation。这里有个什么好处呢,也就是在多次方法调用时,continuation实例就一个,不会像前面Java调用协程代码一样需要创建多个匿名内部类对象,这样可以极大地节省内存。

接下来定义几个变量:

// 三个变量,对应原函数的三个变量
lateinit var user: String
lateinit var friendList: String
lateinit var feedList: String

// result 接收协程的运行结果
var result = continuation.result

// suspendReturn 接收挂起函数的返回值
var suspendReturn: Any? = null

// CoroutineSingletons 是个枚举类
// COROUTINE_SUSPENDED 代表当前函数被挂起了
val sFlag = CoroutineSingletons.COROUTINE_SUSPENDED

分别定义了函数中的临时变量、挂起函数执行结果以及是否挂起的标志位,接着就是协程状态机的核心逻辑:

when (continuation.label) {
    0 -> {
        // 检测异常
        throwOnFailure(result)
        log("start")
        // 将 label 置为 1,准备进入下一次状态
        continuation.label = 1
        // 执行 getUserInfo
        suspendReturn = getUserInfo(continuation)
        // 判断是否挂起
        if (suspendReturn == sFlag) {
            return suspendReturn
        } else {
            result = suspendReturn
            //go to next state
        }
    }

    1 -> {
        throwOnFailure(result)
        // 获取 user 值
        user = result as String
        log(user)
        // 将协程结果存到 continuation 里
        continuation.mUser = user
        // 准备进入下一个状态
        continuation.label = 2
        // 执行 getFriendList
        suspendReturn = getFriendList(user, continuation)
        // 判断是否挂起
        if (suspendReturn == sFlag) {
            return suspendReturn
        } else {
            result = suspendReturn
            //go to next state
        }
    }

    2 -> {
        throwOnFailure(result)
        user = continuation.mUser as String
        // 获取 friendList 的值
        friendList = result as String
        log(friendList)
        // 将协程结果存到 continuation 里
        continuation.mUser = user
        continuation.mFriendList = friendList
        // 准备进入下一个状态
        continuation.label = 3
        // 执行 getFeedList
        suspendReturn = getFeedList(user, friendList, continuation)
        // 判断是否挂起
        if (suspendReturn == sFlag) {
            return suspendReturn
        } else {
            result = suspendReturn
            //go to next state
        }
    }

    3 -> {
        throwOnFailure(result)
        user = continuation.mUser as String
        friendList = continuation.mFriendList as String
        feedList = continuation.result as String
        log(feedList)
        loop = false
    }
}

由上面我们发现在testCoroutine()中调用了3个挂起函数,这3个挂起函数把整个方法体分割为了4个部分,我们来简单解读一下上面代码:

  • when表达式实现了协程状态机;
  • continuation.label是状态流转的关键,continuation.label改变一次,就代表了挂起函数被调用了一次
  • 每次挂起函数执行完,都会检查是否发生了异常;
  • testCoroutine里的原本的代码,被拆分到状态机里各个状态中,分开执行;
  • getUserInfo(continuation)、getFriendList(user,continuation)和getFeedList(user,friendList,continuation)这3个函数用的是同一个continuation实例
  • 如果一个函数被挂起了,它的返回值是CoroutineSingletons.COROUTINE_SUSPEND;
  • 在挂起函数执行过程中,状态机会把之前的结果以成员变量的方式保存在continuation中

到这里我们就过完了整个逻辑,首先就是testCoroutine()函数会执行多次,而这里只会创建一个ContinuationImpl对象,而在这个对象里面有个invokeSuspend方法,会调用testCoroutine()进入方法,而且有个label来切换状态机,当调用挂起函数时会传入continuation,当挂起函数执行完后,会调用continuation的invokeSuspend方法来重新进入状态机

我这边简单地画了一个状态流程图,其实就是状态机的运行流程,其中黑色表示第一次调用,红色表示调用完getUserInfo第二次调用,绿色表示第三次调用:

挂起函数状态机.png

通过这里我们就可以简单的一句话总结挂起函数原理就是:CPS和状态机的巧妙应用。

goto跳转

上面流程图如果仔细思考的话,还是有个问题,那就是判断是否是挂起函数,如果是挂起函数我们在continuation中调用InvokeSuspend()方法可以再次进入testCoroutine()函数,但是不是挂起函数时,这个如何进入呢

其实当不是挂起函数时,并不会调用testCoroutine()函数,而是会直接进入状态机,这里其实就是goto语句,而goto语句在Kotlin已经没有了,所以在Java中类似下面代码来实现跳转:

...
label: whenStart
when (continuation.label) {
    0 -> {
        ...
    }

    1 -> {
        ...
        suspendReturn = noSuspendFriendList(user, continuation)
        if (suspendReturn == sFlag) {
            return suspendReturn
        } else {
            result = suspendReturn
            // 让程序跳转到 label 标记的地方
            // 从而再执行一次 when 表达式
            goto: whenStart
        }
    }

    2 -> {
        ...
    }

    3 -> {
        ...
    }
}

所以在反编译后的代码也会有许多label,就是为了实现这种跳转。

反编译代码

那现在我们简单来看一下反编译的代码,其实理解了上面逻辑就可以了,本质上来说Kotlin协程就是通过label代码嵌套,配合Switch巧妙地构造出一个状态机结构,这部分逻辑比较复杂,因为label代码不易阅读;而且随着编译器的发展,反编译出来的Java代码也会有细微差别,但是原理思想是不变的。

可以看上面反编译的代码,下面是把部分不易读的变量给重命名了一下:

public static final Object testCoroutine(@NotNull Continuation completion) {
   Object continuation;
   label: init_continuation {
      if (completion instanceof ContinuationImpl) {
         continuation = (ContinuationImpl)completion;
         if ((((ContinuationImpl)continuation).label & Integer.MIN_VALUE) != 0) {
            ((ContinuationImpl)continuation).label -= Integer.MIN_VALUE;
            //非第一次调用
            break label: init_continuation;
         }
      }
      //创建唯一的ContinuationImpl实例
      continuation = new ContinuationImpl(completion) {
         Object result;
         int label;

         @Nullable
         public final Object invokeSuspend(@NotNull Object $result) {
            this.result = $result;
            this.label |= Integer.MIN_VALUE;
            return TestKtCoroutine.testCoroutine(this);
         }
      };
   }

   //局部变量,保存每次挂起函数调用结果 
   Object tempSuspendResult;
   label31: {
      Object var6;
      label30: {
          //当前continuation的结果
         Object currentResult = ((ContinuationImpl)continuation).result;
         //挂起标致
         var6 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
         //临时变量,用来打印结果
         String tempPrint;
         switch(((ContinuationImpl)continuation).label) {
         case 0:
            ResultKt.throwOnFailure(currentResult);
            tempPrint = "start";
            System.out.println(tempPrint);
            ((ContinuationImpl)continuation).label = 1;
            tempSuspendResult = getUserInfo((Continuation)continuation);
            if (tempSuspendResult == var6) {
               return var6;
            }
            break;
         case 1:
            ResultKt.throwOnFailure(currentResult);
            tempSuspendResult = currentResult;
            break;
         case 2:
            ResultKt.throwOnFailure(currentResult);
            tempSuspendResult = currentResult;
            break label30;
         case 3:
            ResultKt.throwOnFailure(currentResult);
            tempSuspendResult = currentResult;
            break label31;
         default:
            throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
         }

         tempPrint = (String)tempSuspendResult;
         System.out.println(tempPrint);
         ((ContinuationImpl)continuation).label = 2;
         tempSuspendResult = getFriendList(user, (Continuation)continuation);
         if (tempSuspendResult == var6) {
            return var6;
         }
      }

      String friendList = (String)tempSuspendResult;
      System.out.println(friendList);
      ((ContinuationImpl)continuation).label = 3;
      tempSuspendResult = getFeedList(friendList, (Continuation)continuation);
      if (tempSuspendResult == var6) {
         return var6;
      }
   }

   String feedList = (String)tempSuspendResult;
   System.out.println(feedList);
   return Unit.INSTANCE;
}

上面代码也就通过label来控制代码的走向,来完成一个状态机的结构实现。

总结

本篇文章首先介绍了一个非常底层的概念就是Continuation,然后通过CPS编译器把挂起函数编译为Java的回调形式的代码,然后再通过状态机把调用不同挂起函数的业务分发到不同的状态上,通过循环调用和goto语句来多次进入状态机判断,执行后续逻辑代码。本质上来说,挂起函数就是CPS+状态机。

分类:
Android
标签:
分类:
Android
标签:
收藏成功!
已添加到「」, 点击更改