ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
## 参考文章 [Kotlin 协程的挂起好神奇好难懂?今天我把它的皮给扒了](https://kaixue.io/kotlin-coroutines-2/) ## 上期回顾 大部分情况下,我们都是用`launch`函数来创建协程,其实还有其他两个函数也可以用来创建协程: * `runBlocking` * `async` `runBlocking`通常适用于单元测试的场景,而业务开发中不会用到这个函数,因为它是线程阻塞的。 接下来我们主要来对比`launch`与`async`这两个函数。 * 相同点:它们都可以用来启动一个协程,返回的都是`Coroutine`,我们这里不需要纠结具体是返回哪个类。 * 不同点:`async`返回的`Coroutine`多实现了`Deferred`接口。 关于`Deferred`更深入的知识就不在这里过多阐述,它的意思就是延迟,也就是结果稍后才能拿到。 我们调用`Deferred.await()`就可以得到结果了。 接下来我们继续看看`async`是如何使用的,先回忆一下上期中获取头像的场景: ~~~kotlin coroutineScope.launch(Dispatchers.Main) { // 👇 async 函数启动新的协程 val avatar: Deferred = async { api.getAvatar(user) } // 获取用户头像 val logo: Deferred = async { api.getCompanyLogo(user) } // 获取用户所在公司的 logo // 👇 👇 获取返回值 show(avatar.await(), logo.await()) // 更新 UI } ~~~ 可以看到 avatar 和 logo 的类型可以声明为`Deferred`,通过`await`获取结果并且更新到 UI 上显示。 `await`函数签名如下: ~~~kotlin public suspend fun await(): T ~~~ 前面有个关键字是之前没有见过的 ——`suspend`,这个关键字就对应了上期最后我们留下的一个问号:协程最核心的那个「非阻塞式」的「挂起」到底是怎么回事? 所以接下来,我们的核心内容就是来好好说一说这个「挂起」。 ## 「挂起」的本质 协程中「挂起」的对象到底是什么?挂起线程,还是挂起函数?都不对,**我们挂起的对象是协程。** 还记得协程是什么吗?**启动一个协程可以使用`launch`或者`async`函数,协程其实就是这两个函数中闭包的代码块。** `launch`,`async`或者其他函数创建的**协程,在执行到某一个`suspend`函数的时候,这个协程会被「suspend」,也就是被挂起。** **那此时又是从哪里挂起?从当前线程挂起。换句话说,就是这个协程从正在执行它的线程上脱离。** >[success]注意,**不是这个协程停下来了!是脱离,当前线程(协程所在的线程)从这行代码开始不再运行这个协程了,不再管这个协程要去做什么了**。 suspend 是有暂停的意思,但我们在协程中应该理解为:**当线程执行到协程的 suspend 函数的时候,暂时不继续执行协程代码了**。 我们先让时间静止,然后兵分两路,分别看看这两个互相脱离的线程和协程接下来将会发生什么事情: ### **线程:** 前面我们提到,**挂起会让协程从正在执行它的线程上脱离**,具体到代码其实是: **协程的代码块中,线程执行到了 suspend 函数这里的时候,就暂时不再执行剩余的协程代码,跳出协程的代码块。** 那线程接下来会做什么呢?该干嘛干嘛 如果它是一个后台线程: * 要么无事可做,被系统回收 * 要么继续执行别的后台任务 **总之,跟 Java 线程池里的线程在工作结束之后是完全一样的:回收或者再利用。** **如果这个线程它是 Android 的主线程,那它接下来就会继续回去工作:也就是一秒钟 60 次的界面刷新任务**。 什么是继续回去工作?示例如下 ~~~kotlin // 主线程中 GlobalScope.launch(Dispatchers.Main) { val image = suspendingGetImage(imageId) // 获取图片 avatarIv.setImageBitmap(image) // 显示出来 } suspend fun suspendingGetImage(id: String) = withContext(Dispatchers.IO) { ... } //相当于: handler.post { val image = suspendingGetImage(imageId) avatarIv.setImageBitmap(image) } ~~~ 首先,如果你启动一个执行主线程任务的协程,它实质上会往你的主线程post()一个新任务Runnable,这个任务Runnable就是你的协程代码需要完成的任务,那么当这个协程被挂起的时候,那实质上就是你post()的这个任务Runnable提前结束了。那这时候主线程干嘛呢?继续刷新界面呗。那剩下的代码怎么办?协程不是还没执行完么?刚才也说了,兵分两路。稍后看协程。 一个常见的场景是,获取一个图片,然后显示出来: ~~~kotlin // 主线程中 GlobalScope.launch(Dispatchers.Main) { val image = suspendingGetImage(imageId) // 获取图片 avatarIv.setImageBitmap(image) // 显示出来 } suspend fun suspendingGetImage(id: String) = withContext(Dispatchers.IO) { ... } ~~~ 这段执行在主线程的协程,它实质上会往你的主线程`post`一个`Runnable`,这个`Runnable`就是你的协程代码: ~~~kotlin handler.post { val image = suspendingGetImage(imageId) avatarIv.setImageBitmap(image) } ~~~ 当这个协程被挂起的时候,就是主线程`post`的这个`Runnable`提前结束,然后继续执行它界面刷新的任务。 关于线程,我们就看完了。 这个时候你可能会有一个疑问,那`launch`包裹的剩下代码怎么办?协程不是还没执行完么?刚才也说了,兵分两路。稍后看协程。 所以接下来,我们来看看协程这一边。 ### **协程:** ***** 线程的代码在到达`suspend`函数的时候被掐断,接下来协程会从这个`suspend`函数开始继续往下执行,不过是在**指定的线程**。 ***** **谁指定的?是`suspend`函数指定的,比如我们这个例子中,函数内部的`withContext`传入的`Dispatchers.IO`所指定的 IO 线程。** 另外在挂起函数执行完成之后,协程为我们做的最爽的事就来了,**它会自动帮我们把协程再切回来**。 #### **`Dispatchers`调度器小知识** ***** `Dispatchers`调度器,它可以将协程限制在一个特定的线程执行,或者将它分派到一个线程池,或者让它不受限制地运行,关于`Dispatchers`这里先不展开了。 那我们平日里常用到的调度器有哪些? 常用的`Dispatchers`,有以下三种: * `Dispatchers.Main`:Android 中的主线程 * `Dispatchers.IO`:针对磁盘和网络 IO 进行了优化,适合 IO 密集型的任务,比如:读写文件,操作数据库以及网络请求 * `Dispatchers.Default`:适合 CPU 密集型的任务,比如计算 ***** 回到我们的协程,它从`suspend`函数开始脱离启动它的线程,继续执行在`Dispatchers`所指定的 IO 线程。 紧接着在`suspend`函数执行完成之后,协程为我们做的最爽的事就来了:会**自动帮我们把线程再切回来**。 这个「切回来」是什么意思? 我们的协程原本是运行在**主线程**的,当代码遇到 suspend 函数的时候,发生线程切换,根据`Dispatchers`切换到了 IO 线程;这个所谓的切回来就是:当这个挂起函数执行完毕后,协程会帮我再`post`一个`Runnable`任务,让我剩下的代码继续回到主线程去执行。这就是为啥你指定线程的那个参数不叫`Threads`,而是叫做`Dispatchers`调度器,它不只是只能指定协程执行的线程,还能在suspend挂起函数之后自动再切回来。其实,也不是一定会切回来,也可以通过设置特殊的`Dispatchers`来让挂起函数执行完之后也不切回来,不过这是你自己的选择,而不是它的定位。**挂起的定位就是暂时切走,稍后再切回来**。 我们从线程和协程的两个角度都分析完成后,终于可以对协程的「挂起」suspend 做一个解释: **协程在执行到有 suspend 标记的函数的时候,会被 suspend 也就是被挂起,而所谓的被挂起,其实个开启一个协程一样,说起来比较玄乎,但其实就是切个线程;** 不过区别在于,**挂起函数在执行完成之后,协程会重新切回它原先的线程**。 再简单来讲,在 Kotlin 中所谓的挂起,其实就是**一个稍后会被自动切回来的线程调度操作**。 >[success] 这个「切回来」的动作,在 Kotlin 里叫做[resume](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.coroutines.experimental/-continuation/resume.html),恢复。 那么上期我们最后一个问题,**为什么挂起函数只能在协程里或者另一个挂起函数里面被调用?** * 首先,通过刚才的分析我们知道:**挂起之后是需要恢复**。而**恢复这个功能是协程的**,如果你不在协程里面调用,恢复这个功能没法实现。 * 另外,再细想下这个逻辑:一个挂起函数要么在协程里被调用,要么在另一个挂起函数里被调用,那么它其实就是直接或者间接地,总是会在一个协程里被调用的。 所以,**要求`suspend`函数只能在协程里或者另一个 suspend 函数里被调用,还是为了要让协程能够在`suspend`函数切换线程之后再切回来**。 ## 怎么就「挂起」了? 我们**先了解到了什么是「挂起」后,再接着看看这个「挂起」是怎么做到的**。 首先你可以写一个自定义的`suspend`函数,然后在主线程上的协程里去调用它,你会发现它还是运行在主线程,没有切换。 ~~~kotlin suspend fun suspendingPrint(Dispatchers.Main) { println("Thread: ${Thread.currentThread().name}") } launch(){ suspendingPrint() } I/System.out: Thread: main ~~~ 输出的结果还是在主线程。没有切换。 为什么没切换线程?因为它不知道往哪切,需要我们告诉它。 对比之前例子中`suspendingGetImage`函数代码: ~~~kotlin // 👇 suspend fun suspendingGetImage(id: String) = withContext(Dispatchers.IO) { ... } ~~~ 我们可以发现不同之处其实在于`withContext`函数。 其实通过`withContext`源码可以知道,它本身就是一个挂起函数,它接收一个`Dispatcher`参数,依赖这个`Dispatcher`参数的指示,你的协程被挂起,然后切到别的线程。 所以这个`suspend`,其实并不是起到把任何把协程挂起,或者说切换线程的作用。还需要你在挂起函数里面去调用另外一个挂起函数,而且里面这个挂起函数需要是协程自带的、内部实现了协程挂起代码的,或者它不是自带的,但它的内部直接或者间接地调用了某一个自带的挂起函数,这也是可以的,总之你最终需要调用到一个自带的挂起函数,让它来去真正做挂起,也就是线程切换的工作。 **真正挂起协程这件事,是 Kotlin 的协程框架帮我们做的**。也就是说**所谓的协程被挂起或者说切线程这件事,它并不是发生在你外部这个挂起函数被调用的时候,而是里面那个挂起函数,那个`withContext`函数被调用的时候**。 所以我们**想要自己写一个挂起函数,仅仅只加上`suspend`关键字是不行的,还需要函数内部直接或间接地调用到 Kotlin 协程框架自带的`suspend`函数(挂起)才行**。 >[info]备注:自带的挂起函数不只是`withContext()`一个,还有其他的,他们都能实现协程的挂起,而我们要想自己写一个自定义的挂起函数,就需要在这个自定义的挂起函数内部直接或者间接地去调用到某一个自带的挂起函数才行。 ## suspend 的意义? 这个`suspend`关键字,**既然它并不是真正实现挂起,那它的作用是什么?** **它其实是一个提醒。** 谁对谁的提醒? **函数的创建者对函数的调用者的提醒:我是一个耗时函数,我被我的创建者用挂起的方式放在后台运行,所以请在协程里调用我。表面上它是一个要求,你需要在协程里调用我,但本质上,它其实是一个提醒——我是一个被自动放在后台运行的耗时函数,所以你需要在协程里调用我** 这个提醒又有什么作用呢?**它能让我们的主线程不卡**,对比我们在写Java代码时,在主线程做事需要非常小心,一不留神,我们在主线程调用了一个耗时方法,那就会卡一下,而且这种事情是很难避免的。我又不知道哪个方法会耗时?又不是我写的,就算是我写的,万一哪天给忘了呢?而**协程通过挂起函数这种方式,它把耗时任务切线程这个工作,实际上交给了函数的创建者,而不是调用者。对于调用者而言,事情非常简单,它只会收到一个提醒,你需要把我放在协程里面,剩下的其他调用者都不用管,而通过`suspend`关键字这种方式,它实际上作为一个提醒,是形成了一种机制,一种让所有耗时任务全都自动放在后台执行的机制,那么主线程是不是就不卡了,所以为什么`suspend`关键字并没有实际去操作挂起,但 Kotlin 却把它提供出来让我们使用?因为它本来就不是用来操作挂起的**。 **挂起的操作 —— 也就是切线程,依赖的是挂起函数里面的实际代码,而不是这个关键字**。 所以这个关键字,**只是一个提醒**。 还记得刚才我们尝试自定义挂起函数的方法吗? ~~~kotlin // 👇 redundant suspend modifier suspend fun suspendingPrint() { println("Thread: ${Thread.currentThread().name}") } ~~~ ![](https://img.kancloud.cn/e1/be/e1be27eb1139c204f12a7d1d0a3a1801_865x91.png) 如果你创建一个`suspend`函数但它内部不包含真正的挂起逻辑,编译器会给你一个提醒:`redundant suspend modifier`,告诉你这个`suspend`是多余的。 因为你这个函数实质上并没有发生挂起,那你这个`suspend`关键字只有一个效果:就是限制这个函数只能在协程里被调用,如果在非协程的代码中调用,就会编译不通过。 所以,**创建一个`suspend`函数,为了让它包含真正挂起的逻辑,要在它内部直接或间接调用 Kotlin 自带的`suspend`函数,你的这个`suspend`才是有意义的**。 ## 怎么自定义 suspend 函数? 在了解了`suspend`关键字的来龙去脉之后,我们就可以进入下一个话题了:怎么自定义`suspend`函数。 这个「怎么自定义」其实分为两个问题: * 什么时候需要自定义`suspend`函数? * 原则:耗时 * 具体该怎么写呢? ### 什么时候需要自定义 suspend 函数 如果你的某个函数比较耗时,也就是要等的操作,那就把它写成`suspend`函数。这就是原则。 **耗时操作一般分为两类:I/O 操作和 CPU 计算工作。比如文件的读写、网络交互、图片的模糊处理,都是耗时的,通通可以把它们写进`suspend`函数里。** **另外这个「耗时」还有一种特殊情况,就是这件事本身做起来并不慢,但它需要等待,比如 5 秒钟之后再做这个操作。这种也是`suspend`函数的应用场景**。 ### 具体该怎么写 给函数加上`suspend`关键字,然后在`withContext`把函数的内容包住就可以了。 提到用`withContext`是因为它在挂起函数里功能最简单直接:把线程自动切走和切回。 当然并不是只有`withContext`这一个函数来辅助我们实现自定义的`suspend`函数,别的挂起函数功能总会比它多一些或者少一些,比如还有一个挂起函数叫`delay`,它的作用是等待一段时间后再继续往下执行代码。 使用它就可以实现刚才提到的等待类型的耗时操作: ~~~kotlin suspend fun suspendUntilDone() { while (!done) { delay(5) } } ~~~ 这些东西,在我们初步使用协程的时候不用立马接触,可以先把协程最基本的方法和概念理清楚。 ## 总结 我们今天整个文章其实就在理清一个概念:什么是挂起?**挂起,就是一个稍后会被自动切回来的线程调度操作。** 好,关于协程中的「挂起」我们就解释到这里。 可能你心中还会存在一些疑惑: * 协程中挂起的「非阻塞式」到底是怎么回事? * 协程和 RxJava 在切换线程方面功能是一样的,都能让你写出避免嵌套回调的复杂并发代码,那协程还有哪些优势,或者让开发者使用协程的理由? 这些疑惑的答案,我们都会在下一篇中全部揭晓。