当前位置: 首页>后端>正文

Kotlin协程 - 原理探索(一)

1. 协程解决了什么问题?

用协程也有一段时间了,但是关于协程具体到底怎么运作的这件事上,处于一种懂了但是又好像没懂的情况,但是协程解决什么事情倒是很明确。

我们先举个Android日常开发会经常遇到的痛点,就是异步回调地狱,比如现在有一个功能需要调用A接口,然后用A接口的返回参数请求B接口,再用B接口的返回值请求C接口。(当然,一般遇不上啊,这太灾难了)

那么以上的功能我们一般是怎么解决的?

fun main(){
    request1(){ params1->
        request2(params1){ params2->
            request3(params2)
        }
    }
}

写完后我背后一凉,这太不优雅了!!要是现在再改改需求,请求接口C必须先要同时请求并且等待接口A和B的返回值,然后拿着A和B的返回值进行接口C的请求。

可能讲到这里你已经开始骂娘了,但是此时协程站出来了,说了句:“不用骂了!因为我来了!”

那么我们用协程会怎么写以上两个功能?

需求一:

fun main(){
   GlobalScope.launch {
      var params1= request1()//异步请求
      var params2= request2(params1)//异步请求
      request3(params2)
  }
}

需求二:

fun main(){
   GlobalScope.launch {
      var params1= async{request1()}//异步请求
      var params2= async{request2(params1)}//异步请求
      request3(params1.await(),params2.await())
  }
}

这实在太优雅了啦!消除了回调地狱,让异步的程序用同步的写法来执行~这就是协程所解决的痛点!

没错,协程就是用同步的写法来编写异步的程序,但是具体点的概念是什么呢?

而且也不只是Kotlin拥有协程,还有其他拥有协程的语音,比如Go,PyThon,甚至Java也有协程了,那么既然他们都称为协程,那么肯定有一套统一的思想驱使他们去设计协程,那么协程具体是基于什么思想和理念来设计的呢?接下来讲解下。

2.协程设计理念探索

2.1 Continuation Passing Style(CPS)

协程是基于Continuation Passing Style(CPS)这种理念来设计的,那什么是CPS呢?

举两个例子,一种是平时编写程序的写法,另一种是CPS的写法:

fun drawView(){
    measure();
    layout();
    draw();
}

上面是理所当然的写法,那么接着看看CPS是怎么写的?

fun drawView(){
    measure(){ //第一步
        layout(){ //第二步
            draw() //第三步
        }
    }
}

在CPS中,每个下个步的执行都是通过上一步的完成回调来执行的,而CPS中的C就是下一步、延续物的意思,而网上普遍称为续体,接着我们来看看上面的代码分析一下续体有哪些?

  1. 第二步和第三步是第一步的续体
  2. 第三步是第二步的续体

但是一般看到这里都会产生疑问:咦?通过回调的写法,那tm不是变得更麻烦了吗?这CPS跟回调地狱有什么区别?

确实没区别,但是讲CPS是为了凸显执行步骤续体这一概念,这样才能更好的去解释协程。

但是刚刚衍生出一个问题,我勒个去,这CPS回调太恶心了,Kotlin协程是怎么解决这些回调的?我们会在章节2.3中细说,接着我们先保留问题,先看看Kotlin中的续体是什么?

2.2 Kotlin中的续体接口

Kotlin中的续体都必须是继承至Continuation这个接口的。

public interface Continuation<in T> {
    //协程上下文
    public val context: CoroutineContext

    //恢复挂起的函数,并且返回参数结果
    public fun resumeWith(result: Result<T>)
}

我们可以看到,接口包含一个成员变量和一个函数,分别是协程的上下文与恢复挂起。

  • CoroutineContext:协程的上下文,本质是一个链表,包含了协程执行时需要的一些参数比如:调度器、名称/ID、控制器 Job、异常 Handler等
  • resumeWith(result:Result<T>):进行下一步的函数,result就是当前步骤的结果。

2.3 状态机

Kotlin利用状态机来消除CPS的回调,先简单介绍一下状态机这种设计模式。

状态机围绕着两个关键的变量来设计,一是状态,二是行为,每个状态下都会有符合自己状态的行为。

举个例子:

  1. 比如我一台电视,有开机和关机两种状态,在开机时候我们需要屏幕渲染出画面,在关机的时候,我们就停止了屏幕的渲染。
  2. 那么Activity的生命周期也属于一种状态模式,每个生命周期下他的行为都不一样,比如onCreate中我们要初始化布局,onDestroy里面我们要进行销毁动作

介绍完状态机模式后,我相信理解上没什么难度,那么Kotlin协程是怎么做到利用状态机消除CPS回调的?

想要看到Kotlin协程的代码真实面貌,需要反编译一下才能看清全貌。

接下来贴上一段需求一的代码与其反编译后的代码

//原协程代码
fun main(){
   GlobalScope.launch {
      var params1= request1()//异步请求
      var params2= request2(params1)//异步请求
      request3(params2)
  }
}

//反编译后
public final void main() {
  BuildersKt.launch(() {
    //可以理解成一个程序计数器,也就是状态
     int label;
     
     public final Object invokeSuspend(@NotNull Object $result) {
        //当前上一步的执行结果
        Object result;
            //通过swtich来区分对应状态下的行为
           switch (this.label) {
              case 0:
                //将label改为1,意味着下次执行该方法时,会到case 1逻辑分支。
                 this.label = 1;
                 //调用方法,如果该方法是挂起方法则返回一个标识
                 result = request1(this);
                 //判断标识,如果执行的函数是挂起函数则直接return,在外围循环中该invokeSuspend方法会重新被调用,直到执行完毕
                 //这里只需要大概知道原理,后面会细说整体原理
                 if (result == IS_SUSPEND) {
                    return IS_SUSPEND;
                 }
                 break;
              case 1:
                //因为在case0中label被改为1,执行挂起函数然后return了,在case1的挂起函数执行完后,该方法被外部重新执行,此时来到case1逻辑中。
                //通过参数获取case 0中的结果
                 result = $result;
                 String params1 = (String)result;
                 //设置下一步需要执行的步数
                 this.label = 2;
                 //与request1同理,就不重复说了
                 result = request2(params1, this);
                 if (result == IS_SUSPEND) {
                   return IS_SUSPEND;
                 }
                 break;
              case 2:
                //这里基本与request2同理就不重复说了
                 result = $result;
                 String params2 = (String)result;
                 this.label = 3;
                 if (request3(params2, this) == IS_SUSPEND) {
                    return IS_SUSPEND;
                 } else {
                    return Unit.INSTANCE;
                 }                     
                 break;
              case 3:
                //到了第四步,没有续体可以执行,视为结束,返回结束标识,至此,我们该协程运行完毕
                 ResultKt.throwOnFailure($result);
                 return Unit.INSTANCE;
           }
       }
    }
  );
}


我们通过反编译后的伪代码看出来,Kotlin通过label变量来决定当前执行哪一步的逻辑,而switch中就是存放每一步的方法快,这样就能消除回调嵌套,当程序执行完某一步之后,如果当前是挂起函数,则会return掉当前方法,进入下一次的循环,重新调用方法,判断label执行到哪一步,直到结束。

这里跟实际反编译的代码虽然区别很大,但是意思是差不多的,协程除了通过状态机控制流程,其实还加上闭包,加上闭包的好处是可以控制变量的生命周期,有些变量没必要贯穿整个协程周期,那这里就先不细说。

2.4 挂起本质

章节2.3中,我们知道了协程是通过状态机来区别执行哪一步,但是具体对方法是怎么挂起和恢复的只是有一个模糊的理解,本章节我们尝试深入讲明白。

2.4.1 了解挂起的概念

首先我们需要了解在协程中什么是挂起,先上一个协程例子:

fun main(){
   GlobalScope.launch {
      var params= request()//异步请求
  }
}

我们可以得知request是异步请求,请求结果赋值到params变量中,那么在协程里面整体步骤分为以下两步(不严谨的,便于理解):

  1. 调用request方法,此时因为request方法在另外一个线程,所以执行request方法后程序挂起了。
  2. 网络请求回来了,解析了返回值并且返回,此时挂起在“=”号右边的程序恢复,并且将request方法的结果赋值到params变量中。

可能熟悉线程的童鞋会一下子反应过来,卧槽这不就是阻塞吗?这里要特别说明一下,在协程里,挂起并不会造成线程阻塞,可以理解成非阻塞等待

2.4.2 挂起函数与恢复挂起

当我们理解了什么是挂起后,接下来就需要学习怎么声明挂起函数了,声明一个挂起函数我们需要再函数前加关键字suspend

来,我们尝试下加一下suspend关键字,看看有没问题。

suspend fun request(): String {
    //模拟网络请求
    Thread{
        var params = requetHttp();
    }.start()
    return ;
}

卧槽,问题来了,那异步的结果是需要怎么返回啊?

在异步的请求下,仅仅通过添加suspend关键字还不够,还需要将关键的逻辑包装成续体,那么在Kotlin下有两种主流续体,一种是SafeContinuation,另一种是CancellableContinuation,可以先简单理解成前者是不可取消的续体,而后者是可以取消的,来,我们改造下。

   suspend fun request(): String {
        //这里并不是return真正的返回值,这里返回的是IS_SUSPEND的标识,详情看上面反编译后的case0;
        //这里我们通过suspendCancellableCoroutine这个方法创建了可取消续体,并且包裹了我们实际的逻辑
        return suspendCancellableCoroutine { it:CancellableContinuation<String> ->
        //当执行到这个闭包逻辑时,其实整个协程的状态已经属于挂起中的状态
         Thread{
            var params = requetHttp();
            //我们调用CancellableContinuation中的resumeWith方法后。
            //该方法会告诉协程,当前我这个挂起函数已经执行完毕,并且将返回值返回,现在可以恢复协程中的逻辑执行了。
            it.resumeWith(Result.success("param"));
         }.start()
        }
    }

在上面的挂起函数里面我们主要做了两件事:

  1. 调用suspendCancellableCoroutine方法,将我们的逻辑作为闭包传进去,此时会生成一个续体包裹我们的闭包。
  2. 我们拥有续体之后,我们就拥有了恢复程序的能力,具体看2.2章节中的续体接口,其接口提供了resumeWith函数,提供恢复挂起的能力。
  3. 调用续体的resumeWith方法,恢复挂起,至此协程的工作完毕。

3. 完整的挂起与恢复流程是怎么样的?

上面其实已经讲解了以下几点:

  1. 协程是如何通过状态机+续体进行工作
  2. 如何声明一个挂起函数与将逻辑包裹在续体里
  3. 挂起函数中是怎么恢复挂起点的

但是以上几点都讲得比较零碎,接下来我们将整体流程给复盘一次,加深对协程运作的印象。

首先声明一个协程与挂起函数,同时贴出他们反编译后的代码,并且讲解运作流程。

//源代码
fun main(){
   GlobalScope.launch {
      var params = request()//挂起函数
  }
}

 suspend fun request(): String {
        return suspendCancellableCoroutine { it:CancellableContinuation<String> ->
         Thread{
            var params = requetHttp();
            it.resumeWith(Result.success("param"));
         }.start()
        }
    }

将上面代码进行反编译后,我们对流程进行分析与解读,请大家仔细看代码注释,这将会帮助你了解协程的基本运作。

class Demo{
   public final void main() {
    //1. 通过launch方法开始协程运作
      BuildersKt.launch(() {
         int label;
         @Nullable
         public final Object invokeSuspend(@NotNull Object $result) {
            Object var3 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
            Object var10000;
            switch (this.label) {
               case 0:
                  Demo1 var4 = Demo1.INSTANCE;
                  //2. 将执行步骤指向第二步
                  this.label = 1;
                  //3.调用挂起函数,留意参数,将当前整个闭包对象传递进去(具体看下面的方法)
                  var10000 = var4.request(this);
                  //4.判断request方法是否挂起方法,如果是则reutrn。
                  if (var10000 == var3) {
                     return var3;
                  }
                  break;
               case 1:
                  //4.invokeSuspend方法会再次被request调用,根据状态机走到这步,从参数中获取request的返回值
                  var10000 = $result;
                  break;
               default:
                  throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
            }
            //5.将参数强转成具体类型,至此该协程结束
            String var2 = (String)var10000;
            return Unit.INSTANCE;
         }
      });
   }

    /*
    注意,方法在反编译后会新增一个参数,该参数指的就是launch函数里面传递的闭包
    这里可以判断出那个闭包是继承于Continuation的。
    */
   private final Object request(Continuation $completion) {
      //3.1 新建一个续体对象,
      CancellableContinuationImpl cancellable$iv = new CancellableContinuationImpl(IntrinsicsKt.intercepted($completion), 1);
      cancellable$iv.initCancellability();
      CancellableContinuation it = (CancellableContinuation)cancellable$iv;
      int var7 = false;
      //3.2 开始原执行方法体函数
      new Thread((){
          Result.Companion var1 = Result.Companion;
          //3.5 模拟请求接口
          String var2 = "param1";
          //3.6 最后实际是调用了外面协程闭包的invokeSuspend方法,利用状态机进行下一步,这里相当于回调
          this.$it.resumeWith(Result.constructor-impl(var2));          
      }).start();
      //3.3 这里一般是返回标记为,表明当前目前该函数是挂起函数
      Object var10000 = cancellable$iv.getResult();
      if (var10000 == IntrinsicsKt.getCOROUTINE_SUSPENDED()) {
         DebugProbesKt.probeCoroutineSuspended($completion);
      }
      //3.4 返回标记位(这里并没有真正的返回接口结果)
      return var10000;
   }
}


4.总结

至此,协程的整体基本运作流程到这里结束了,因为篇幅问题,我们先从最表象的流程原理开始讲,让大家对协程的实际运作有个基本的认识,接着我们总结一下本文章涉及的知识点

  1. 协程是基于CPS理念来设计的,CPS理念中核心的概念为续体与执行顺序的逻辑(步数)。
  2. Kotlin利用了续体和状态机与编译期修改,消除了CPS原本所产生的回调。
  3. 协程闭包与挂起函数,在编译阶段会对方法和闭包内的签名,包括返回值、修饰符、入参、方法实体进行修改。
  4. 了解了协程的基本运作流程,了解了状态机、续体、挂起、恢复具体在Kotlin协程的实际应用。
  5. 通过流程其实我们可以得出结论:挂起的本质是return,恢复的本质是回调(resumeWith)
  6. 最后还是总结一个协程的主要作用:异步的逻辑用同步的写法,保持代码的可读性与简洁

那么本篇文章到此结束了,因为协程涉及的概念、原理会比较多,本篇文章核心重点是让读者至少知道我们编写的协程与挂起函数,具体是怎么运作的。


https://www.xamrdz.com/backend/32m1941389.html

相关文章: