阅读 5311

【带着问题学】协程到底是什么?

前言

随着kotlinAndroid开发领域越来越火,协程在各个项目中的应用也逐渐变得广泛
但是协程到底是什么呢?
协程其实是个古老的概念,已经非常成熟了,但大家对它的概念一直存在各种疑问,众说纷纷
有人说协程是轻量级的线程,也有人说kotlin协程其实本质是一套线程切换方案

显然这对初学者不太友好,当不清楚一个东西是什么的时候,就很难进入为什么怎么办的阶段了
本文主要就是回答这个问题,主要包括以下内容
1.关于协程的一些前置知识
2.协程到底是什么?
3.kotlin协程的一些基本概念,挂起函数,CPS转换,状态机等
以上问题总结为思维导图如下:

1. 关于协程的一些前置知识

为了了解协程,我们可以从以下几个切入点出发
1.什么是进程?为什么要有进程?
2.什么是线程?为什么要有线程?进程和线程有什么区别?
3.什么是协作式,什么是抢占式?
4.为什么要引入协程?是为了解决什么问题?

1.1 什么是进程?

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

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

这个资源分配怎么理解呢?

在单核CPU中,同一时刻只有一个程序在内存中被CPU调用运行

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

这种方式会浪费CPU资源,我们可能更想要下面这种方式

当程序A读取数据的时,切换 到程序B去执行,当A读取完数据,让程序B暂停,切换 回程序A执行?

在计算机里 切换 这个名词被细分为两种状态:

挂起:保存程序的当前状态,暂停当前程序; 激活:恢复程序状态,继续执行程序;

这种切换,涉及到了 程序状态的保存和恢复,而且程序AB所需的系统资源(内存、硬盘等)是不一样的,那还需要一个东西来记录程序AB各自需要什么资源,还有系统控制程序AB切换,要一个标志来识别等等,所以就有了一个叫 进程的抽象。

1.1.1 进程的定义

进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体
主要由以下三部分组成
1.程序:描述进程要完成的功能及如何完成;
2.数据集:程序在执行过程中所需的资源;
3.进程控制块:记录进程的外部特征,描述执行变化过程,系统利用它来控制、管理进程,系统感知进程存在的唯一标志。

1.1.2 为什么要有进程

其实上文我们已经分析过了,操作系统之所以要支持多进程,是为了提高CPU的利用率
而为了切换进程,需要进程支持挂起恢复,不同进程间需要的资源不同,所以这也是为什么进程间资源需要隔离,这也是进程是资源分配的最小单位的原因

1.2 什么是线程?

1.2.1 线程的定义

轻量级的进程,基本的CPU执行单元,亦是 程序执行过程中的最小单元,由 线程ID程序计数器寄存器组合堆栈 共同组成。
线程的引入减小了程序并发执行时的开销,提高了操作系统的并发性能。

1.2.2 为什么要有线程?

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

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

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

1.2.3 进程与线程的区别

  • 1.一个程序至少有一个进程,一个进程至少有一个线程,可以把进程理解做 线程的容器;
  • 2.进程在执行过程中拥有 独立的内存单元,该进程里的多个线程 共享内存;
  • 3.进程可以拓展到 多机,线程最多适合 多核;
  • 4.每个独立线程有一个程序运行的入口、顺序执行列和程序出口,但不能独立运行,需依存于应用程序中,由应用程序提供多个线程执行控制;
  • 5.「进程」是「资源分配」的最小单位,「线程」是 「CPU调度」的最小单位
  • 6.进程和线程都是一个时间段的描述,是 CPU工作时间段的描述,只是颗粒大小不同。

1.3 协作式 & 抢占式

单核CPU,同一时刻只有一个进程在执行,这么多进程,CPU的时间片该如何分配呢?

1.3.1 协作式多任务

早期的操作系统采用的就是协作时多任务,即:由进程主动让出执行权,如当前进程需等待IO操作,主动让出CPU,由系统调度下一个进程。
每个进程都循规蹈矩,该让出CPU就让出CPU,是挺和谐的,但也存在一个隐患:单个进程可以完全霸占CPU
计算机中的进程良莠不齐,先不说那种居心叵测的进程了,如果是健壮性比较差的进程,运行中途发生了死循环、死锁等,会导致整个系统陷入瘫痪!
在这种鱼龙混杂的大环境下,把执行权托付给进程自身,肯定是不科学的,于是由操作系统控制的抢占式多任务横空出世~

1.3.2 抢占式多任务

由操作系统决定执行权,操作系统具有从任何一个进程取走控制权和使另一个进程获得控制权的能力。
系统公平合理地为每个进程分配时间片,进程用完就休眠,甚至时间片没用完,但有更紧急的事件要优先执行,也会强制让进程休眠。
这就是所谓的时间片轮转调度

时间片轮转调度是一种最古老,最简单,最公平且使用最广的算法。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。如果在时间片结束时进程还在运行,则CPU将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或结束,则CPU当即进行切换。调度程序所要做的就是维护一张就绪进程列表,当进程用完它的时间片后,它被移到队列的末尾。

有了进程设计的经验,线程也做成了抢占式多任务,但也带来了新的——线程安全问题,这个一般通过加锁的方式来解决,这里就不缀述了

1.4 为什么要引入协程?

上面介绍进程与线程的时候也提到了,之所以引入进程与线程是为了异步并发的执行任务,提高系统效率及资源利用率
但作为Java开发者,我们很清楚线程并发是多么的危险,写出来的异步代码是多么的难以维护。
Java中,我们一般通过回调来处理异步任务,但是当异步任务嵌套时,往往程序就会变得很复杂与难维护

举个例子,当我们需要完成这样一个需求:查询用户信息 --> 查找该用户的好友列表 --> 查找该好友的动态
看一下Java回调的代码

getUserInfo(new CallBack() {
    @Override
    public void onSuccess(String user) {
        if (user != null) {
            System.out.println(user);
            getFriendList(user, new CallBack() {
                @Override
                public void onSuccess(String friendList) {
                    if (friendList != null) {
                        System.out.println(friendList);
                        getFeedList(friendList, new CallBack() {
                            @Override
                            public void onSuccess(String feed) {
                                if (feed != null) {
                                    System.out.println(feed);
                                }
                            }
                        });
                    }
                }
            });
        }
    }
});
复制代码

这就是传说中的回调地狱,如果用kotlin协程实现同样的需求呢?

val user = getUserInfo()
val friendList = getFriendList(user)
val feedList = getFeedList(friendList)
复制代码

相比之下,可以说是非常简洁了
Kotlin 协程的核心竞争力在于:它能简化异步并发任务,以同步方式写异步代码
这也是为什么要引入协程的原因了:简化异步并发任务

2.到底什么是协程

2.1 什么是协程?

一种非抢占式(协作式)的任务调度模式,程序可以主动挂起或者恢复执行。

2.2 协程与线程的区别是什么?

协程基于线程,但相对于线程轻量很多,可理解为在用户层模拟线程操作;
每创建一个协程,都有一个内核态线程动态绑定,用户态下实现调度、切换,真正执行任务的还是内核线程。
线程的上下文切换都需要内核参与,而协程的上下文切换,完全由用户去控制,避免了大量的中断参与,减少了线程上下文切换与调度消耗的资源。
线程是操作系统层面的概念,协程是语言层面的概念

线程与协程最大的区别在于:线程是被动挂起恢复,协程是主动挂起恢复

2.3 协程可以怎样分类?

根据 是否开辟相应的函数调用栈 又分成两类:

  • 有栈协程:有自己的调用栈,可在任意函数调用层级挂起,并转移调度权;
  • 无栈协程:没有自己的调用栈,挂起点的状态通过状态机或闭包等语法来实现;

2.4 Kotlin中的协程是什么?

"假"协程,Kotlin在语言级别并没有实现一种同步机制(锁),还是依靠Kotlin-JVM的提供的Java关键字(如synchronized),即锁的实现还是交给线程处理
因而Kotlin协程本质上只是一套基于原生Java线程池 的封装。

Kotlin 协程的核心竞争力在于:它能简化异步并发任务,以同步方式写异步代码。
下面介绍一些kotin协程中的基本概念

3. 什么是挂起函数?

我们知道使用suspend关键字修饰的函数叫做挂起函数,挂起函数只能在协程体内或者其他挂起函数内使用.
协程内部挂起函数的调用处被称为挂起点,挂起点如果出现异步调用,那么当前协程就被挂起,直到对应的Continuationresume函数被调用才会恢复执行

我们下面来看看挂起函数具体执行的细节

可以看出kotlin协程可以做到一行代码切换线程
这些是怎么做到的呢,主要是通过suspend关键字

3.1 什么是suspend

suspend 的本质,就是 CallBack

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

不过当我们写挂起函数的时候,并没有写callback,所谓的callback从何而来呢?
我们看下反编译的结果

//                              Continuation 等价于 CallBack
//                                         ↓         
public static final Object getUserInfo(Continuation $completion) {
  ...
  return "BoyCoder";
}

public interface Continuation<in T> {
    public val context: CoroutineContext
//      相当于 onSuccess     结果   
//                 ↓         ↓
    public fun resumeWith(result: Result<T>)
}
复制代码

可以看出
1.编译器会给挂起函数添加一个Continuation参数,这被称为CPS 转换(Continuation-Passing-Style Transformation)
2.suspend函数不能在协程体外调用的原因也可以知道了,就是因为这个Continuation实例的传递

4. 什么是CPS转换

下面用动画演示挂起函数在 CPS 转换过程中,函数签名的变化:
可以看出主要有两点变化
1.增加了Continuation类型的参数
2.返回类型从String转变成了Any

参数的变化我们之前讲过,为什么返回值要变呢?

4.1 挂起函数返回值

挂起函数经过 CPS 转换后,它的返回值有一个重要作用:标志该挂起函数有没有被挂起。
听起来有点奇怪,挂起函数还会不挂起吗?

只要被suspend修饰的函数都是挂起函数,但是不是所有挂起函数都会被挂起
只有当挂起函数里包含异步操作时,它才会被真正挂起

由于 suspend 修饰的函数,既可能返回 CoroutineSingletons.COROUTINE_SUSPENDED,表示挂起
也可能返回同步运行的结果,甚至可能返回 null
为了适配所有的可能性,CPS 转换后的函数返回值类型就只能是 Any?了。

4.2 小结

1.suspend修饰的函数就是挂起函数
2.挂起函数,在执行的时候并不一定都会挂起
3.挂起函数只能在其他挂起函数中被调用
4.挂起函数里包含异步操作的时候,它才会真正被挂起

5. Continuation是什么?

Continuation词源是continue,也就是继续,接下来要做的事的意思
放到程序中Continuation则代表了,接下来要执行的代码
以上面的代码为例,当程序运行 getUserInfo() 的时候,它的 Continuation则是下图红框的代码:

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

当然有人会问,这么简单粗暴?三个挂起函数最终变成三个 Callback 吗?
当然不是,思想仍然是CPS的思想,不过需要结合状态机
CPS状态机就是协程实现的核心

6. 状态机

kotlin协程的实现依赖于状态机
想要查看其实现,可以将kotin源码反编译成字节码来查看编译后的代码
关于字节码的分析之前已经有很多人做过了,而且做的很好,可参考:Kotlin Jetpack 实战 | 09. 图解协程原理
读者可通过上面的链接进行详细的学习,下面给出状态机的动画演示

  1. 协程实现的核心就是CPS变换与状态机
  2. 协程执行到挂起函数,一个函数如果被挂起了,它的返回值会是:CoroutineSingletons.COROUTINE_SUSPENDED
  3. 挂起函数执行完成后,通过Continuation.resume方法回调,这里的Continuation是通过CPS传入的
  4. 传入的Continuation实际上是ContinuationImpl,resume方法最后会再次回到invokeSuspend方法中
  5. invokeSuspend方法即是我们写的代码执行的地方,在协程运行过程中会执行多次
  6. invokeSuspend中通过状态机实现状态的流转
  7. continuation.label 是状态流转的关键,label改变一次代表协程发生了一次挂起恢复
  8. 通过break label实现goTo的跳转效果
  9. 我们写在协程里的代码,被拆分到状态机里各个状态中,分开执行
  10. 每次协程切换后,都会检查是否发生异常
  11. 切换协程之前,状态机会把之前的结果以成员变量的方式保存在 continuation 中。

以上是状态机流转的大概流程,读者可跟着参考链接,过一下编译后的字节码执行流程后,再来判断这个流程是否正确

7. CoroutineContext是什么?

我们上面说了Continuation是继续要执行的代码,在实现上它也是一个接口

public interface Continuation<in T> {
    public val context: CoroutineContext
    public fun resumeWith(result: Result<T>)
}
复制代码

1.Continuation主要由两部分组成,一个context,一个resumeWith方法
2.通过resumeWith方法执行接下去的代码
3.通过context获取上下文资源,保存挂起时的一些状态与资源

CoroutineContext即上下文,主要承载了资源获取,配置管理等工作,是执行环境相关的通用数据资源的统一提供者

CoroutineContext是一个特殊的集合,这个集合它既有Map的特点,也有Set的特点
集合的每一个元素都是Element,每个Element都有一个Key与之对应,对于相同KeyElement是不可以重复存在的
Element之间可以通过+号组合起来,Element有几个子类,CoroutineContext也主要由这几个子类组成:

  • Job:协程的唯一标识,用来控制协程的生命周期(newactivecompletingcompletedcancellingcancelled);
  • CoroutineDispatcher:指定协程运行的线程(IODefaultMainUnconfined);
  • CoroutineName: 指定协程的名称,默认为coroutine;
  • CoroutineExceptionHandler: 指定协程的异常处理器,用来处理未捕获的异常.

7.1 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 {
       
      	//每个Element都有一个Key实例
        public val key: Key<*>
				
      	//...
    }
}
复制代码

1.CoroutineContext内主要存储的就是Element,可以通过类似map[key] 来取值
2.Element也实现了CoroutineContext接口,这看起来很奇怪,为什么元素本身也是集合呢?主要是为了API设计方便,Element内只会存放自己
3.除了plus方法,CoroutineContext中的其他三个方法都被CombinedContextElementEmptyCoroutineContext重写
4.CombinedContext就是CoroutineContext集合结构的实现,它里面是一个递归定义,Element就是CombinedContext中的元素,而EmptyCoroutineContext就表示一个空的CoroutineContext,它里面是空实现

7.2 为什么CoroutineContext可以通过+号连接

CoroutineContext能通过+号连接,主要是因为重写了plus方法
当通过+号连接时,实际上是包装到了CombinedContext中,并指向上一个Context

如上所示,是一个单链表结构,在获取时也是通过这种方式去查询对应的key,操作大体逻辑都是先访问当前element,不满足,再访问leftelement,顺序都是从rightleft
由于篇幅关系,这里就不一起看源码了,如果想看源码分析的同学可参考:CoroutineContext的plus操作

总结

本文主要围绕协程到底是什么这一切入点,介绍了关于协程的一些前置知识,协程到底是什么,以及kotlin协程的一些基本概念
关于协程到底是什么,总结如下:
1.一种非抢占式(协作式)的任务调度模式,程序可以主动挂起或者恢复执行
2.Kotlin协程本质上只是一套基于原生Java线程池 的封装
3.Kotlin 协程的核心竞争力在于:它能简化异步并发任务,以同步方式写异步代码。
4.kotlin协程在实现上主要依赖于CPS转换与状态机

相关文章

【带着问题学】协程到底是怎么切换线程的?

参考资料

枯燥的Kotlin协程三部曲(上)——概念启蒙篇
Kotlin Jetpack 实战 | 09. 图解协程原理
揭秘kotlin协程中的CoroutineContext

文章分类
Android
文章标签