Kotlin-假如,你要设计一个协程库

2,509 阅读10分钟

引言

本文不分析Coroutine中复杂的源码流程,而是以一个全局的思想去看待Coroutine的设计,由于协程本身的复杂性,这里做一个抛砖引玉,不过于深入复杂的代码,而是想要获取里面精华的思想

从一段代码开始

   suspend fun getTest():String{
        sleep(5000) or 可以阻塞的任何操作
       // delay(500)
        Log.i("hellox","thread is ${Thread.currentThread()}")  2
        return "11"
    }
Main中:
CoroutineScope(Dispatchers.Main).launch {
    Log.i("hellox","Dispatchers thread is ${Thread.currentThread()}")
    getTest()
    Log.i("hellox","delay gone") 1
}
Log.i("hellox","main run") 3

大家可以猜猜看,1,2,3的输出顺序,答案是:3 -> 2 ->1,Thread.currentThread是main线程

接着我们再main的程序中再加入两个post任务

handler.post {
    Log.i("hellox"," post1  ${Thread.currentThread()}") 1
}
CoroutineScope(Dispatchers.Main).launch {
    Log.i("hellox","Dispatchers thread is ${Thread.currentThread()}")
    getTest()

    Log.i("hellox","delay gone") 2
}
handler.post {
    Log.i("hellox","post2  ${Thread.currentThread()}") 3
}

这个时候再猜猜看输出顺序 答案是 1 -2 - 3 Thread.currentThread还是main线程

这里就给出了一个答案,同时也纠正一下网上对Kotlin协程的误区: 误区1:有人getTest是一个阻塞任务的话,我们都说阻塞任务不应该放到主线程中,那么该为什么可以放在CoroutineScope(Dispatchers.Main)中,难道他不会阻塞主线程吗??很多人回答是他运行在子线程中。

解答:这是一个很严重的误区,在我们没有显式调用切换线程的操作的时候,协程还是运行在本身依托线程,怎么理解呢?我们把Dispatchers.Main设定为线程依托是主线程的话,那么后续的操作就都是在主线程,所以,一切的阻塞操作,都会阻塞主线程!是的,就算你声明为了一个suspend函数,只要你的操作是阻塞操作,例如例子上的sleep,就会阻塞到主线程,导致后续的任务无法继续执行,所以后续的 handler.post { Log.i("hellox","post2 ${Thread.currentThread()}") 3 }中的post接收操作handleMessage操作,才会一直等待着主线程完成sleep才有机会进行。

image.png

Kotlin协程,是真的可以写协作切换的吗

之所以引用Dispatchers.Main作为演示例子,是为了提出一个概念。有以下概念:

  1. 一个线程,如果运行在线程的任务发生了阻塞,我们会将这个任务先“拿出去”,运行下一个任务,不切换线程情况下。
  2. 一个线程,如果发生了阻塞,就立马把时间片让出去,给另一个线程使用
  3. 一个线程,如果发生了阻塞,就等本身的时间片耗尽,然后系统把cpu执行权让给其他线程

我们通常说的协程(包括其他语言的协程,比如py),其实是2,协程最初的作用是啥,最本质得作用其实是某个线程发生了阻塞,就通过某种机制,把自己的时间片自动切换给其他线程,达到了一个协作的目的。但是我们kotlin协程呢?其实不是的,他算一个弱化版的,因为我们没法发在语言上探知“阻塞”,就是本身的yield操作,也只是交给系统判断不是嘛!所以本质上Kotlin协程,其实是3。但是我们可以通过一些机制,实现1的效果,就是我接下来要说明的Dispatchers.Main,而Dispatchers.IO,或者其他的withContext操作,本质更像线程池的操作(接下来会讲到)。

我们再来改一下代码

    suspend fun getTest():String{
//        sleep(5000)
        delay(500)
        Log.i("hellox","thread is ${Thread.currentThread()}")
        return "test"
    }
handler.post {
    Log.i("hellox"," post1  ${Thread.currentThread()}") 1
}
CoroutineScope(Dispatchers.Main).launch {
    Log.i("hellox","Dispatchers thread is ${Thread.currentThread()}") 2
    getTest()

    Log.i("hellox","delay gone")3
}
Log.i("hellox","main run") 4
Log.i("hellox","main thread is ${Thread.currentThread()}")
handler.post {
    Log.i("hellox","post2  ${Thread.currentThread()}")5
}

上面的sleep操作替换为了kotlinx.coroutines 中的delay操作,我们惊讶的发现输出是4 - 1 -2 - 5- 3

结合上面的例子来看,就发现了很大不同,首先是getTest不再阻塞handler.post { Log.i("hellox","post2 ${Thread.currentThread()}")5 }的操作了,而是在执行suspend函数前进行了打印操作,然后接着执行了post里面的操作,最后又回到了suspend函数本身执行!这!是不是就是我们概念中的第一种形态!一个线程,如果运行在线程的任务发生了阻塞,我们会将这个任务先“拿出去”,运行下一个任务,不切换线程情况下

解析

虽然笔者很像在宏观角度去讲述这个原理,但是还是抱着严谨的态度,不泛泛而谈,所以给出部分字节码,用来代替本身的源码执行过程,希望在这个过程,我们能够找到答案:

getTest 的字节码

  public final getTest(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
  @Lorg/jetbrains/annotations/Nullable;() // invisible 【重点】
    // annotable parameter count: 1 (invisible)
    @Lorg/jetbrains/annotations/NotNull;() // invisible, parameter 0
    ALOAD 1
    INSTANCEOF com/example/newtestproject/MainActivity$getTest2$1
    IFEQ L0
    ALOAD 1
    CHECKCAST com/example/newtestproject/MainActivity$getTest2$1
    ASTORE 3
    ALOAD 3
    GETFIELD com/example/newtestproject/MainActivity$getTest2$1.label : I
    LDC -2147483648
    IAND
    IFEQ L0
    ALOAD 3
    DUP
    GETFIELD com/example/newtestproject/MainActivity$getTest2$1.label : I
    LDC -2147483648
    ISUB
    PUTFIELD com/example/newtestproject/MainActivity$getTest2$1.label : I
    GOTO L1
   L0
   FRAME SAME
    NEW com/example/newtestproject/MainActivity$getTest2$1
    DUP
    ALOAD 0
    ALOAD 1
    INVOKESPECIAL com/example/newtestproject/MainActivity$getTest2$1.<init> (Lcom/example/newtestproject/MainActivity;Lkotlin/coroutines/Continuation;)V
    ASTORE 3
   L1
   FRAME APPEND [T com/example/newtestproject/MainActivity$getTest2$1]
    ALOAD 3
    GETFIELD com/example/newtestproject/MainActivity$getTest2$1.result : Ljava/lang/Object;
    ASTORE 2
   L2
    INVOKESTATIC kotlin/coroutines/intrinsics/IntrinsicsKt.getCOROUTINE_SUSPENDED ()Ljava/lang/Object;
   L3
    LINENUMBER 53 L3
    ASTORE 4
    ALOAD 3
    GETFIELD com/example/newtestproject/MainActivity$getTest2$1.label : I
    TABLESWITCH
      0: L4
      1: L5
      default: L6
   L4
   FRAME FULL [com/example/newtestproject/MainActivity kotlin/coroutines/Continuation java/lang/Object com/example/newtestproject/MainActivity$getTest2$1 java/lang/Object] []
    ALOAD 2
    INVOKESTATIC kotlin/ResultKt.throwOnFailure (Ljava/lang/Object;)V
   L7
    LINENUMBER 55 L7
    LDC 500
    ALOAD 3
    ALOAD 3
    ICONST_1
    PUTFIELD com/example/newtestproject/MainActivity$getTest2$1.label : I
    INVOKESTATIC kotlinx/coroutines/DelayKt.delay (JLkotlin/coroutines/Continuation;)Ljava/lang/Object;
    DUP
    ALOAD 4
    IF_ACMPNE L8
   L9
    LINENUMBER 53 L9
    ALOAD 4
    ARETURN
   L5
   FRAME SAME
    ALOAD 2
    INVOKESTATIC kotlin/ResultKt.throwOnFailure (Ljava/lang/Object;)V
    ALOAD 2
   L8
    LINENUMBER 56 L8
   FRAME SAME1 java/lang/Object
    POP
    LDC "hellox"
    LDC "thread is "
    INVOKESTATIC java/lang/Thread.currentThread ()Ljava/lang/Thread;
    INVOKESTATIC kotlin/jvm/internal/Intrinsics.stringPlus (Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/String;
    INVOKESTATIC android/util/Log.i (Ljava/lang/String;Ljava/lang/String;)I
    POP
   L10
    LINENUMBER 57 L10
    LDC "11"
    ARETURN
   L6
   FRAME SAME
    NEW java/lang/IllegalStateException
    DUP
    LDC "call to 'resume' before 'invoke' with coroutine"
    INVOKESPECIAL java/lang/IllegalStateException.<init> (Ljava/lang/String;)V
    ATHROW
    LOCALVARIABLE $continuation Lkotlin/coroutines/Continuation; L1 L6 3
    LOCALVARIABLE $result Ljava/lang/Object; L2 L6 2
    MAXSTACK = 5
    MAXLOCALS = 5

还有main本身的字节码,这里去掉不重要的部分,只关注一个点

 final static INNERCLASS com/example/newtestproject/MainActivity$getTest$1 null null

我们惊讶的发现,在声明suspend函数的类,这里是MainActivity,居然多了一个内部类MainActivity$getTest$1,还有就是原本的getTest函数,现在变成了public final getTest(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;这个样子!函数列表居然多了一个参数Continuation(这里就解释了,为什么suspend函数不能被普通的函数调用起来,因为普通函数根本就没有这个生成的Continuation参数,为什么需要这么一个对象,是因为协程运行过程中必须要记录一些内部数据对不对,所以就用这个对象记录了,这也是为什么kotlin 协程可以被称为无栈协程,ps:有争议噢),而且原本返回值是String(返回值被Continuation中的泛型标记,拿到结果会转换,内部的resume函数会传入该结果),居然变成了Ljava/lang/Object!(这里解释一下为什么返回object类型,因为协程体的执行有可能不会挂起,比如直接返回了结果数值,又或者是挂起了,返回当前的状态,所以才是object。所以这里就有两个含义了,一个是函数的直接返回,一个是状态机内部的状态体现,请仔细思考噢)这里给出getTest编译后的签名signature (Lkotlin/coroutines/Continuation<Ljava/lang/String;>;)Ljava/lang/Object;

编译器背后

看到上面的变化,其实这一切都是编译器默默帮我们完成的,suspend关键字就是让函数多了一个名为Continuation<返回值类型>参数,这个,就是kotlin协程中背后的状态机的关键!这里不去解决背后复杂代码的实现,而是以一个设计师的角度去让大家思考一下,如果我们自己想要设计一个概念1(上文)的操作的话,我们自己会怎么做?

  1. 我们是不是需要一个表示状态,如果处于阻塞状态的任务的话,我们就将该任务标记为可拿掉状态,假如我们把这个任务标记为Suspend状态,那么如果这个任务完成了呢?我们要不就标记为Resume状态吧,还有嘛?假如Resume操作完了,我们是不是要再标记一个状态,表面这个任务是真的完成了,我们不妨叫做Complied状态吧,这样我们就有三个状态啦Suspend-Resume-Complied,最后在多加一个,比如有种任务是一开始就可以立即完成的,我们不妨就叫做init状态吧,作为所以状态的开始。是不是就有以下状态流转了呢!

image.png 这里我们蜻蜓点水说明一下协程基本思想就好,其实这些状态的流转,就靠着Continuation进行,当然,kotlin协程还有很多内部细节,我们这里不深究,只要明白,如果我们想要默默的进行状态流转的话,是不是就需要一个类似Continuation的对象,不然我们怎么知道什么时候有结果,什么时候拿不到结果需要等待对吧!

CoroutineScope(Dispatchers.Main).launch究竟做了啥

按照上面的讲解,我们知道了需要一个对象去流转状态对吧,那么状态流转后我们还需要干什么?当然是对应状态的执行啦!Dispatchers.Main表明我们需要在主线程提交一个东西,那么我们想想看,拿什么去提交呢?没错,Android就是handler呀!所以本质上,我们就是用handler向主线程post了一个任务!任务的内容是啥!是不是就是我们lauch后面的lambed表示式的内容!具体细节可与看下(DispatchedTask,HandlerDispatcher)这里纠结源码不是我们的目的,而是思路。明白这里的同学就可以明白例子1为什么是这样输出结果的。

delay跟sleep本质

我们再来讨论一下,为什么例子2中的sleep会阻塞任务队列的任务进行,而delay却不会呢?明明都是suspend修饰的函数?其实是不是suspend修饰的函数根本不重要,重要的是有没有用到suspend函数提供的continuatin对象!sleep这个函数是不是就没有用到呢对吧(本身就不是coroutine里面的函数,没有适配),所以我们协程框架压根就不知道啥时候发生了阻塞,状态就只能是从init - Completed,期间有阻塞我们就只能等待了!但是delay就不一样了,delay就是适配了continuatin对象!,我们看下源码:

public suspend fun delay(timeMillis: Long) {
    if (timeMillis <= 0) return // don't delay
    return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
        // if timeMillis == Long.MAX_VALUE then just wait forever like awaitCancellation, don't schedule.
        if (timeMillis < Long.MAX_VALUE) {
            cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
        }
    }
}

CancellableContinuation就是Continuation对象的子类!(编译时还做了操作,可以看看上面的字节码)因为编译器还做了额外操作,我们简单理解为其实就用到了Continuation即可,有了Continuation对象,我们是不是就有了一个途径可以从init状态转变为suspend状态了!这样在suspend阶段的时候,我们执行其他任务是不是就可以了呢!就像概念1所说的!那么我们怎么执行其他任务呀,delay是怎么把自己拿掉,执行其他任务从而不导致阻塞的呢?没错,还是handler。抛开复杂的流程,最后会执行到这里:

IZOSr6C7x4.jpg 里面的postDelay操作就是delay的核心啦!我们再小结一下,以例子3来说就是,当执行到launch的时候,我们post了一个任务,代表着协程开始执行,当发现有suspend函数并且该suspend函数用到了Continuation并将状态切换为Suspend的时候,我们又post了一个消息到主队列!所以为什么例子3的输出结果会是这样!

其他Disptahcher

那么如果我们需要切换其他线程的情况呢?Kotlin协程对这一块的处理又是怎么样的呢?其实就是概念3的处理!我们也可以简单的设计一下:

image.png 我们把协程体的任务称为task的话,就有如下流程(只是设计概要,kotlin协程有更加细节的逻辑):

  1. task1来的时候,如果就用当前线程池的某个线程,比如线程1去处理,同时可以标记当前状态为suspend,如果处理完了,我们就标记为complied,更Dispatcher.main的设计一样。
  2. task2来的时候,如果当前状态是suspend,我们就可以开另一个线程处理,重复1
  3. 如果是compiled,就继续用线程1,就不用新开线程去处理了,节约资源

但是实际上,kotlin每个协程体都是单线程模型的,就算是Dispatcher.IO,在内部不切换协程/新建子协程情况下,都是一串行的,只有我们手动切换,比如说withContext操作才能够像上诉我们设计的流程一样进行。withContext内部就可以依靠着线程池,做到究竟要不要复用线程!只是我们在代码声明一个切换操作。

总结

相信看到这里,读者们已经对Kotlin协程有个全角度的认识了!具体的细节部分,还是需要多翻看源码,这里主要的目的,是想透露出代码最本质的设计思想,希望对你有帮助!

link:juejin.cn/post/710008… 如果你想对字节码有更多的了解,欢迎看看gradle配置即可执行的hook库实现!