Kotlin协程那些事儿-基础篇

1,002 阅读18分钟

screenshot-20220217-172919.png

什么是异步程序

什么异步?

同步:一定要等任务执行完了,得到结果才能执行下一个任务

异步:不等任务执行完,直接执行下一个任务

常见异步程序的设计思路

与同步程序相比,异步程序的设计复杂度往往更高,通常在同步程序中能够轻易实现的功能在异步程序中却面临很大的挑战。

Future

Future是JDK1.5版本时就引入的接口。它有一个get方法,能够同步阻塞的返回Future对应的异步任务的结果。

我们看线程池源码ExecutorService.submit()方法时,可以看到,它返回了一个Future类型,一个Future类型的实例代表一个未来能获取结果的对象:

ExecutorService executor = Executors.newFixedThreadPool(4); 
// 定义任务:
Callable<String> task = new Task();
// 提交任务并获得Future:
Future<String> future = executor.submit(task);
// 从Future获取异步执行返回的结果:
String result = future.get(); // 可能阻塞

如上代码,当我们提交一个Callable对象时,我们同时会获取到一个Future对象,我们在主线程的某个时刻调用Future对象的get()方法,就可以获得异步执行的结果。在调用get()时,如果异步任务已完成,我们将直接获取结果,如果异步任务还没有完成,那么get()将会阻塞,直到任务完成后才会返回结果。

一个Future接口表示一个未来可能会返回的结果,它定义的方法有:

  • get():获取结果(可能会等待)
  • get(long timeout, TimeUnit unit):获取结果,但只等待指定的时间;
  • cancel(boolean mayInterruptIfRunning):取消当前任务;
  • isDone():判断任务是否已完成。

CompletableFuture

使用Future获取异步执行结果时,要么调用阻塞方法get(),要么轮询看isDone()方法返回是否为true,这两种方式都不太好,因为主线程都要被迫等待。

从java1.8开始引入CompletableFuture,它针对Future做了改进,可以传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法。

Promise与async/await

Promise是一个异步任务,它存在挂起,完成,拒绝三个状态,当它处在完成状态时,结果通过调用then方法的参数进行回调;出现异常拒绝时,通过catch方法传入的参数来捕获拒绝的原因。

从ECMAScript 6开始,JavaScript就已经支持Promise了,我们来看如下代码:

function bitmapPromise(url) {
return new Promise(resolve, reject) => {
        try {
            downLoad(url, resolve)
        } catch (e) {
            reject(e)
        }
    })
}

const urls = ....;  //此处省略url的获取

Promise.all(urls.map(url => bitmapPromise(url)))
.then(bitmaps => console.log(bitmaps))
.catch(e => console.error(e))

如上代码看出,我们通过 bitmapPromise 函数创建 Promise 实例,后者接收一个Lambda表达式,这个Lambda表达式有两个参数,resolvereject,分别对应完成拒绝状态的回调。其中resolve会作为参数传递给downLoad函数,在图片获取完成之后回调。

Promise.all会将对歌Promise整合到一起,这和CompletableFuture定的List<CompletableFuture<T>>.allOf如出一辙。最终我们得到一个新的Promise,它的结果是整合了前面所有的bitmapPromise函数返回的结果bitmaps,因此我们在then当中传入的Lambda表达式就是用来处理消费这个bitmaps的。

什么是响应式编程?

响应式编程(Reactive Programming)主要关注的是数据流的变换和流转,因此它更注重描述数据输入和输出之间的关系。输入和输出之间用函数变换来连接,函数也只对输入和输出负责,因此我们可以很轻松的通过将这些函数调用分发到其他线程上的方法来实现异步。Rxjava就是这样一个很好的例子。

Observable.just("...")
.map{download(it)}
.subscribeOn([Schedulers.io](http://Schedulers.io)())
.subscribe({bitmap-> ...}, {throwable -> ...})

上述代码看上去逻辑似乎与前面的Promise没有太大区别,对于只有一个元素输入的例子,Rxjava提供了一个更适合也更像Promise的API,叫做Single

进程和线程

什么是进程?

我们在背进程的定义时,可能会经常看到一句话

进程时资源分配的最小单位

这个资源分配怎么理解呢? 在单核CPU中,同一时刻只有一个程序在内存中被CPU调用运行。

假设有 A, B 两个程序,A正在运行,此时需要读取大量输入的数据(IO 操作),那么CPU只能干等着,直到A数据读取完毕,再继续往下执行,A执行完,再去执行程序B,白白浪费CPU资源。

进程的定义

我们知道,程序并不能单独运行,必须要将程序装载到内存中,系统为它分配资源才能运行,而执行该程序的就称之为进程。进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调用的独立单位,是应用程序的载体。

为什么要有进程

操作系统之所以要支持多进程,主要是为了提高CPU的利用率,而为了切换进程,需要进程支持挂起和恢复。不通进程间需要的资源不通,所以这也是为什么进程间资源需要隔离,也是进程是资源分配的最小单位的原因。

什么是线程?

线程是CPU调度的最小单位,亦或是 程序执行过程中的最小单元,由 线程ID程序计数器寄存器组合堆栈共同组成。

线程的引入大大减小了程序并发执行时的开销,提高了操作系统的并发性能。

为什么要有线程?

这个问题也很好理解,进程的出现使得多个程序得以并发执行,提高了系统效率以及资源利用率,但存在以下问题:

  • 单个进程只能干一件事,进程中的代码依旧是串行执行。
  • 执行过程如果堵塞,整个进程就会挂起,即使进程中某些工作不依赖于正在等待的资源。也不会执行。
  • 多个进程间的内存无法共享,进程间通讯比较麻烦。

线程的出现是为了降低上下文切换消耗,提高系统的并发性,并突破一个进程只能干一件事的缺陷,使得进程的并发成为可能。

线程和进程的区别

做一个简单的比喻:进程 = 火车,线程 = 车厢

线程在进程下行进(单纯的车厢无法运行)

一个进程可以包含多个线程(一辆火车可以有多个车厢,春节有可能会多加车厢)

不通进程间数据很难共享(一辆火车上的乘客很难换到另外一辆火车,比如站点换乘)

同一进程下的不通线程间数据很容易共享(不通车厢来回走动很容易)

进程要比线程消耗更多的计算机资源(采用多列火车的方式比多加几节车厢更消耗资源)

进程间不会相互影响,一个线程挂掉有可能导致整个进程挂掉(一列火车不会影响,但是如果一列火车一节车厢着火了,将影响到所有车厢)

进程使用的内存地址可以上锁,即一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存(比如火车上的洗手间)- “互斥锁”

进程使用的内存地址可以限定使用量(比如火车上的餐厅,最多允许多少人进入,如果满了需要在门口等,等有人出来了才能进去)- “信号量”

协程的基本概念

协程究竟是什么?

协程概念的最核心的点就是函数或者一段程序能够被挂起,稍后再在挂起点为止恢复。挂起和恢复是开发者的程序逻辑自己控制的,协程是通过主动挂起出让运行权来实现协作的,因此它本质上就是在讨论程序控制流程的机制,这是最核心的点,任何场景下探讨协程都能落实到挂起恢复

总结一下就是:协程是一种非抢占式(协作式)的任务调度模式,程序可以主动挂起恢复.

什么是抢占式,什么是协作式?

多任务处理是在一段时间内同时执行多个任务或进程的方法。抢占式和协作式多任务处理是多任务处理的两种方式。

抢占式任务

在抢占式多任务中,操作系统可以启动从正在运行的进程到另外一个进程的上下文切换。换句话说,操作系统允许停止当前正在运行的进程的执行。并将CPU分配给其他进程。操作系统使用一些标准来决定一个进程在允许另一个进程使用操作系统之前应该执行多长时间。从一个进程获取操作系统的控制权并将其交给另外一个进程的机制称为抢占。

协作式多任务

在协作式多任务中,操作系统从不启动从正在运行的进程到另外一个进程的上下文切换。仅当当前进程自愿出让控制权或空闲或者进程堵塞以允许多个应用程序同时执行时,才会发生上下文切换。此外,在这种多任务处理中,所有进程协作使得调度方案起作用。

我们通过图表来看看两者详细的区别

抢先式多任务合作多任务
1   抢占式多任务是操作系统用来决定在允许另一个任务使用操作系统之前任务应该执行多长时间的任务。协作多任务处理是一种计算机多任务处理,其中操作系统从不启动从正在运行的进程到另一个进程的上下文切换。
2   它中断应用程序并将控制权交给应用程序控制之外的其他进程。   在协作多任务处理中,进程调度程序永远不会意外中断进程。
3   操作系统可以启动从一个正在运行的进程到另一个进程的上下文切换。操作系统不会启动从正在运行的进程到另一个进程的上下文切换。
4   恶意程序启动无限循环,它只会伤害自己,而不会影响其他程序或线程。恶意程序可以通过忙于等待或运行无限循环而不放弃控制来使整个系统停止。
5   抢占式多任务处理强制应用程序共享 CPU,无论它们是否愿意。在协作式多任务处理中,所有程序都必须协作才能工作。如果一个程序不合作,它可能会占用 CPU。   

来源:Difference between Preemptive and Cooperative Multitasking

协程的分类

在此前我们需要先了解清楚什么是调用栈(Call Stack)

Call Stack(通常译作“调用栈”)是计算机系统中的一个重要概念。在计算机程序中,一个 procedure(通常译作“过程”)吃进去一个参数,干一些事情,再吐出去一个返回值(或者什么都不吐)。我们熟悉的function、method、handler等等其实都是procedure

当一个procedure A 调用另一个 procedure B的时候,计算机其实需要干好几件事。

  • 转移控制 —— 计算机要暂停 A 并开始执行 B,并让 B 在执行完之后还能回到 A 继续执行。

  • 转移数据 —— A 要能够传递参数给 B,并且 B 也能返回值给 A。

  • 分配和释放内存 —— 在 B 开始执行时为它的局部变量分配内存,并在 B 返回时释放这部分内存。

我们可以想象一下,假设 A 调用 B,B 再调用 C,C 执行完返回给 B,B 再执行完返回给 A,哪种数据结构最适合管理它们所使用的内存?没错,是 stack,因为过程调用具有 last-in first-out(后进先出) 的特点。当 A 调用 B 的时候,A 只要将它需要传递给 B 的参数 push 进这个 stack,再把将来 B 返回之后 A 应当继续执行的指令的地址(学名叫 return address)也 push 进这个 stack,就万事大吉了。之后 B 可以继续在这个 stack 上面保存一些寄存器的值,分配局部变量,进而继续构造调用 C 时需要传递的参数等等。

这个 stack 其实就是我们所说的 call stack。

按调用栈分类

由于协程需要支持挂起、恢复,因此对于挂起点的状态保存就显得极其关键。类似的,线程会因为 CPU 的调度权的切换而被中断,它的中断状态会保存在调用栈中,因而协程的实现也是按照是否开辟相应的调用栈存在以下两种类型:

  • 有栈协程「Stackful Coroutine」

每一个协程都有自己的调用栈,有点类似线程的调用栈,这种情况下的协程实现其实很大程度上接近线程,不同点主要体现在调度上。

  • 无栈协程「Stackless Coroutine」

协程没有自己的调用栈,挂起点的状态通过状态机或者闭包等语法来实现。

有栈协程的有点事可以在任意函数调用层级的任意位置进行挂起,并转移调度权。  

按调用方式分类

调度过程中,根据协程转移调度权的目标又将协程分为对称协程非对称协程

  • 对称协程「Symmetric Coroutine」

任何一个协程都是相互独立且平等的,调度权可以在任意协程之前转移。

  • 非对称协程「Asymmetric Coroutine」

协程出让调度权的目标只能是它的调用者,即协程之前存在调用和被调用关系

协程和线程的区别

协程和线程最大的区别在于,从任务的角度来看,线程一旦开始执行就不会暂停,直到任务结束,这个过程是连续的。线程之间是抢占式的调度,因此不存在协作的问题。

Kotlin协程是什么?

Kotlin协程并不是一个的协程,Kotlin协程是运行在线程中的,在单线程中使用协程,比不适用协程的耗时并不会少。

Kotlin协程的基础设施

怎样创建协程?

在Kotlin中创建一个简单协程并不是什么难事,标准库中提供了一个createCoroutine函数,我们可以通过它来创建协程,如下代码:

fun testCoroutine() {
    val continuation : Continuation<Unit> = suspend {
        println("In Coroutine")
        19
    }.createCoroutine(object : Continuation<Int> {
        override fun resumeWith(result: Result<Int>) {
            println("Coroutine End: $result")
        }
        override val context: CoroutineContext = EmptyCoroutineContext
    })
    continuation.resume(Unit) //执行已创建好的协程
}

fun main() {
    testCoroutine()
}

------------println------------

In Coroutine
Coroutine End: Success(19)

Process finished with exit code 0

我们来看看createCoroutine的声明:

fun <T> (suspend () -> T).createCoroutine(
completion: kotlin.coroutines.Continuation<T>
): Continuation<kotlin.Unit> { /* compiled code */ }

可以看到, suspend()->createCoroutine函数的Receiver。

 1> Receiver是一个被suspend修饰的挂起函数,这也是协程的执行体。

 2> 通过println可以看出,参数completion会在协程执行完成后调用,实际上就是协程的完成回调

 3> 返回值是一个Continuation对象,由于现在协程仅仅被创建出来,因此需要通过这个值在之后出发协程的启动,我们通过continuation.resume(Unit)执行已创建好的协程。

协程的启动

其实在协程的创建中,我们已经执行了协程的启动,调用continuation.resume(Unit)之后,协程辉立即开始执行。 我们深入了解一下这个返回的Continuation实例,带着疑问来了解一下,为什么调用它的resume就会触发协程体的执行呢?它们二者之间有什么关系?

通过打断点调试,我们可以得知continuationSafeContinuation的实例,不过可不要被它安全的外表骗了,它其实只是一个“马甲”。

它有一个名为delegate的属性,这个属性才是Continuation的本体。而这个本体就是我们的协程体,那个用于创建协程的suspend Lambda表达式.编译器在编译之后会稍加改动,生成一个匿名内部类,这个类继承自SuspendLambda类,而这个类又是Continuation接口的实现类。

如上图看就比较清晰了,创建协程返回的Continuation实例就是套了几层马甲的协程体,因而调用它的resume就可以触发协程体的执行。

一般来说,我们创建协程后就会立即让它开始执行,因此标准库提供了一个一步到位的API-startCoroutine。它与createCoroutine除了返回值类型不同之外,剩下的完全一致。

fun <T> (suspend() -> T).startCoroutine(completion: Continuation<T>)

什么是挂起函数?

我们知道使用 suspend关键字修饰的函数叫做挂起函数,挂起函数只能在协程体或者其他挂起函数内调用。这样一来,Kotlin语言体系中的函数就分为两种:普通函数和挂起函数,其中挂起函数可以调用任何函数,普通函数只能调用普通函数。


suspend fun suspendFunc1(a: Int){
    return
}

suspend fun suspendFunc2(a: String, b: String) {
    suspendCoroutine<Int> { continuation ->
        thread {
            continuation.resumeWith(Result.success(5))。  //1
        }
    }
}

通过以上两个挂起函数,我们发现挂起函数既可以像普通函数那样同步返回(如suspendFunc1),也可以处理异步逻辑(如suspendFunc2),既然是函数,它们也有自己的函数类型,依次为:suspend(Int)->Unitsuspend(String, String) -> Int

suspendFunc2定义中,我们使用了suspendCoroutine<T>获取当前所在协程体的Continuation<T>的实例作为参数将刮起函数当成异步函数来处理,在代码1处创建线程执行continuation.resumeWith操作,因此协程调用suspendFunc2无法同步执行,会进入挂起状态,直到结果返回。

挂起函数并不一定会真的挂起,所谓协程的挂起实际就是程序执行流程发生异步调用时,当前调用流程的执行状态进入等待状态。也就是挂起点

什么是挂起点?

在协程的创建和运行过程中,我们的协程本身就是一个Continuation实例,正因如此挂起函数才能在协程体内运行。在协程内部挂起函数的调用被称为挂起点挂起点如果出现异步调用,那么当前协程就会被挂起,直到对应的Continuationresume 函数被调用才会恢复执行。

异步调用是否发生,取决于 resume 函数与对应的挂起函数的调用是否在相同的调用栈上,切换函数调用栈的方法可以是切换到其他线程上执行,也可以不切换线程但在当前函数返回之后的某一个时刻再执行

理解suspend关键字

suspend用于修饰会被挂起的函数,被标记为 suspend的函数只能运行在协程或者其他 suspend 函数当中。

声明一个 suspend 函数并调用

//声明
suspend fun test(): String {
    withContext(Dispatchers.IO) {
        delay(1000L)
    }
    return "testSuspend"
}
//调用
suspend fun main() {
    test()
}

我们看看反编译的代码:


final class AAAKt$$$main extends Lambda implements Function1 {
   public final Object invoke(Object var1) {
      return AAAKt.main((Continuation)var1);
   }
}

public final class AAAKt {
   @Nullable
   public static final Object test(@NotNull Continuation var0) {
      Object $continuation;
	  ...
      return "testSuspend";
   }
   public static void main(String[] var0) {
      RunSuspendKt.runSuspend(new AAAKt$$$main(var0));
   }
}

Java代码的调用

public static class TestClass {
        void javaTest() {
            AAAKt.test(new Continuation<String>() {
                @NotNull
                @Override
                public CoroutineContext getContext() {
                    return EmptyCoroutineContext.INSTANCE;
                }

                @Override
                public void resumeWith(@NotNull Object o) {
                    System.out.println("JavaTest object:" + o);
                }
            });
        }

    }

通过上述代码可以看出

编译器会给标记为suspend的挂起函数增加 Continuation 参数,这被称为CPS(Continuation-Passing-Style Transformation),通过Continuation来控制异步调用流程的。

CPS转换是什么?

CPS变换(Continuation-Passing-Style Transformation),是通过传递 Continuation 来控制异步调用流程的。

我们来想象一下,程序被挂起时,最关键的是要保存挂起点。线程也类似,它被中断时,中断点就是被保存在调用栈中的。

Kotlin协程挂起时就将挂起点的信息保存到了 Continuation 对象中。Continuation 携带了协程继续执行所需要的上下文,恢复执行的时候只需要执行它的恢复调用并且把需要的参数或异常传入即可。Continuation 占用的内存非常小,这也是无栈协程能够流行的一个原因

我们回想一下:为什么Kotlin语法要求挂起函数一定要运行在协程体内或者其他挂起函数中呢?答案就是:任何一个协程体或者挂起函数中都有一个隐含的 Continuation 实例,编译器能够对这个实例进行正确传递,并将这个细节隐藏在协程的背后,让我们的异步代码看起来像同步代码一样。

Coutinuation是什么?

Continuationcontinue的名词,我们知道continue是继续的意思,那么放在程序中,Continuation就代表的是继续执行代码接下来要执行的代码剩下的代码

我们以下边的代码为例,当程序执行getUserInfo()的时候,它的 Continuation 则是下图红框中的代码:

image.png

Continuation 就是接下来要运行的代码,剩余未执行的代码。

image.png

理解了 Continuation,以后,CPS 就容易理解了,它其实就是:将程序接下来要执行的代码进行传递的一种模式。CPS 转换,就是将原本的同步挂起函数转换成CallBack 异步代码的过程。这个转换是编译器在背后做的,我们程序员对此无感知。

状态机是什么?

Kotlin协程的实现依赖状态机。

我们通过一段协程反编译后的代码来了解协程的执行过程以及状态机的流转。

为了方便理解,接下来我贴出的代码是我用 java 翻译后大致等价的代码,改善了可读性,抹掉了不必要的细节。方便我们阅读。 testCoroutine反编译前的代码如下:


suspend fun testCoroutine() {
    println("start")
    val user = getUserInfo()
    println("println-----$user")
    val friend = getFriend(user)
    println("println-----$friend")
    val feed = getFeed(friend)
    println("println-----$feed")
}

suspend fun getUserInfo(): String {
    delay(1000L)
    return "user"
}

suspend fun getFriend(user: String): String {
    delay(500L)
    return "$user and friend"
}

suspend fun getFeed(friend: String): String {
    delay(200L)
    return "$friend and feed";
}

反编译后,testCoroutine函数的签名变成了这样:

 public static final Object testCoroutine(Continuation completion) {
	//协程返回的结果
    Object result = null;
    //表示协程状态机当前的状态
    int label;
    ...
    return result;
 }

由于其他几个函数也是挂起函数,所以它们的函数签名也都变成了如下:

  public static final Object getUserInfo(@NotNull Continuation var0) {
        //协程返回的结果
        Object result = null;
        //表示协程状态机当前的状态
        int label;
        ...
	return result;

    }

    public static final Object getFriend(@NotNull String user, @NotNull Continuation var1) {
        //协程返回的结果
        Object result = null;
        //表示协程状态机当前的状态
        int label;
        ...
	return result;
    }

我们从简单入手,分析一下getFeed()的函数体实现,下述代码就是getFeed()优化过的反编译代码:

public static final Object getFeed(@NotNull String friend, @NotNull Continuation completion) {
        //协程返回的结果
        Object result;

        //表示协程状态机当前的状态
        int label;

        //CoroutineSingletons 是个枚举类,`COROUTINE_SUSPEND`代表当前函数被挂起了
        Object suspendFlag = IntrinsicsKt.getCOROUTINE_SUSPENDED();

        Continuation $continuation = new ContinuationImpl(completion) {
            // invokeSuspend 是协程的关键
            // 状态机相关代码就是后面的 when 语句
            // 协程的本质,可以说就是 CPS + 状态机
            public final Object invokeSuspend(@NotNull Object $result) {
                result = $result;
                label |= Integer.MIN_VALUE;
                return TestKt.getFeed((String) null, this);
            }
        };

        if (completion instanceof Continuation) {
            $continuation = completion;
            if ((label & Integer.MIN_VALUE) !=0){
                label -= Integer.MIN_VALUE;

                Object $result = result;

                switch (label){
                    //label 默认从0开始,开始第一次的状态机改变
                    case 0:
                        //检查异常
                        ResultKt.throwOnFailure($result);
                        //给协程返回的结果值赋值我们传入的`friend`的值,
                        result = friend;
                        //将 label 设置为1,准备进行下一次状态
                        label = 1;
                        //启动一个延迟200L的操作,因为`delay`是`suspend`函数,所以需要传入我们创建的`Continuation`,也就是`$continuation`,通过`delay`函数得到一个新的`Continuation`,
                        Object delayObj = DelayKt.delay(200L, $continuation);
                        //判断是否挂起,如果我们的`delayObj`是`IntrinsicsKt.getCOROUTINE_SUSPENDED() `,代表这个function处在suspend的状态,意味着它可能还在进行耗时操作,此时就`suspend Coroutine`继续挂起,待到下次被唤醒再从`Coroutine`被`suspend` 的地方继续执行
                        if (delayObj == suspendFlag) {
                            //此处有个问题,既然我们都执行了`return suspendFlag`,我们都return了状态,代表它已经结束了,那它是怎么继续执行的呢?而且还有办法在执行完后告诉原先的invoke它的`Coroutine`做完了?原因就是
                            return suspendFlag;
                        }
                        break;
                    case 1:
                        friend = (String) result;
                        ResultKt.throwOnFailure($result);
                        break;
                    default:
                        throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
                }

                return friend + " and feed";
            }
        }
        return result;
    }

代码中有详细的解释,我们只总结一下:

  • switch表达式实现了协程状态机切换,也就是协程的切换。

  • 被suspend修饰的函数均会创建一个Continuation实体类。

  • 协程内部是通过状态机才实现的挂起恢复的,并且利用状态机来记录协程执行的状态,并存储在Continuation中。

  • 多个suspend函数调用传递的是同一个Continuation实例。

  • 一个函数如果被挂起了,它的返回值会是: CoroutineSingletons.COROUTINE_SUSPENDED

  • IntrinsicsKt.getCOROUTINE_SUSPENDED() 代表了当前函数的状态是否是挂起(正在进行耗时操作)。此时会直接return挂起状态,再重新执行invokeSuspend(),等待下一次被唤醒。

  • invokeSuspend()的执行是从初始化开始,也就是case 0做为初始化。

  • continuationlabel是状态流转的关键。

  • continuationlabel的值改变一次,就代表协程切换了一次。默认为0

  • 每次协程切换后,都会检查是否发生了异常。

另外我们可以通过状态机流转动画来看看执行流程

56ba74c4febf4140a26174eac73e1880_tplv-k3u1fbpfcp-watermark.awebp

关于状态机更多实现可以参考以下文章

CoroutineContext是什么?

CoroutineContext是协程的上下文,主要用于提供协程启动和运行时需要的信息。

CoroutineContext是一个特殊的集合,该集合既有Map的特点,也有Set的特点,集合的每一个元素都是Element,每个Element都有一个Key与之对应,对于相同的keyElement是不可以重复存在的。

Element之间可以通过 + 号组合起来。CoroutineContext主要由以下类组成:

  • Job: 协程的唯一标识,用于控制协程的生命周期,包括(new、active、completing、completed、cancelling、cancelled)。

  • CoroutineDispatcher: 协程运行的线程,包括(IO、Default、Main、Unconfined)。

  • CoroutineName: 协程的名称,默认为coroutine

  • CoroutineExpcetionHandler: 协程的异常处理器,用来处理未捕获的异常。

CoroutineContext的结构

我们来看看CoroutineContext的全家福:

image.png

上述的四个Element,每一个Element都继承自CoroutineContext,而每一个Element都可以通过 + 号来组合,也可以通过类似map[key] 来取值,这和CoroutineContext运算符重载逻辑和它的结构实现CombinedContext有关。

CoroutineContext

我先来看一下CoroutineContext类:


public interface CoroutineContext {
   
    //操作符[]重载,可以通过CoroutineContext[Key]这种形式来获取与Key关联的Element
    public operator fun <E : Element> get(key: Key<E>): E?

    //它是一个聚集函数,提供了从left到right遍历CoroutineContext中每一个Element的能力,并对每一个Element做operation操作
    public fun <R> fold(initial: R, operation: (R, Element) -> R): R

    //操作符+重载,可以CoroutineContext + CoroutineContext这种形式把两个CoroutineContext合并成一个
    public operator fun plus(context: CoroutineContext): CoroutineContext
    
    //返回一个新的CoroutineContext,这个CoroutineContext删除了Key对应的Element
    public fun minusKey(key: Key<*>): CoroutineContext
  
    //Key定义,空实现,仅仅做一个标识
    public interface Key<E : Element>

   //Element定义,每个Element都是一个CoroutineContext
    public interface Element : CoroutineContext {
    	. . .   
    }
}

除了plus方法,CoroutineContext中的其他三个方法都被CombinedContext、Element、EmptyCoroutineContext重写,CombinedContext就是CoroutineContext集合结构的实现,它里面是一个递归定义,Element就是CombinedContext中的元素,而EmptyCoroutineContext就表示一个空的CoroutineContext,它里面是空实现。

CombinedContext

我们来看看CombinedContext 类:

//CombinedContext只包含left和element两个成员:left可能为CombinedContext或Element实例,而element就是Element实例
internal class CombinedContext(
    private val left: CoroutineContext,
    private val element: Element
) : CoroutineContext, Serializable {
  
      //CombinedContext的get操作的逻辑是:
      //1、先看element是否是匹配,如果匹配,那么element就是需要找的元素,返回element,否则说明要找的元素在left中,继续从left开始找,根据left是CombinedContext还是Element转到2或3
      //2、如果left又是一个CombinedContext,那么重复1
      //3、如果left是Element,那么调用它的get方法返回
      override fun <E : Element> get(key: Key<E>): E? {
        var cur = this
        while (true) {
            //1
            cur.element[key]?.let { return it }
            val next = cur.left
            if (next is CombinedContext) {//2
                cur = next
            } else {//3
                return next[key]
            }
        }
    }

    //CombinedContext的fold操作的逻辑是:先对left做fold操作,把left做完fold操作的的返回结果和element做operation操作
    public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
        operation(left.fold(initial, operation), element)

    //CombinedContext的minusKey操作的逻辑是:
    //1、先看element是否是匹配,如果匹配,那么element就是需要删除的元素,返回left,否则说明要删除的元素在left中,继续从left中删除对应的元素,根据left是否删除了要删除的元素转到2或3或4
    //2、如果left中不存在要删除的元素,那么当前CombinedContext就不存在要删除的元素,直接返回当前CombinedContext实例就行
    //3、如果left中存在要删除的元素,删除了这个元素后,left变为了空,那么直接返回当前CombinedContext的element就行
    //4、如果left中存在要删除的元素,删除了这个元素后,left不为空,那么组合一个新的CombinedContext返回
    public override fun minusKey(key: Key<*>): CoroutineContext {
      	//1
        element[key]?.let { return left }
        val newLeft = left.minusKey(key)
        return when {
            newLeft === left -> this//2
            newLeft === EmptyCoroutineContext -> element//3
            else -> CombinedContext(newLeft, element)//4
        }
    }
  
  //...
}

可以发现CombinedContext中的get、fold、minusKey操作都是递归形式的操作,递归的终点就是当这个left是一个Element,我们再看Element类:

Element

Element类的代码如下:

//Element定义,每个Element都是一个CoroutineContext
public interface Element : CoroutineContext {

    //每个Element都有一个Key实例
    public val key: Key<*>

    //Element的get方法逻辑:如果key和自己的key匹配,那么自己就是要找的Element,返回自己,否则返回null
    public override operator fun <E : Element> get(key: Key<E>): E? =
            if (this.key == key) this as E else null

    //Element的fold方法逻辑:对传入的initial和自己做operation操作
    public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
            operation(initial, this)

    //Element的minusKey方法逻辑:如果key和自己的key匹配,那么自己就是要删除的Element,返回EmptyCoroutineContext(表示删除了自己),否则说明自己不需要被删除,返回自己
    public override fun minusKey(key: Key<*>): CoroutineContext =
            if (this.key == key) EmptyCoroutineContext else this
}

CoroutineContext的plus操作

为了方便我们理解,我们简化了代码,去掉了ContinuationInterceptor,如下:

public interface CoroutineContext {
   
    //...
  
    public operator fun plus(context: CoroutineContext): CoroutineContext =
  	//如果要相加的CoroutineContext为空,那么不做任何处理,直接返回
        if (context === EmptyCoroutineContext) this else 
  	    //如果要相加的CoroutineContext不为空,那么对它进行fold操作
            context.fold(this) { acc, element -> //我们可以把acc理解成+号左边的CoroutineContext,element理解成+号右边的CoroutineContext的某一个element
                //首先从左边CoroutineContext中删除右边的这个element
                val removed = acc.minusKey(element.key)
                //如果removed为空,说明左边CoroutineContext删除了和element相同的元素后为空,那么返回右边的element即可
                if (removed === EmptyCoroutineContext) element else {
                  	//如果removed不为空,说明左边CoroutineContext删除了和element相同的元素后还有其他元素,那么构造一个新的CombinedContext返回
                  	return CombinedContext(removed, element)
                }
            }
}

plus方法大部分情况最终下返回一个CombinedContext,即我们把两个CoroutineContext相加后,返回一个CombinedContext,在组合成CombinedContext时,+号右边的CoroutineContext中的元素会覆盖+号左边的CoroutineContext中的含有相同key的元素。

总结一下:

  • CoroutineContext内主要存储的是元素Element,每个Element都有一个Key与之对应,可通过Map的Key来获取值。

  • EmptyCoroutineContext表示一个空的CoroutineContext,它里面是空实现。

  • Element实现了ContinueContext接口,主要是为了存放和操作Element自己。

  • CombinedContext就是CoroutineContext集合结构的实现,它里面是一个递归定义Element就是CombinedContext中的元素。

  • 通过+号相加时,实际上是组合到CombinedContext中,并指向上一个Context

  • 在组合成CombinedContext时,+号右边CoroutineContext中的元素会覆盖左边CoroutineContext中的含有相同key的元素。

协程的拦截器

Kotlin协程提供了一种叫做拦截器(ContinuationInterceptor)的组件,它允许我们拦截协程异步回调的恢复调用。同时也可以操纵协程的线程调度。

  • 可以对协程上下文所在的协程的Continuation进行拦截,所以它可以用来处理线程的切换。

  • 可以自定义拦截器,实现ContinuationInterceptor接口并实现interceptContinuation函数。

拦截的分析

我们先来启动一个协程,如下:


fun main() {
    val continuation = suspend {
        println("In Coroutine")
        5
    }
    continuation.startCoroutine(object : Continuation<Int> {
        override val context: CoroutineContext
            get() = EmptyCoroutineContext

        override fun resumeWith(result: Result<Int>) {
            println("Coroutine End: $result")
        }
    })
    testLaunch()
}
---------------------println------------------------

In Coroutine
Coroutine End: Success(5)

上述代码可以看到,当我们启动协程时调用Continuation.startCoroutine(),我们就来这个看看startCoroutine源码,代码很简单如下如下:

public fun <T> (suspend () -> T).startCoroutine(
    completion: Continuation<T>
) {
    createCoroutineUnintercepted(completion).intercepted().resume(Unit) /`intercepted()`
}

createCoroutineUnintercepted 最后会产出一个 Continuation ,而resume 其实就是我们前面说到的初始化操作,这行会去执行状态机 case 0

至于 intercepted() ,到底要拦截啥,其实就是把生成的 Continuation 拦截给指定的 ContinuationInterceptor (这东西包裝在 CoroutineContext 里面,原则上在指定 Dispatcher 的时候就已经建立好了)

我们再跟一下代码,走到ContinuationImplintercepted(),如下:

public fun intercepted(): Continuation<Any?> =
    intercepted
        ?: (context[ContinuationInterceptor]?.interceptContinuation(this) ?: this)
            .also { intercepted = it }

我们通过文章中的CoroutineCotext知道,通过continuation.context[Key]?. Element便可以获到Context中Key对应的Element对象。这里可以注意到 interceptContinuation(Continuation) ,可以用他追下去,发现他是 ContinuationInterceptor 的方法 ,再追下去可以发现CoroutineDispatcher 继承了他:

public final override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> =
    DispatchedContinuation(this, continuation)

可以发现该动作产生了一个 DispatchedContinuation,看看 DispatchedContinuation ,可以注意到刚才有提到的 resumeCancellableWith

inline fun resumeCancellableWith(result: Result<T>) {
    val state = result.toState()
    if (dispatcher.isDispatchNeeded(context)) {
        _state = state
        resumeMode = MODE_CANCELLABLE
        dispatcher.dispatch(context, this)
    } else {
        executeUnconfined(state, MODE_CANCELLABLE) {
            if (!resumeCancelled()) {
                resumeUndispatchedWith(result)
            }
        }
    }
}

原则上就是利用 dispatcher 来決定需不需要 dispatch,沒有就直接执行了 resumeUndispatchedWith

@Suppress("NOTHING_TO_INLINE") // we need it inline to save us an entry on the stack
inline fun resumeUndispatchedWith(result: Result<T>) {
    withCoroutineContext(context, countOrElement) {
        continuation.resumeWith(result)
    }
}

其实就是直接跑 continuationresumeWith

另外我们看到了isDispatchNeeded()dispatch(),跟进去HandlerDispatcher瞅瞅,如下:

// 判断是否需要调度,参数context为CoroutineContext
override fun isDispatchNeeded(context: CoroutineContext): Boolean {
    //判断invokeImmediately的值或者是否是同一个线程
    return !invokeImmediately || Looper.myLooper() != handler.looper
}
//线程调度
override fun dispatch(context: CoroutineContext, block: Runnable) {
    //调用Handler的post方法,将Runnable添加到消息队列中,这个Runnable会在这个Handler附加在线程上的时候运行
    handler.post(block)
}

这段代码我们很熟了,是用来切换线程的。

  • isDispatchNeeded 就是说是否需要切换线程

  • dispatch 则是切换线程的操作

拦截器的使用

挂起点恢复执行的位置都可以在需要的时候添加拦截器来实现一些 AOP 操作。

拦截器是协程上下文的一类实现,定义拦截器只需要实现拦截器的接口,并添加到对应的协程上下文中即可。

拦截器的关键拦截函数是interceptContinuation,可以根据需要返回一个新的Continuation实例。我们在LogContinuationresumeWith中打印日志,接下来把他设置到上下文中,程序运行时就会有相应的日志输出。

代码如下:


class LogInterceptor : ContinuationInterceptor {

    override val key: CoroutineContext.Key<*> = ContinuationInterceptor

    override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> = LogContinuation(continuation)
}

class LogContinuation<T>(private val continuation: Continuation<T>): Continuation<T> by continuation {
    override fun resumeWith(result: Result<T>) {
        println("before resumeWith: $result")

        continuation.resumeWith(result)

        println("after resumeWith")
    }
}

fun main() {
    suspend {
        println("In Coroutine")
        5
    }.startCoroutine(object : Continuation<Int> {
        //将拦截器添加到协程上下文中即可
        override val context: CoroutineContext = LogInterceptor()

        override fun resumeWith(result: Result<Int>) {
            println("Coroutine End: $result")
        }
    })
}

运行上述代码,可以看到, before...after...就是我们自定义拦截器的日志输出。

before resumeWith: Success(kotlin.Unit)
In Coroutine
Coroutine End: Success(5)
after resumeWith

总结

本文通过对Kotlin协程的基础知识作了详细介绍,让我们对协程的概念以及Kotlin协程的内部实现有了一个初步的理解和认识,总结如下:

  • 协程本质上就是一个支持挂起和恢复的程序,内部通过状态机的切换实现挂起和恢复。

  • Kotlin 协程本质就是Java线程池的封装,内部通过拦截器实现了线程切换。

  • Kotlin 协程内部状态机的执行流程。

  • Kotlin 协程拦截器如何实现线程切换。

  • 协程内部是通过状态机才实现的挂起恢复的,并且利用状态机来记录协程执行的状态,并存储在Continuation中。

参考文章