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

带着问题去研学,带着问题去研学是什么意思

前言

随着 kotlin Android 开发领域越来越火,协程在各个项目中的应用也逐渐变得广泛

但是协程到底是什么呢?

协程其实是个古老的概念,已经非常成熟了,但大家对它的概念一直存在各种疑问,众说纷纷有人说协程是轻量级的线程,也有人说 kotlin 协程其实本质是一套线程切换方案

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

带着问题去研学,带着问题去研学是什么意思

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

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

1.1 什么是进程?

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

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

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

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

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

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

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

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

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

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

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.进程可以拓展到 多机,线程最多适合 多核;
  • 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 关键字修饰的函数叫做挂起函数,挂起函数只能在协程体内或者其他挂起函数内使用.

协程内部挂起函数的调用处被称为挂起点,挂起点如果出现异步调用,那么当前协程就被挂起,直到对应的 Continuation resume 函数被调用才会恢复执行

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

带着问题去研学,带着问题去研学是什么意思

可以看出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 源码反编译成字节码来查看编译后的代码关于字节码的分析之前已经有很多人做过了,而且做的很好。下面给出状态机的演示。

带着问题去研学,带着问题去研学是什么意思

  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 与之对应,对于相同 Key Element 是不可以重复存在的 Element 之间可以通过 + 号组合起来, Element 有几个子类, CoroutineContext 也主要由这几个子类组成:

  • Job :协程的唯一标识,用来控制协程的生命周期( new active completing completed cancelling cancelled );
  • CoroutineDispatcher :指定协程运行的线程( IO Default Main Unconfined );
  • 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 中的其他三个方法都被 CombinedContext Element EmptyCoroutineContext 重写

4. CombinedContext 就是 CoroutineContext 集合结构的实现,它里面是一个递归定义, Element 就是 CombinedContext 中的元素,而 EmptyCoroutineContext 就表示一个空的 CoroutineContext ,它里面是空实现

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

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

带着问题去研学,带着问题去研学是什么意思

如上所示,是一个单链表结构,在获取时也是通过这种方式去查询对应的 key ,操作大体逻辑都是先访问当前 element ,不满足,再访问 left element ,顺序都是从 right left

最近我整理一些Android 开发相关的学习文档、面试题,希望能帮助到大家学习提升,如有需要参考的可以直接私信“1”找我参考

带着问题去研学,带着问题去研学是什么意思

带着问题去研学,带着问题去研学是什么意思