枯燥的Kotlin协程三部曲(上)——概念启蒙篇

0x0、引言


Kotlin 1.3 版本开始引入协程 Coroutine,简练的官方文档 和 网上一堆浅尝辄止的文章让我心里没底,不想止步于仅仅知道:

① Android中,Kotlin协程用于解决:处理耗时任务保证主线程安全
② 利用Kotlin协程,可以用看起来:同步 的方式编写 异步 代码;
③ 基础的API调用;

我还想了解更多,如协程的概念,Kotlin协程在实际开发中的使用,背后的原理等,遂有此文。
Kotlin协程的源码还没啃完,此系列目前只能算是笔记,边看边学,部分内容摘取自参考文献,只是让潜意识里有这些概念,后续看完源码,缕清思路再来整理一波,有纰漏之处,欢迎评论区友善指出,一起讨论学习,谢谢~
本文主要阐述一些概念相关的东西,一些前置知识,有所了解得可直接跳过。


0x1、追根溯源


1、同步 & 异步


先撇开编程相关的东西不说,通过 坐公交 的形象化例子帮助理解 同步与异步:

乘客排队等公交,车来了,前门扫码上车,一个扫完到下一个扫,一种 串行化 的关系,这是 同步
前门乘客上车,后门乘客下车,互不影响,同时进行,一种 并行化 的关系,这是 异步

我们把乘客上车和下车,看做是两个 任务,司机开车也是一个任务,跟这两个任务是异步关系。异步说明两者可以同时进行,乘客还没上完车,司机直接把车开走,也是可以的:

不过这显然不合常理,正常来说:司机应该等乘客上下车完毕才发车,那司机怎么知道:

常规操作有两种:

轮询(主动):每隔一段时间查看下前后门监控,看下还有没有乘客;
回调(被动):早期的公交车上都会配有一个乘车员,没乘客上下车了,她就会喊司机开车;


2、堵塞 & 非堵塞


同步和异步的关注点是 是否同时进行,而堵塞和非堵塞关注的是 能否继续进行,还是坐公交的例子:

有乘客上下车,司机发车就需要等待,此时司机发车的任务处于 堵塞 状态;
乘客都上下车完毕,司机又可以发车了,此时司机发车的任务处于 非堵塞 状态;

堵塞的真正含义:关心的事物由于某些原因,无法继续进行,因此让你等待。
等待:只是堵塞的一个副作用,表明随时间流逝,没有任何有意义的事物发生或进行。

堵塞时,没必要干等着,可以做点其他无关的事物,因为这不影响你对相关事情的等待;
比如司机等发车时,可以喝喝茶、看看手机等,但不能离开。

计算机没人那么灵活,堵塞时干等最容易实现,只需挂起线程,让出CPU即可,等条件满足时,在重新调度此线程。


3、程序


回到编程相关,任务 对应计算机中的 程序,定义如下:

为了完成特定任务,用某种编程语言编写的一组指令集合(一组 静态代码)

CPU处理器逐条执行指令,哪怕出现外部中断,也只是从当前程序切到另一段程序,继续逐条执行。

和预期一致,代码 逐条执行,但有些业务场景 顺序结构 就无能为力了,比如:

女朋友:你下班后去超市买10个鸡蛋回来,看到有卖西瓜的就买1个

此时,需要用到四种 基础控制流 中的另外一种 → 选择执行

剩下两种基础控制流为 迭代和递归,我们使用 控制流 来完成 逻辑流,程序执行到哪,逻辑就执行到哪,这样的程序结构清晰,可读性好,比较符合编程人员的思维习惯,这也是 同步编程 的方式。


4、进程


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

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

看着有点蠢,能不能这样:

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

当然可以,不过在计算机里 切换 这个名词被细分为两种状态:

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

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

进程的定义

程序在一个数据集上的一次动态执行过程,一般由下述三个部分组成:

  • 程序:描述进程要完成的功能及如何完成;
  • 数据集:程序在执行过程中所需的资源;
  • 进程控制块:记录进程的外部特征,描述执行变化过程,系统利用它来控制、管理进程,系统感知进程存在的唯一标志。

进程是系统进行 资源分配和调度 的一个 独立单位

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

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

于是划分粒度更小的 线程 出现了。


5、线程

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

线程的定义

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

区分:「进程」是「资源分配」的最小单位,「线程」是 「CPU调度」的最小单位

线程和进程的关系

① 一个程序至少有一个进程,一个进程至少有一个线程,可以把进程理解做 线程的容器
② 进程在执行过程中拥有 独立的内存单元,该进程里的多个线程 共享内存
③ 进程可以拓展到 多机,线程最多适合 多核
④ 每个独立线程有一个程序运行的入口、顺序执行列和程序出口,但不能独立运行,需依存于应用程序中,由应用程序提供多个线程执行控制;

进程和线程都是一个时间段的描述,是 CPU工作时间段的描述,只是颗粒大小不同。


6、并发 & 并行


上面提到一个名词 并发 (Concurrency),指的是:

同一时刻只有一条指令执行,但多个进程指令被快速地 轮换执行,使得在宏观上有同时执行的效果,微观上并不是同时执行,只是把CPU时间分成若干段,使得多个进程快速交替地执行,存在于单核或多核CPU系统中。

而另一个容易混淆的名词 并行 (Parallel) 则是:

同一时刻,有多条指令在多个处理器上同时执行,从微观和宏观上看,都是一起执行的,存在于多核CPU系统中。


7、协作式 & 抢夺式


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

协作式多任务

早期的操作系统采用的就是协作时多任务,即:

由进程主动让出执行权,如当前进程需等待IO操作,主动让出CPU,由系统调度下一个进程。

每个进程都循规蹈矩,该让出CPU就让出CPU,是挺和谐的,但也存在一个隐患:

单个进程可以完全霸占CPU

计算机中的进程良莠不齐,先不说那种居心叵测的进程了,如果是健壮性比较差的进程,运行中途发生了死循环、死锁等,会导致整个系统陷入瘫痪!在这种鱼龙混杂的大环境下,把执行权托付给进程自身,肯定是不符合基础国情,由操作系统扛大旗的 抢占式多任务 横空出世~

抢占式多任务

由操作系统决定执行权,操作系统具有从任何一个进程取走控制权和使另一个进程获得控制权的能力。

系统公平合理地为每个进程分配时间片,进程用完就休眠,甚至时间片没用完,但有更紧急的事件要优先执行,也会强制让进程休眠。有了进程设计的经验,线程也做成了抢占式多任务,但也带来了新的——线程安全问题


8、线程安全问题


进程在执行过程中拥有独立的内存单元,而多个线程共享这个内存,可能存在这样一种情况:

假设有一个变量a = 10,它可以被线程t1和t2共享访问,两个线程都会对i值进行写入,假设在单核CPU上运行此程序,系统需要给两个线程都分配CPU时间片:

  • 1.t1从内存中读取了a的值为10,它把a的值+1,准备把11这个新值写入内存中,此时时间片耗尽;
  • 2.系统执行了线程调度,t1的执行现场被保存,t2获得执行,它也去读a的值,此时a的值仍为10,+1,然后把11写入内存中;
  • 3.t1再次被调度,此时它也把11写入内存中。

程序的执行结果和我们的预期不符,a的值应该为12而不是11,这就是线程调度不可预测性引起的 线程同步安全问题

解决方法

系列化访问临界资源,同一时刻,只能有一个线程访问临界资源,也称 同步互斥访问,通常的操作就是 加锁(同步锁),当线程访问临界资源时需要获得这个锁,其他线程无法访问,只能 等待(堵塞),等这个线程使用完释放锁,供其他线程继续访问。

前置概念相关的东西就说这么多,相信会对你接下来学习Kotlin协程大有裨益。


0x2、单线程的Android GUI系统


是的,Android GUI 被设计成单线程了,你可能会问:为啥不采用性能更高的多线程?

答:如果设计成多线程,多个线程同时对一个UI控件进行更新,容易发生 线程同步安全问题;最简单的解决方式:加锁,但这意味着更多的耗时和UI更新效率的降低,而且还有死锁等诸多问题要解决;多线程模型带来的复杂度成本,远远超出它能提供的性能优势成本。这也是大部分GUI系统都是单线程模型的原因。

Android要求:在主线程(UI线程)更新UI,注意是 → 要求建议,不是规定,规定底线是:

只有创建这个view的线程才能操作这个view

所以,你在子线程中更新子线程创建的UI也是可以的,不过不建议这么做,建议:

在子线程中完成耗时操作,然后通过Handler发送消息,通知UI线程更新UI。

Tips:关于Handler更多的内容可移步至:《换个姿势,带着问题看Handler》

接着说下,Android异步更新UI的写法都有哪些~


1、Handler


主线程中实例化一个Handler对象,在子线程需要更新UI的地方,通过Handler对象的post(runnable)或其他函数,往主线程的消息队列发送消息,等待调度器调度,分发给对应的Handler完成UI更新,写法示例如下:

利用 lambda表达式 + Kotlin语法糖thread { } 可对上述代码进行简化:

还有另外一种常见的写法:自定义一个静态内部类Handler,把UI更新操作统一放到这里,根据msg.what进行区分。


2、AsyncTask


AsyncTask是Android提供的一个轻量级的用于处理异步任务的类(封装Handler+Thread),使用代码示例如下:

相比起手写Handler简单了一些,只需要继承AsyncTask,然后就是填空题(按需重写函数):

  • onPreExecute():异步操作开始,可以做一些UI的初始化操作;
  • doInBackground():执行异步操作,可调用publishProgress()触发onProgressUpdate()进度更新;
  • onProgressUpdate():根据进度更新UI;
  • onPostExecute():异步操作完成,更新UI;

但也存在以下局限性:

① AsyncTask类需在主线程中加载;
② AsyncTask对象需在主线程中创建;
③ execute()必须在主线程中调用,且一个AsyncTask对象只能调用一次此方法;
④ 需要为每一种任务类型创建一个特定子类,同时为了访问UI方便,经常定义为Activity的内部类,耦合严重。

可以通过 函数转换为回调的方式 来解耦,抽取后的自定义AsyncTask类如下:

调用也很简单,按需重写对应函数即可:

解耦后灵活多了,外部调用逻辑内部异步逻辑 的分离开来了,但依旧存在问题,如异常处理、任务取消等。


3、runOnUiThread


可以说是很无脑了,在子线程中想更新UI,直接写一个runOnUiThread{}包裹着UI更新相关的代码即可,示例如下:

点进源码康康:

噢,还挺简单:

Activity中定义了此函数,判断当前线程是否为主线程,不是 → Handler.post,是 → 直接执行UI更新。

回调确实是个好东西,但是多层次的回调嵌套,可能会形成 Callback Hell(回调地狱),比如现在有这样的逻辑:

访问百度 → 展示内容(UI) → 下载图标 → 显示图标(UI) → 生成缩略图 → 显示缩略图(UI) → 上传缩略图 → 界面更新(UI)

按照这样的逻辑,用runOnUiThread一把梭,伪代码如下:

老千层饼了,一层套一层,看到这种代码,不知道你是不是和我一样 气抖冷 (听说前端写js回调的更可怕,23333)

常见的规避方法:把嵌套的层次移到外层空间,不使用匿名的回调函数,为每个回调函数命名。


4、RxJava

RxJava在 链式调用 的设计基础上,通过设置 不同的调度器,可以灵活地在 不同线程间切换 并执行对应的Task。
RxJava很强大,但因为较高的学习门槛,大多Android开发仔的认知还停留在:线程切换工具+操作符好用 的阶段。
巧了,笔者也是:

有兴趣深入学习的RxJava的可以康康《RxJava 沉思录(一):你认为 RxJava 真的好用吗?》
这里只是展示效果,用RxJava写代码的效果,等我变强了,再回来完善这一块:

输出结果如下:

随心所欲,控制线程切换~


5、LiveData


LiveData 是Jetpack提供的一种响应式编程组件,可以包含任何类型的数据,并在数据发生变化时通知给观察者;由于它可以感知并遵循Activity、Fragment或Service等组件的生命周期,因此可以做到仅在组件处于声明周期的激活状态时才更新UI。一般是搭配 ViewModel 组件一起使用的。

MutableLiveData是一种可变的LiveData,提供了两种读数据的方法:
主线程中调用的setValue()在非主线程中调用的postValue()

使用时需导入依赖

implementation "androidx.lifecycle:lifecycle-runtime:2.2.0"

使用代码示例如下


6、Kotlin协程


使用Kotlin协程需要先添加 协程核心库和平台库 依赖(build.gradle中引入):

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7'

使用 withContext 函数可以切换到指定的线程,并在闭包内的逻辑执行结束后,自动把线程切换回上下文继续执行。把RxJava部分的示例改成Kotlin协程的形式,代码示例如下:

使用Kotlin协程后,代码量并没有减少,但是异步代码的编写却轻松多了,开始有一种「同步方式写异步代码」的味道了~
再简化下,把withContext 作为函数的返回值。


0x3、Kotlin中的协程到底是什么


协程

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

与线程的关系

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

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

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

Kotlin中的协程

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

只是这套API 隐藏了异步实现细节,让我们可以用如同 同步的写法来写异步操作 罢了。


参考文献