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:文章写的不好,主要是给自己在巩固协程知识点做一些记录。

相关推荐
笑鸿的学习笔记6 分钟前
ROS2笔记之服务通信和基于参数的服务通信区别
android·笔记·microsoft
8931519601 小时前
Android开发融云获取多个会话的总未读数
android·android开发·android教程·融云获取多个会话的总未读数·融云未读数
zjw_swun1 小时前
实现了一个uiautomator玩玩
android
pengyu2 小时前
系统化掌握Dart网络编程之Dio(二):责任链模式篇
android·flutter·dart
水w2 小时前
【Android Studio】如何卸载干净(详细步骤)
android·开发语言·android studio·activity
亦是远方2 小时前
2025华为软件精英挑战赛2600w思路分享
android·java·华为
jiet_h2 小时前
深入解析KSP(Kotlin Symbol Processing):现代Android开发的新利器
android·开发语言·kotlin
清晨細雨2 小时前
UniApp集成极光推送详细教程
android·ios·uni-app·极光推送
Li_na_na012 小时前
解决安卓手机WebView无法直接预览PDF的问题(使用PDF.js方案)
android·pdf·uni-app·html5
CYRUS_STUDIO2 小时前
Frida Hook Native:jobjectArray 参数解析
android·c++·逆向