1. 协程解决了什么问题?
用协程也有一段时间了,但是关于协程具体到底怎么运作的这件事上,处于一种懂了但是又好像没懂的情况,但是协程解决什么事情倒是很明确。
我们先举个Android日常开发会经常遇到的痛点,就是异步回调地狱,比如现在有一个功能需要调用A接口,然后用A接口的返回参数请求B接口,再用B接口的返回值请求C接口。(当然,一般遇不上啊,这太灾难了)
那么以上的功能我们一般是怎么解决的?
fun main(){
request1(){ params1->
request2(params1){ params2->
request3(params2)
}
}
}
写完后我背后一凉,这太不优雅了!!要是现在再改改需求,请求接口C必须先要同时请求并且等待接口A和B的返回值,然后拿着A和B的返回值进行接口C的请求。
可能讲到这里你已经开始骂娘了,但是此时协程站出来了,说了句:“不用骂了!因为我来了!”
那么我们用协程会怎么写以上两个功能?
需求一:
fun main(){
GlobalScope.launch {
var params1= request1()//异步请求
var params2= request2(params1)//异步请求
request3(params2)
}
}
需求二:
fun main(){
GlobalScope.launch {
var params1= async{request1()}//异步请求
var params2= async{request2(params1)}//异步请求
request3(params1.await(),params2.await())
}
}
这实在太优雅了啦!消除了回调地狱,让异步的程序用同步的写法来执行~这就是协程所解决的痛点!
没错,协程就是用同步的写法来编写异步的程序,但是具体点的概念是什么呢?
而且也不只是Kotlin拥有协程,还有其他拥有协程的语音,比如Go,PyThon,甚至Java也有协程了,那么既然他们都称为协程,那么肯定有一套统一的思想驱使他们去设计协程,那么协程具体是基于什么思想和理念来设计的呢?接下来讲解下。
2.协程设计理念探索
2.1 Continuation Passing Style(CPS)
协程是基于Continuation Passing Style(CPS)这种理念来设计的,那什么是CPS呢?
举两个例子,一种是平时编写程序的写法,另一种是CPS的写法:
fun drawView(){
measure();
layout();
draw();
}
上面是理所当然的写法,那么接着看看CPS是怎么写的?
fun drawView(){
measure(){ //第一步
layout(){ //第二步
draw() //第三步
}
}
}
在CPS中,每个下个步的执行都是通过上一步的完成回调来执行的,而CPS中的C就是下一步、延续物的意思,而网上普遍称为续体,接着我们来看看上面的代码分析一下续体有哪些?
- 第二步和第三步是第一步的续体
- 第三步是第二步的续体
但是一般看到这里都会产生疑问:咦?通过回调的写法,那tm不是变得更麻烦了吗?这CPS跟回调地狱有什么区别?
确实没区别,但是讲CPS是为了凸显执行步骤与续体这一概念,这样才能更好的去解释协程。
但是刚刚衍生出一个问题,我勒个去,这CPS回调太恶心了,Kotlin协程是怎么解决这些回调的?我们会在章节2.3中细说,接着我们先保留问题,先看看Kotlin中的续体是什么?
2.2 Kotlin中的续体接口
Kotlin中的续体都必须是继承至Continuation这个接口的。
public interface Continuation<in T> {
//协程上下文
public val context: CoroutineContext
//恢复挂起的函数,并且返回参数结果
public fun resumeWith(result: Result<T>)
}
我们可以看到,接口包含一个成员变量和一个函数,分别是协程的上下文与恢复挂起。
- CoroutineContext:协程的上下文,本质是一个链表,包含了协程执行时需要的一些参数比如:调度器、名称/ID、控制器 Job、异常 Handler等
- resumeWith(result:Result<T>):进行下一步的函数,result就是当前步骤的结果。
2.3 状态机
Kotlin利用状态机来消除CPS的回调,先简单介绍一下状态机这种设计模式。
状态机围绕着两个关键的变量来设计,一是状态,二是行为,每个状态下都会有符合自己状态的行为。
举个例子:
- 比如我一台电视,有开机和关机两种状态,在开机时候我们需要屏幕渲染出画面,在关机的时候,我们就停止了屏幕的渲染。
- 那么Activity的生命周期也属于一种状态模式,每个生命周期下他的行为都不一样,比如onCreate中我们要初始化布局,onDestroy里面我们要进行销毁动作
介绍完状态机模式后,我相信理解上没什么难度,那么Kotlin协程是怎么做到利用状态机消除CPS回调的?
想要看到Kotlin协程的代码真实面貌,需要反编译一下才能看清全貌。
接下来贴上一段需求一的代码与其反编译后的代码
//原协程代码
fun main(){
GlobalScope.launch {
var params1= request1()//异步请求
var params2= request2(params1)//异步请求
request3(params2)
}
}
//反编译后
public final void main() {
BuildersKt.launch(() {
//可以理解成一个程序计数器,也就是状态
int label;
public final Object invokeSuspend(@NotNull Object $result) {
//当前上一步的执行结果
Object result;
//通过swtich来区分对应状态下的行为
switch (this.label) {
case 0:
//将label改为1,意味着下次执行该方法时,会到case 1逻辑分支。
this.label = 1;
//调用方法,如果该方法是挂起方法则返回一个标识
result = request1(this);
//判断标识,如果执行的函数是挂起函数则直接return,在外围循环中该invokeSuspend方法会重新被调用,直到执行完毕
//这里只需要大概知道原理,后面会细说整体原理
if (result == IS_SUSPEND) {
return IS_SUSPEND;
}
break;
case 1:
//因为在case0中label被改为1,执行挂起函数然后return了,在case1的挂起函数执行完后,该方法被外部重新执行,此时来到case1逻辑中。
//通过参数获取case 0中的结果
result = $result;
String params1 = (String)result;
//设置下一步需要执行的步数
this.label = 2;
//与request1同理,就不重复说了
result = request2(params1, this);
if (result == IS_SUSPEND) {
return IS_SUSPEND;
}
break;
case 2:
//这里基本与request2同理就不重复说了
result = $result;
String params2 = (String)result;
this.label = 3;
if (request3(params2, this) == IS_SUSPEND) {
return IS_SUSPEND;
} else {
return Unit.INSTANCE;
}
break;
case 3:
//到了第四步,没有续体可以执行,视为结束,返回结束标识,至此,我们该协程运行完毕
ResultKt.throwOnFailure($result);
return Unit.INSTANCE;
}
}
}
);
}
我们通过反编译后的伪代码看出来,Kotlin通过label变量来决定当前执行哪一步的逻辑,而switch中就是存放每一步的方法快,这样就能消除回调嵌套,当程序执行完某一步之后,如果当前是挂起函数,则会return掉当前方法,进入下一次的循环,重新调用方法,判断label执行到哪一步,直到结束。
这里跟实际反编译的代码虽然区别很大,但是意思是差不多的,协程除了通过状态机控制流程,其实还加上闭包,加上闭包的好处是可以控制变量的生命周期,有些变量没必要贯穿整个协程周期,那这里就先不细说。
2.4 挂起本质
在章节2.3中,我们知道了协程是通过状态机来区别执行哪一步,但是具体对方法是怎么挂起和恢复的只是有一个模糊的理解,本章节我们尝试深入讲明白。
2.4.1 了解挂起的概念
首先我们需要了解在协程中什么是挂起,先上一个协程例子:
fun main(){
GlobalScope.launch {
var params= request()//异步请求
}
}
我们可以得知request是异步请求,请求结果赋值到params变量中,那么在协程里面整体步骤分为以下两步(不严谨的,便于理解):
- 调用request方法,此时因为request方法在另外一个线程,所以执行request方法后程序挂起了。
- 网络请求回来了,解析了返回值并且返回,此时挂起在“=”号右边的程序恢复,并且将request方法的结果赋值到params变量中。
可能熟悉线程的童鞋会一下子反应过来,卧槽这不就是阻塞吗?这里要特别说明一下,在协程里,挂起并不会造成线程阻塞,可以理解成非阻塞等待。
2.4.2 挂起函数与恢复挂起
当我们理解了什么是挂起后,接下来就需要学习怎么声明挂起函数了,声明一个挂起函数我们需要再函数前加关键字suspend
来,我们尝试下加一下suspend关键字,看看有没问题。
suspend fun request(): String {
//模拟网络请求
Thread{
var params = requetHttp();
}.start()
return ;
}
卧槽,问题来了,那异步的结果是需要怎么返回啊?
在异步的请求下,仅仅通过添加suspend关键字还不够,还需要将关键的逻辑包装成续体,那么在Kotlin下有两种主流续体,一种是SafeContinuation,另一种是CancellableContinuation,可以先简单理解成前者是不可取消的续体,而后者是可以取消的,来,我们改造下。
suspend fun request(): String {
//这里并不是return真正的返回值,这里返回的是IS_SUSPEND的标识,详情看上面反编译后的case0;
//这里我们通过suspendCancellableCoroutine这个方法创建了可取消续体,并且包裹了我们实际的逻辑
return suspendCancellableCoroutine { it:CancellableContinuation<String> ->
//当执行到这个闭包逻辑时,其实整个协程的状态已经属于挂起中的状态
Thread{
var params = requetHttp();
//我们调用CancellableContinuation中的resumeWith方法后。
//该方法会告诉协程,当前我这个挂起函数已经执行完毕,并且将返回值返回,现在可以恢复协程中的逻辑执行了。
it.resumeWith(Result.success("param"));
}.start()
}
}
在上面的挂起函数里面我们主要做了两件事:
- 调用suspendCancellableCoroutine方法,将我们的逻辑作为闭包传进去,此时会生成一个续体包裹我们的闭包。
- 我们拥有续体之后,我们就拥有了恢复程序的能力,具体看2.2章节中的续体接口,其接口提供了resumeWith函数,提供恢复挂起的能力。
- 调用续体的resumeWith方法,恢复挂起,至此协程的工作完毕。
3. 完整的挂起与恢复流程是怎么样的?
上面其实已经讲解了以下几点:
- 协程是如何通过状态机+续体进行工作
- 如何声明一个挂起函数与将逻辑包裹在续体里
- 挂起函数中是怎么恢复挂起点的
但是以上几点都讲得比较零碎,接下来我们将整体流程给复盘一次,加深对协程运作的印象。
首先声明一个协程与挂起函数,同时贴出他们反编译后的代码,并且讲解运作流程。
//源代码
fun main(){
GlobalScope.launch {
var params = request()//挂起函数
}
}
suspend fun request(): String {
return suspendCancellableCoroutine { it:CancellableContinuation<String> ->
Thread{
var params = requetHttp();
it.resumeWith(Result.success("param"));
}.start()
}
}
将上面代码进行反编译后,我们对流程进行分析与解读,请大家仔细看代码注释,这将会帮助你了解协程的基本运作。
class Demo{
public final void main() {
//1. 通过launch方法开始协程运作
BuildersKt.launch(() {
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
Object var3 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
Object var10000;
switch (this.label) {
case 0:
Demo1 var4 = Demo1.INSTANCE;
//2. 将执行步骤指向第二步
this.label = 1;
//3.调用挂起函数,留意参数,将当前整个闭包对象传递进去(具体看下面的方法)
var10000 = var4.request(this);
//4.判断request方法是否挂起方法,如果是则reutrn。
if (var10000 == var3) {
return var3;
}
break;
case 1:
//4.invokeSuspend方法会再次被request调用,根据状态机走到这步,从参数中获取request的返回值
var10000 = $result;
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
//5.将参数强转成具体类型,至此该协程结束
String var2 = (String)var10000;
return Unit.INSTANCE;
}
});
}
/*
注意,方法在反编译后会新增一个参数,该参数指的就是launch函数里面传递的闭包
这里可以判断出那个闭包是继承于Continuation的。
*/
private final Object request(Continuation $completion) {
//3.1 新建一个续体对象,
CancellableContinuationImpl cancellable$iv = new CancellableContinuationImpl(IntrinsicsKt.intercepted($completion), 1);
cancellable$iv.initCancellability();
CancellableContinuation it = (CancellableContinuation)cancellable$iv;
int var7 = false;
//3.2 开始原执行方法体函数
new Thread((){
Result.Companion var1 = Result.Companion;
//3.5 模拟请求接口
String var2 = "param1";
//3.6 最后实际是调用了外面协程闭包的invokeSuspend方法,利用状态机进行下一步,这里相当于回调
this.$it.resumeWith(Result.constructor-impl(var2));
}).start();
//3.3 这里一般是返回标记为,表明当前目前该函数是挂起函数
Object var10000 = cancellable$iv.getResult();
if (var10000 == IntrinsicsKt.getCOROUTINE_SUSPENDED()) {
DebugProbesKt.probeCoroutineSuspended($completion);
}
//3.4 返回标记位(这里并没有真正的返回接口结果)
return var10000;
}
}
4.总结
至此,协程的整体基本运作流程到这里结束了,因为篇幅问题,我们先从最表象的流程原理开始讲,让大家对协程的实际运作有个基本的认识,接着我们总结一下本文章涉及的知识点
- 协程是基于CPS理念来设计的,CPS理念中核心的概念为续体与执行顺序的逻辑(步数)。
- Kotlin利用了续体和状态机与编译期修改,消除了CPS原本所产生的回调。
- 协程闭包与挂起函数,在编译阶段会对方法和闭包内的签名,包括返回值、修饰符、入参、方法实体进行修改。
- 了解了协程的基本运作流程,了解了状态机、续体、挂起、恢复具体在Kotlin协程的实际应用。
- 通过流程其实我们可以得出结论:挂起的本质是return,恢复的本质是回调(resumeWith)
- 最后还是总结一个协程的主要作用:异步的逻辑用同步的写法,保持代码的可读性与简洁
那么本篇文章到此结束了,因为协程涉及的概念、原理会比较多,本篇文章核心重点是让读者至少知道我们编写的协程与挂起函数,具体是怎么运作的。