前言
协程使用好久了,总觉要对协程做一点笔记来巩固一下自己的理解。其实网上对于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的运行环境的主线程事件循环。
这里的关键信息
- "resume函数与对应的挂起函数的调用是否在相同的调用栈上",(resume函数代表恢复被挂起的线程,然后将结果返回)
- 切换函数调用栈的方法可以是切换到其他线程上执行
- 也可以是不切换线程但在当前函数返回之后的某一个时刻再执行,这个大家可以理解成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,我们使用的withContext
、suspendCoroutine
的方式都是可以顺利挂起协程的,里面最终也是调用到了suspendCoroutineUninterceptedOrReturn
的方法。
结论:
什么是kotlin协程?
kotlin协程是一个代码结构体,类似线程池里面的Runnable、Future,以及Handler里面的Message转换出来的Runnable,只不过协程结合了kotlin的编译器,封装了供了大量供开发人员方便使用的API的一种线程框架。所以协程不应该跟线程比较,就好像微信小程序和微信应用一样的关系。
协程的挂起和恢复是什么?
协程的挂起和恢复在某一个协程的作用域里面,暂停挂起当前协程,去执行另外一个协程,当被执行的协程执行完的时候,重新去恢复被挂起的协程的执行,恢复的本质就是callback,只不过编译器帮我们做了callback的封装和调用。
suspend关键字有什么用?
suspend关键字是一种结合编译器,提示开发人员,当前方法会被挂起。但是是否真正会被挂起,取决于方法体内最终是否会调用到suspendCoroutineUninterceptedOrReturn
的方法,其中delay
、withContext
、suspendCoroutine
都是google开发人员帮我们封装好方便挂起的API
PS:文章写的不好,主要是给自己在巩固协程知识点做一些记录。