kotlin协程的挂起和恢复以及suspend关键字的作用

前言

协程使用好久了,总觉要对协程做一点笔记来巩固一下自己的理解。其实网上对于kotlin协程的文章已经有很多的,这些文章也帮助了我认识和了解kotlin的协程本质是什么。这里十分感谢网上各位大神的分享,包括《深入理解kotlin协程》这本书,以及扔物线大神。 本次文章的重点不是kotlin协程的使用,而是帮助那些原先跟我一样对kotlin协程比较迷茫的同学去理解kotlin协程的基本工作原理,什么是协程?什么是挂起和恢复?suspend关键字到底有什么用?

什么是协程?

以下摘了很多网上的一些介绍。

协程是轻量级的线程
协程又称微线程
协程,英文Coroutines,是一种基于线程之上,但又比线程更加轻量级的存在,这种由程序员自己写程序来管理的轻量级线程叫做『用户空间线程』,具有对内核来说不可见的特性。
协程,又叫纤程(Fiber),或者绿色线程(GreenThread)。

其实,上述的说法在描述上来说,并没有什么毛病,协程是一种协作式的代码程序,可以让开发人员自己去进行人为控制的一种框架。他是一种开发理念,但是在不同的开发平台是有区别的,例如:Go语言的协程,kotlin的协程等等。 因此,我们抛开开发平台只谈协程是不行的,就好比如,你让一个Go语言的开发和kotlin的开发去争论协程的原理和特性是没有意义的,因为开发平台是不同。因此,我们下面讲的协程,全都指kotlin的协程。

首先,为什么叫协程?什么是协作?

其实这个话题,本来我是不打算要讨论的,因为本身kotlin的协程就是在线程的基础上封装的线程框架,但是我觉得聊到协程的话,不说这个,又感觉少了点什么。们首先拿线程的特性来说明,我们都知道,在JVM当中,线程是CPU调度的基本单位,它运行在内核态,线程的执行不能靠Java开发人员的人为的干预,即便可以提高线程的优先级,也不能保证线程一定是优先执行的,所以,线程是抢占式的。(注意,这里不是说线程和协程就是一个维度的东西,其实他们不是同级别,而是上下级的关系,后面我会详细讲解的)。下面看一个例子:

javascript 复制代码
// 定义一个线程,优先级为50,并且启动它
thread(priority = 50) { 
    Log.i(TAG,"start thread 1")
}
// 定义一个线程,优先级为100,并且启动它
thread(priority = 100) {
    Log.i(TAG,"start thread 2")
}

经过几次的运行,两个线程的执行顺序并不是一定的。所以线程是抢占式的。当然,同学们可能会说

javascript 复制代码
// 定义一个线程,优先级为50,并且启动它
thread(priority = 50) { 
    Log.i(TAG,"start thread 1")
    // 定义一个线程,优先级为100,并且启动它
    thread(priority = 100) {
        Log.i(TAG,"start thread 2")
    }
}

我这样写,不就是能保证顺序的一致性了嘛?其实呢,我举前面那个例子只是告诉大家,什么是抢占?并不是说,我们使用线程就一定不能保证顺序性。这里旨在让大家了解抢占。

那么协程如何实现协作式的呢?这里举个例子:

scss 复制代码
import kotlinx.coroutines.*

fun main() = runBlocking {
    // 启动第一个协程
    val job1 = launch(Dispatchers.Default) {
        repeat(10) { i ->
            println("Job1: I'm working step $i...")
            delay(200L)
        }
    }
    // 等待协程1完成
    job1.join()
    // 启动第二个协程
    val job2 = launch(Dispatchers.Default) {
        repeat(10) { i ->
            println("Job2: I'm working step $i...")
            delay(300L)
        }
    }

    // 等待协程2完成
    job2.join()

    println("main: All jobs are completed.")
}

上述是一个比较简单的协程的例子,我们可以在开发过程中,去控制协程的执行前后顺序。 其实,上述的例子也并不准确,因为本身拿协程来跟线程比是不对的,因为协程本事就是基于线程的,它的执行线程顺序也是依赖线程的,所以我只能说,在开发的代码层面上,协程是一个封装了线程的线程池框架,它提供丰富的API结合了kotlin的编译器语法来帮助我们更好的处理异步协作编程。以前没有协程的时候,我们也能靠线程池、Handler、AsyncTask、IntentService等等异步编程的框架来实现我们的代码,所以,在协作这个方式上,其实协程就是帮我们封装了各个可能遇到的场景的线程框架。

为什么要说协作呢,因为我们后面要讲的就是协程保证协作的重要的两个概念,挂起和恢复。

挂起和恢复

什么是挂起?

在编程中,线程挂起是指暂停当前线程的执行,而将控制权交给其他线程或进程。这通常发生在多线程环境中,当一个线程需要等待某个条件成立(例如,等待资源可用或等待其他线程完成工作)时,它可以选择挂起自己,让出 CPU 给其他线程使用。

举个Java的例子,我们通常使用wait和notify/notifyAll的组合去说明一个线程的挂起和恢复

csharp 复制代码
public class ThreadExample {
    public static void main(String[] args) {

        final Object lock = new Object();// 定义一个lock变量来实现锁竞争

        Thread thread1 = new Thread(() -> {
            synchronized (lock) {// 线程1获取锁
                try {
                    System.out.println("Thread 1: Holding lock...");
                    System.out.println("Thread 1: Pausing (calling wait())...");
                    lock.wait();// 当前线程被挂起
                    System.out.println("Thread 1: Resumed.");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (lock) {// 线程2获取锁
                try {
                    System.out.println("Thread 2: Holding lock...");
                    System.out.println("Thread 2: Pausing for 5 seconds...");
                    Thread.sleep(5000);// 模拟耗时操作
                    System.out.println("Thread 2: Calling notify()");
                    lock.notify();// 重新恢复thread1
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

不过,java的wait实际上是会阻塞当前的线程。我们再看个android的模拟挂起恢复的例子:

kotlin 复制代码
// 定义请求方法
fun requestHttp(
    requestParams : Map<String,String>, 
    block : (RequestResult) -> Unit
) = requestHttpReal(requestParams,block) // 这里模拟真实的请求
scss 复制代码
val requestParams = assembleRequestParams() // 这里是主线程
thread {                                    // 这里会"挂起"当前的线程
    requestHttp(requestParams){             // 这里是IO线程,进行http请求
        requestResult->{
            runOnUiThread{ updateUI(requestResult) }     // 这里会"恢复"当前的线程,切换回主线程,进行UI刷新
        }
    }
}
doSomethingElse()

其实,上述的例子其实也不是非常恰当,因为,虽然看似主线程被挂起了,但是其实主线程还是在执行的,下面的doSomethingElse() 还是会在主线程继续执行。

协程的挂起和恢复:

我们来看一个协程的挂起和恢复例子

定义请求方法,这里需要在方法前增加suspend的关键字

kotlin 复制代码
suspend fun requestHttp(
    requestParams : Map<String,String>, 
    block : (RequestResult) -> Unit
) = requestHttpReal(requestParams,block) // 这里模拟真实的请求
scss 复制代码
val requestParams = assembleRequestParams()
GlobalScope.launch{ // 开启一个全局协程Coroutine-1(实际开发环境当中不建议使用) ,在main主线程
    val requestResult = withContext(Dispatchers.IO){ // 开启一个协程Coroutine-2,并将该协程放在异步线程里面执行,并挂起Coroutine-1
        requestHttp(requestParams) // 非阻塞的等待 1 秒钟(默认时间单位是毫秒),该方法用suspend关键字修饰
    }
    updateUI(requestResult) // 恢复Coroutine-1的执行,进行UI刷新
}
doSomethingElse()

这里可能大家就问了,上面的第3行代码是在异步线程IO上执行的,而第6行代码是在main主线程里面执行的,为什么第6行代码会等待第二行代码执行完之后进行?这里,其实就是kotlin编译器帮我们做的事情,结合协程,他让我们能用同步的方式写异步的代码,避免了回调地狱。这里有点跑题了,我们来逐个分析下,这里的执行过程。

  • 首先,我们使用了GlobalScope#launch()的方式创建了一个协程Coroutine-1,尽管这个协程是在主线程运行,但是他具备了协程的挂起和恢复的能力。
  • 然后,当我们执行withContext(Dispathchers.IO)的时候,其实我们又开启了第二个协程Coroutine-2
  • 因为withContext函数的特性,它会挂起当前的协程(协程Coroutine-1),此时会将我们的执行点进行一个上下文记录(这个记录包括协程执行的代码点,所处的线程以及其他的上下文数据),也就是第6行代码代码之前,停止(协程Coroutine-1)的执行,转而去执行Coroutine-2里面的代码,也就是第4的代码,
  • 由于withContext里面制定的调度器是Dispatchers.IO,协程框架会自动进行线程切换
  • 等待第4行代码执行完毕的时候,会根据之前的Coroutine-1的记录的代码点和所处的线程,重新回到第6行代码上根据之前记录的所在线程去继续执行,这里的协程框架也会帮我们把线程自动切换回主线程。
  • 另外,上面代码里面的第8行,因为不在协程内部,所以,他不会被挂起,也就是说,他不会等待withContext里面的代码,而是直接继续执行

看到这里大家是不是觉得,和上面的runOnUiThread的Handler很相似?

所以,协程(协程体)本质上是一段代码结构,大家可以近似理解为一个Runnable,只不过这个"Runnable"比较特殊,能被协程框架记录上下文,这里的上下文包括代码执行的位置,所处的线程以及其他的额外资源,然后在需要的时候重新唤起。而协程的调度器就类似于线程池一样的东西。无非是编译器和协程的框架帮我们做了这件事情。而协程的恢复,其实本质上就是callback,他会将后面还没执行完的那部分代码打包成一个callback,然后继续执行。这个callback的封装类似下方:

kotlin 复制代码
fun coroutineRequestCallback(requestResult : RequestRequest?){
    requestResult?.let{
        updateUI(requestResult)
    }
}

看到这里,大家应该注意到了,上述的requestHttp的方法,在协程当中使用的时候,我加上了suspend的修饰符,使用过协程的同学都知道,suspend修饰的方法只能被另外的suspend方法或者协程调用。那么为什么要加suspend的修饰符,suspend的作用是什么?我们接下来就详细讨论一下。 首先,suspend是kotlin引入的一个关键字,用于表示当前的方法是一个挂起函数,只有用了suspend修饰符的方法,他才有可能会被挂起,这里请注意,是有可能,并不是一定会被挂起。那么挂起的必要条件是什么?我这里摘录了《深入理解kotlin协程》的3.3.2 挂起点的一句话:

异步调用是否发生,取决于resume函数与对应的挂起函数的调用是否在相同的调用栈上,切换函数调用栈的方法可以是切换到其他线程上执行,也可以是不切换线程但在当前函数返回之后的某一个时刻再执行。前者比较容易理解,后者其实通常就是先将Continuation的实例保存下来,在后续合适的时机再调用,存在事件循环的平台很容易做到这一点,例如第7章讲到的Android平台的主线程Looper和9.1.2节讲到的JavaScript的运行环境的主线程事件循环。

这里的关键信息

  1. "resume函数与对应的挂起函数的调用是否在相同的调用栈上",(resume函数代表恢复被挂起的线程,然后将结果返回)
  2. 切换函数调用栈的方法可以是切换到其他线程上执行
  3. 也可以是不切换线程但在当前函数返回之后的某一个时刻再执行,这个大家可以理解成android里面的handler的方式。

那么resume函数是什么?其实,上面用到的kotlin的协程写法,是已经封装过的方法,它将构造协程体、结果返回、创建协程、执行协程全都进行了一次封装。所以,我们来看一下基础kotlin标准的协程库的使用方式。

kotlin 复制代码
// 1 构造协程体,这里是挂起函数,使用suspend标记当前的函数体允许被挂起
val f : suspend () -> Int = {
    log("In Coroutine.")
    999
}
// 2 协程体内代码之后,进行的结果
val completion = object : Continuation<Int> {
    override fun resumeWith(result: Result<Int>) {
        log("Coroutine End: $result")
        // 拿到协程返回的结果,这是恢复函数,也就是resume
        val resultOrThrow = result.getOrThrow()
        // todo
    }
    override val context: CoroutineContext = EmptyCoroutineContext
}
// 3 创建协程
val createCoroutine = f.createCoroutine(completion)
// 4 执行协程
createCoroutine.resume(Unit)

当调用createCoroutine.resume(Unit) 的时候,协程库会自动调用被suspend标记的block f里面的代码,然后将执行完之后的结果给到resumeWith(result: Result)

看到这里,如何理解"resume函数与对应的挂起函数的调用是否在相同的调用栈上"? 解答这个问题之前,我先将上面的代码进行一段改造

可以到,我将suspend对应的函数体提出来封装成了一个方法,发现,suspend变空了。他的提示如下:

编译器推荐我们直接移除关键字suspend。为什么呢?之前我在前面说到,suspend是一个修饰符,虽然告知开发者,这个方法是一个挂起方法,但是他不一定会挂起,在结合上面的"resume函数与对应的挂起函数的调用是否在相同的调用栈上",很明显,ff()方法不具备挂起的条件,也就是说,"他的resume函数和挂起函数的调用时在相同的调用栈上",那么我们改一下ff里面的实现

我将log方法改成了协程当中的delay方法,结果suspend关键字就起作用了,也就是说,delay是一个可以被挂起的函数,他会将resume函数与对应的挂起函数的调用放在不同的调用栈上 ,那么为什么delay方法可以被挂起呢?

我们查看一下delay方法的实现

发现,其实deley方法也是被suspend修饰,且suspend是生效的,delay其实里面的实现是调用了suspendCancellableCoroutine

发现,其实suspendCancellableCoroutine方法也是被suspend修饰,且suspend是生效的,suspendCancellableCoroutine其实里面的实现是调用了suspendCoroutineUninterceptedOrReturn ,继续跟下去

发现suspendCoroutineUninterceptedOrReturn里面的实现细节已经是throw Error的方式,所以我们可以初步断定,调用了suspendCoroutineUninterceptedOrReturn是suspend生效,然后挂起协程的关键点(网上已经有很多分析suspendCoroutineUninterceptedOrReturn相关的文章了,这里先不做具体的分析)。

除了delay,我们使用的withContextsuspendCoroutine的方式都是可以顺利挂起协程的,里面最终也是调用到了suspendCoroutineUninterceptedOrReturn的方法。

结论:

什么是kotlin协程?

kotlin协程是一个代码结构体,类似线程池里面的Runnable、Future,以及Handler里面的Message转换出来的Runnable,只不过协程结合了kotlin的编译器,封装了供了大量供开发人员方便使用的API的一种线程框架。所以协程不应该跟线程比较,就好像微信小程序和微信应用一样的关系。

协程的挂起和恢复是什么?

协程的挂起和恢复在某一个协程的作用域里面,暂停挂起当前协程,去执行另外一个协程,当被执行的协程执行完的时候,重新去恢复被挂起的协程的执行,恢复的本质就是callback,只不过编译器帮我们做了callback的封装和调用。

suspend关键字有什么用?

suspend关键字是一种结合编译器,提示开发人员,当前方法会被挂起。但是是否真正会被挂起,取决于方法体内最终是否会调用到suspendCoroutineUninterceptedOrReturn的方法,其中delaywithContextsuspendCoroutine都是google开发人员帮我们封装好方便挂起的API

PS:文章写的不好,主要是给自己在巩固协程知识点做一些记录。

相关推荐
SRC_BLUE_171 小时前
SQLI LABS | Less-39 GET-Stacked Query Injection-Intiger Based
android·网络安全·adb·less
无尽的大道5 小时前
Android打包流程图
android
镭封6 小时前
android studio 配置过程
android·ide·android studio
夜雨星辰4876 小时前
Android Studio 学习——整体框架和概念
android·学习·android studio
邹阿涛涛涛涛涛涛6 小时前
月之暗面招 Android 开发,大家快来投简历呀
android·人工智能·aigc
IAM四十二6 小时前
Jetpack Compose State 你用对了吗?
android·android jetpack·composer
奶茶喵喵叫7 小时前
Android开发中的隐藏控件技巧
android
Winston Wood9 小时前
Android中Activity启动的模式
android
众乐认证9 小时前
Android Auto 不再用于旧手机
android·google·智能手机·android auto
三杯温开水9 小时前
新的服务器Centos7.6 安卓基础的环境配置(新服务器可直接粘贴使用配置)
android·运维·服务器