Coroutine 基本原理

1,586 阅读8分钟

什么是协程?

  • 协程本质上可以认为是运行在线程上的代码块,是一套由kotlin官方提供的线程API(类似Java的Executor) 操作。
  • 此处讨论的是跑在JVM上的协程。 JVM原本没有协程这个东西,所以其实协程并没有从底层创建出新的东西,它无非就是对线程的一个封装。

为什么用协程,协程有什么好处?

  • 简单啊!!!
  • 可以用同步的代码写出异步的操作, 或者说用看起来阻塞的方式写出不阻塞的代码

eg: 需要请求两个api, 然后拿着这两个api的值做一个整合,最后在页面显示出来

  1. 如果用回调的方式,写法如下
//回调方式
getCard(canId)
    .enqueue(obj : Callback<Bitmap> {
	...
	override fun onResponse(call:Call<Bitmap>,
                                response: Resoinse<Bitmap>) {
		val card = resonse.body()
		getSofList(canId)
                    .enqueue(obj : Callback<Bitmap> {
			... 
			override fun onResponse(call:Call<Bitmap>
                                                response: Resoinse<Bitmap>) {
				val sofList = response.body()
				show(suspendMerge(cardDetail, sofList))  // 合并两个信息 & UI显示
		})
	}
})

👆这个代码就是用回调的方式实现的,它有什么问题呢?
两个网络请求原本是可以并行的网络请求,只是他们需要一起展示出来。如果用回调的方法,就会被做成一个串行的,然后网络等待多了一倍。

  1. 那我们再用Kotlin的协程实现一下呢?
//协程
suspend fun showCard(){
    val cardDetail = getCard(canId)  // 获取卡详情
    val sofList = getSofList(canId) // 获取银行卡信息
    val cardInfo = suspendMerge(cardDetail.await, sofList.await) // 合并两个信息
    show(cardInfo)  // UI显示
}

哇塞!是不是炒鸡简洁明了😄 对了,这就是为什么要用kotlin协程。这个里面干了啥我们不用管,反正此刻只需要知道,协程就让原本用callback写起来很复杂的东西变的整洁干净,清晰明了就好了。

挂起函数(suspend函数)

刚上面就出现了一个关键字 suspend, 那这个加了suspend这个词的函数又是啥呢? -> 挂起函数

那什么是挂起函数?

就是在普通的函数前面加一个关键字 suspend
一般函数写法:

fun getCard() {
 ......
}

挂起函数写法:

suspend fun getCard() {
 ......
}

suspend关键字作用:

提醒 这个函数的调用者: 我是一个耗时的函数, 请在协程中调用我。

挂起函数作用

说了半天,为什么要用挂起函数?
挂起的意思是:暂时从当前线程脱离,一会儿再切/恢复回来(resume)
当一个函数从当前线程A挂起后,就出现了两条线:

  1. A线程:该执行什么执行什么,比如刷新界面,或者没有其他事情可做就被回收再利用
  2. 挂起函数:切换到指定线程,并从被挂起的那行代码开始,继续向下执行代码,执行完以后,再切回到A线程 举个例子吧:
    安卓开发中,主线程Main正在执行任务,执行到一个网络请求N(挂起函数)时,N脱离主线程,到指定的IO线程做网络请求。 然后线程Main将继续渲染界面(比如转圈圈loading),等N在IO线程执行结束后,切回Main线程,拿着刚请求完的值,继续做后序操作(比如loading结束,显示请求回来的数据结果)

挂起函数问题

  1. 挂起的对象是协程
  2. 挂起函数只能在另一个挂起函数或者协程中被调用:why?
    因为,切走再恢复(resume)回来是协程的东西,所以只能在协程中调用

原理

好了,现在知道什么是suspend函数了,但是具体是实现或者它原理是什么样的呢?我们来慢慢捋一捋。
挂起函数就是在普通的函数前面加一个关键字suspend,然而Java 平台并没有 suspend 关键字,也没有 suspending 机制,那kotlin又是怎么实现了可以用看起来阻塞的方式实现不阻塞的代码的呢?

CPS 变换

Kotlin 编译器会对 suspending 方法做特殊处理,对代码进行转换,从而实现 suspending 机制。
那 Kotlin 编译器做了哪些处理?简单说,主要做了下面这三项处理:

处理一:增加 Continuation 类型入参,返回值变为 Any?
处理二:生成 Continuation 类型的匿名内部类
处理三:对 suspending 方法的调用变为 switch 形式的状态机
以上引用于作者: 编走编想

那我们来看看最根本的变化:

//挂起函数的函数签名
suspend fun <T> CompletableFuture<T>.await():

变化后

fun <T> CompletableFuture<T>.await(continuation: Continuation<T>): Any?

可以看到,编译器做了什么? 传入了一个Continuation并且返回类型变为了Any?

  • Continuation是什么?
    一个接口:Kotlin 的接口可以既包含抽象方法的声明也包含实现。与抽象类不同的是,接口无法保存状态。它可以有属性但必须声明为抽象或提供访问器实现。 (Kotlin interface官方解释
interface Continuation<in T> {
   val context: CoroutineContext
   fun resumeWith(result: Result<T>)
  • Any?是什么? 表示返回的类型可以是任意类型。
    那这里就有一个问题,比如一个函数,原本返回的是String, 为什么这里变成Any了?? 因为编译以后,除了要返回原本要返回的值,还要返回一个标记——COROUTINE_SUSPENDED,所以找不到一个对应的类型来和返回结果匹配,因此变成类一个Any?类型。 ?表示可以为空。

讲原理以前我们先看看怎么写回调: 正常打印一个6

private fun printSix() {
    println("6")
}

Kotlin中用回调实现:向函数中传入一个Printer,并且把具体的实现方式暴露给调用这个方法的人,

interface Printer{
    fun print()
}

private fun printSix(printer: Printer) {
    printer.print()
}

fun callPrintSix() {
    printSix(object : Printer {
        override fun print() {
        //具体的实现暴露给调用者
            println("6")
            Log.i("tag", "6")
            ...
        }
    })
}

那么知道Kotlin怎么写回调以后,再回过头看看我们suspend函数的变化:

suspend fun getCard(cardNum : String) {
    val card = api.getCard(cardNum)
}

经过编译器以后:

interface Continuation<in T> {
   val context: CoroutineContext
   fun resumeWith(result: Result<T>)
}
   
fun getCard(cardNum: Stringcontinuation: Continuation) {
    //new一个continuation
    val newContinuation = object: Continuation({
        override resumeWith(..) {
            getCard(this)  //将new出来的这个newContinuation传入getCard函数自己
            //这里看出来了吗,其实就很像是一个递归
        }
    })
    //状态机
    switch (newContinuation.label) {
        case 0:
          newContinuation.cardNum = cardNum
          newContinuation.label = 1
          return api.getCard(cardNum, newContinuation)
        case 2:
          val card = newContinuation.result
          return card
    }
}

其实仔细读会发现其实kotlin协程就是通过回调+递归+状态机实现了我们最终的能够用简单且同步的方式写出异步的代码。
小结:每个 suspend 方法编译后会增加一个 Continuation 类型的参数。每个 suspend函数都有一个回调自己的 Continuation 实现类,并且这个类会被传递给这个 suspend函数所调用的其它 suspending 方法,这些子方法可以通过 Continuation 回调父方法用来恢复。

cps (continuation-passing style)

续体编程风格,其实CPS就是一种编程风格,也就是一种回调风格。

  • 其中continuation是一个接口,里面包含了一个resume函数,可以通过这个函数将线程恢复到初始线程。比如一开始在Main线程,切到了IO线程去做请求,最后在Main函数调用continuation.resume()那么就会自然切回到main函数,就因为是Main调用的。
  • 根据上面的代码,我们会发现每次都会将父级的continuation传入子方法,依次这样一级一级传下去,如果return的结果没有之前说的那个标记COROUTINE_SUSPENDED, 说明执行已经结束了,那么将调用最初一层层带下去的父continuation.resume()并返回结果。 (对应到状态机中就是没有label这个值,如果还有后续步骤,就会继续return包含label和result的值) 以下是网上找的图

image.png

总结

本质:

  1. 协程本质就是线程框架,是线程上的一个代码块。没有创造新的东西。

  2. 通过编译器实现CPS变化,从而让开发人员可以以很简化的,同步的方式写出异步的代码。
    原理:

  3. 每个SUSPEND函数编译后都会有一个Continuation 类型的入参,用于实现回调。返回值变为 Any? 类型,既可以表示真实的结果,也可表示 Coroutine 的执行状态。

  4. 编译器会为这个 suspend方法生产一个类型为 Continuation 的匿名内部类(扩展 CoroutineImpl),用于对这个 suspend 方法自身的回调,并可以在这个 suspend 方法执行完毕之后,回调这个 suspend 方法上一级的父方法。

  5. 最后,这个 suspend方法如果调用其它 suspend方法,会将这些调用转换为一个 switch 形式的状态机,每个case表示对一个 suspend 子方法的调用或最后的return。同时,生成的 Continuation 匿名内部类会保存下一步需要调用的 suspending 方法的 label 值,表示应该执行 switch 中的哪个 case,从而串联起整个调用过程。