Kotlin协程的核心概念是【挂起和恢复】

279 阅读7分钟

这是我参与「掘金日新计划 · 2 月更文挑战」的第 1 天,点击查看活动详情

进程和线程

进程:
一个虚拟机实例,进程拥有独立的数据资源,独立的内存空间

线程:
属于进程,是程序的实际执行者,一个进程至少包含一个主线程,也可有更多的子线程,线程是最小执行单元

进程和线程都是操作系统级别的

协程的概念

1、具备挂起恢复能力的代码(函数或者一段程序能够被挂起,稍后在挂起的位置恢复)

2、开发者或者程序可以自己灵活的处理挂起和恢复操作,以此实现程序执行流程的协作调度

我们多次提到了挂起的概念,那到底什么是挂起

举个简单点的例子

我们希望一段程序在1秒后发送一个消息(注意不允许使用计时器和handler,不允许阻塞当前线程)

在传统的java代码里面常见的写法

为了不阻塞线程,我们需要开启一个线程 并且在线程启动后休眠1秒然后发送消息,在发送消息的时候我们可能还需要从当前子线程切回到main方法所在的主线程

代码如下

public static void main(String[] args) {
    new Thread(() -> {
        try {
            Thread.sleep(1000);
            sendMessage();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();
}

从这个例子里面来看,事实上单独的看main方法所在的主线程和sleep所在的子线程,在线程内部都是顺序执行的,如果要实现改变执行的顺序,就比较困难

当然我们也有一些方案,比如使用消息队列,但毕竟需要大量的回调来完成代码,这并不优雅

下面我们来看下如何使用协程来完成

GlobalScope.launch(Dispatchers.Main) {
    delay(1000L)
    sendMessage()
}

在个代码中,我们创建了一个在主线程运行的协程,然后在协程内部调用delay方法,延迟了1秒钟,这个时候delay方法就会将协程处于挂起状态,处于这个状态时,协程以外的代码可以正常实行,主线程也不会被阻塞

注意这个时候对于协程本体来说,就像是在delay这个方法这里阻塞了一样,但是对于用于运行该协程的线程来说,就像是这个代码段被临时不执行了一样,用通俗点的话来说,主线程本来是在执行协程里面的的代码任务的,但是当运行到delay的时候,就将这个任务丢到一边了,去做其他事情了,等到1秒后再回来继续做delay后面的事情,这个跟sleep是有本质区别,sleep会要求当前执行的线程必须什么事也不干,等1秒后再继续做下面的事情

那有的人说,我用handler或者java提供的计时器也可以实现这个逻辑,是的确实可以,注意这里只是用更优雅的方式来完成原先繁琐的实现方式

在上述的例子用,我们用sleep,delay来替代一些实质性的任务,比如请求网络,访问数据库等操作,这些操作的一些特点都是耗时的,这些耗时的方法在协程中用suspend关键字来描述

于是有人说用suspend关键字描述的方法会阻塞当前协程内的执行,但是不会阻塞当前协程所在的线程,这种说法也是不合适的,这要看suspend方法内部的处理逻辑,如果协程体运行在main线程,suspend方法也运行在主线程,那么主线程一样会被卡住

在上述案例中,delay方法的实现是使用了计时器或者开启计时线程的方法来保证计时器不会阻塞主线程

挂起状态的定义

什么是挂起状态,当协程内部的suspend方法被执行时,协程内部的代码处理逻辑在suspend方法处被阻塞,协程所在的线程可以从当前的协程任务中解放出来继续处理协程体外的代码逻辑,等待suspend方法执行完毕后,主线程再继续执行协程体内的其他方法

比如如下代码

fun fun10() {
    Log.e("LONG", "fun10 start")
    lifecycle.coroutineScope.launch() {
    Log.e("LONG", "sleep start")
    suspendSleep()
    Log.e("LONG", "sleep end")
    }
    Log.e("LONG", "fun10 end")
}
 
fun fun11() {
    Log.e("LONG", "fun10 start")
    lifecycle.coroutineScope.launch() {
    Log.e("LONG", "sleep start")
    suspendDelay()
    Log.e("LONG", "sleep end")
    }
    Log.e("LONG", "fun10 end")
}
 
 
suspend fun suspendSleep() {
    sleep(1000)
}
 
suspend fun suspendDelay() {
    delay(1000)
}
 
 
fun10的运行结果 因为sleep占用了主线程,所以协程没有挂起或者说挂起失效,主线程依然被占用
E/LONG: fun10 start
E/LONG: sleep start
E/LONG: sleep end
E/LONG: fun10 end
 
fun11的运行结果 delay并没有占用主线程,协程挂起成功,主线程执行了协程体外的代码
E/LONG: fun11 start
E/LONG: sleep start
E/LONG: fun11 end
E/LONG: sleep end

协程作用域

作用域的概念相对比较好理解

简单来说就是协程的生命周期

对象的生命周期一版我们接触的比较多,一个对象的生命周期通常是指对象没有被引用时,会被GC系统回收掉

协程也是一样的

一个协程,如果没有作用域,那么他只有在任务完成的时候才会结束

协程在工作时需要一个作用域来持有协程,有了作用域,协程的生命周期就会变的可控

意思就是说只要作用域还存在,协程就会继续存在,除非协程完成了任务,作用域不再存在,协程也会跟着被销毁

对比我们以前用线程的时候,是不是经常遇到Activity被销毁了,但是线程访问网络的数据确返回了,然后导致各种异常的情况,这个时候我们只能在线程的回调里面去判断当前Activity是否销毁来解决

正确的使用协程作用域就可以有效避免这种问题,还能有效的减少内存的占用

常用的协程作用域有

GlobalScope 全局作用域,与应用生命周期保持同步

MainScope 主线程作用域,跟GlobalScope类似,执行线程在主线程

lifecycle.coroutineScope 与页面生命周期保持一致的作用域

ViewModelScope 与ViewModel生命周期保持一致的作用域

正常情况下 我们只需要使用如下方法进行调用即可开启一个协程并执行协程

GlobalScope.launch() {
    //协程内使用的方法必须要用suspend修饰
    functionA()
    functionB()
}

我们简单看一下launch的方法,探究一下GlobalScope是如何实现作用域控制的

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

launch方法是CoroutineScope的一个扩展函数

GlobalScope其实是继承自CoroutineScope

public object GlobalScope : CoroutineScope {
/**
* Returns [EmptyCoroutineContext].
*/
override val coroutineContext: CoroutineContext
get() = EmptyCoroutineContext
}

launch有三个参数

第一个是协程的上下文

第二个先不管

第三个是协程体的闭包

如果什么参数都不传入,就像我们示例代码里面这样,则context会给一个默认值EmptyCoroutineContext
newCoroutineContext()方法会根据传入的context生成一个新的newContext

public actual fun CoroutineScope.newCoroutineContext(context: CoroutineContext): CoroutineContext {
    val combined = foldCopies(coroutineContext, context, true)
    val debug = if (DEBUG) combined + CoroutineId(COROUTINE_ID.incrementAndGet()) else combined
    return if (combined !== Dispatchers.Default && combined[ContinuationInterceptor] == null)
        debug + Dispatchers.Default else debug
}

newCoroutineContext方法会调用foldCopies方法,注意这里传入一个coroutineContext

coroutineContext其实就是CoroutineScope接口里面的


public interface CoroutineScope {
    /**
     * The context of this scope.
     * Context is encapsulated by the scope and used for implementation of coroutine builders that are extensions on the scope.
     * Accessing this property in general code is not recommended for any purposes except accessing the [Job] instance for advanced usages.
     *
     * By convention, should contain an instance of a [job][Job] to enforce structured concurrency.
     */
    public val coroutineContext: CoroutineContext
}

GlobalScope正是重写了这个coroutineContext并且返回一个EmptyCoroutineContext

事实上这个EmptyCoroutineContext就是用来控制作用域的,当前是一个全局作用域,现阶段可以只了解这么多,目的就是要知道,作用域是可以用一个参数传入进去的

我们写一个简单的例子

第二个协程将第一个协程的返回的job做为contex参数传入,当第一个协程被cancel的时候,第二个协程同样被cancel

fun fun12() {
    val job = lifecycle.coroutineScope.launch() {
        for (i in 1..100) {
            delay(100)
            Log.e("LONG", " 1===========${i}")
            if (i == 5) {
                cancel()
            }
        }
    }
    lifecycle.coroutineScope.launch(job) {
        for (i in 1..100) {
            delay(100)
            Log.e("LONG", " 2===========${i}")
        }
    }
}