Kotlin 协程基础系列:
Kotlin 协程基础四 —— CoroutineScope 与 CoroutineContext
本系列文章作为 Kotlin 协程的基础讲解,会介绍协程的概念、用法与适用场景,此外还会介绍与协程紧密相关的 Flow 的相关知识。首篇会先介绍协程的基本概念与使用方法。
需要先声明的是,由于不同平台对 Kotlin 协程的底层实现是不一样的,对于 Android 开发者而言,我们主要谈论的是针对 JVM 上的 Kotlin 协程。
1、协程的作用
先来了解一下协程是什么:
- 协程本来是与线程并列的概念,都是并发管理工具。只不过有的语言用线程(如 Java)管理并发,有的用协程(如 Kotlin),有的二者都用
- Kotlin 协程实际上是将 Java 线程封装成一套新的 API 来管理并发。因为 Kotlin 的特殊性在于它是一门中间语言,其代码最终还是要编译成 Java 字节码(前面说过,我们研究的是 JVM 的 Kotlin 协程),所以 Kotlin 是无法绕过线程来创建新的并发实现的
为什么已经有了线程,还要用协程?一句话概括,协程更好用,最直接的体现就是可以用线性结构来写并发代码。比如传统方式为了执行一个耗时任务,需要在子线程中运行该耗时任务,拿到结果后需要先切回主线程,再通过接口回调把执行结果传给主线程调用处。但是使用协程可以通过线性结构的代码就完成上述任务,无需使用回调代码。
实际上协程所有的优点都是基于“可以用线性结构来写并发代码”这一点的,比如协程比线程轻(协程实际上是基于线程的,怎么可能比线程轻,只不过是因为写法简单了)、结构化并发等等。
关于“协程比线程轻”这种说法,很常见,因为 Kotlin 官方文档介绍协程时就说了“协程是轻量级的线程”,协程比 JVM 线程更省资源。这里可以理解为,协程可以用更简单的代码,达到与使用线程相同的效果。
2、线程的切换
前面提到,协程与线程都是并发管理工具,而并发管理涉及到的无外乎三件事:
- 线程切换:先切换到子线程执行耗时任务,得到任务结果后,再切回主线程传回任务结果
- 线程同步:在各个线程执行的过程中等待别的线程,属于线程之间的流程配合
- 线程安全:通过互斥锁等机制保护线程间的共享资源
无论从线程还是协程的角度,都是这三件事,我们先说最基本的 —— 切线程。当然,由于协程与线程有着千丝万缕的关系,在介绍协程时,通常我们会用线程对比的方式来进行。
2.1 传统切线程的方式
传统方式执行后台任务,通常都是启动一个子协程:
thread {
...
}
或者使用线程池:
val executor = Executors.newCachedThreadPool()
executor.execute()
对于 Android 客户端而言,在后台线程执行完毕拿到结果后,需要切回到主线程更新 UI,此时可以使用主线程的 Handler 或 View 进行切换:
val handler = Handler(Looper.getMainLooper())
handler.post { }
val view: View = findViewById(R.id.xxx)
view.post { }
2.2 启动协程
使用协程需要添加协程依赖,如果你是做服务端的,那么添加协程的核心依赖即可:
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1'
对于 Android 开发而言,还需再加一项:
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
最基本的协程启动方式:
// EmptyCoroutineContext 是一个空的协程上下文,CoroutineContext 后续会详解
val scope = CoroutineScope(EmptyCoroutineContext)
scope.launch { }
这样会使用默认线程执行任务,当然,协程通过“管理任务执行的线程”的工具 —— ContinuationInterceptor 来进行线程切换。Kotlin 官方提供了 4 个实现类来帮助我们完成“切线程”的任务:
/**
* 4 个实现类来自两种类型:CoroutineDispatcher 和 MainCoroutineDispatcher。
* 抽象类 CoroutineDispatcher 实现了接口 ContinuationInterceptor,
* 而 MainCoroutineDispatcher 继承 CoroutineDispatcher,也是一个抽象类。
*/
public actual object Dispatchers {
// 默认调度器,用于处理计算密集型任务
@JvmStatic
public actual val Default: CoroutineDispatcher = DefaultScheduler
// 切到主线程执行任务
@JvmStatic
public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher
// 不限制,引申为不进行线程管理。具体表现在,启动协程时不会切线程,在挂起函数执行完之后也不会自动
// 把线程切回去(其他三个会),而是继续在挂起函数所在的线程执行后续代码。实际开发中几乎不会用到
@JvmStatic
public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined
// 用于处理 IO 密集型任务
@JvmStatic
public val IO: CoroutineDispatcher = DefaultIoScheduler
}
Default 与 IO 的主要区别在于它们使用的线程池的大小:
- Default 使用的线程池的核心线程数大小为 CPU 的核心数。即对于 N 核心的 CPU,Default 会创建一个核心线程数为 N 的线程池。不需要更多的线程,因为数量相等时已经可以让每个核心都跑一个线程了,实现满载。如果创建更多线程,会在某个核心上出现线程切换,我们知道线程的上下文切换的开销是很大的,这样效率不仅没有提高,反而降低了
- IO 在一般情况下(核心数不超过 64)会开 64 个线程,如果 CPU 核心数大于 64,那么就有多少核心开多少线程。之所以开这么多,是因为 IO 密集型任务的主要工作都在磁盘或网络执行,CPU 只是在开启任务和接收结果时会做一些工作,因此 CPU 在整个 IO 工作过程中是很闲的。为了充分利用 CPU,在 IO 密集型任务中可以多开一些线程让 CPU 得以充分利用
开启协程时可以在 CoroutineScope 或 launch() 传入指定的调度器:
// 1.默认情况,使用 Default
val scope = CoroutineScope(EmptyCoroutineContext)
scope.launch { }
// 2.对所有通过 scope1 启动的协程使用 IO 线程池
val scope1 = CoroutineScope(Dispatchers.IO)
scope1.launch { }
scope1.launch { }
// 3.仅对传入 IO 的 launch 使用 IO 线程池,对 scope2 下其他
// 未指明的 launch 使用 Default
val scope2 = CoroutineScope(EmptyCoroutineContext)
scope2.launch(Dispatchers.IO) { }
2.3 自定义线程池
除了系统提供的 4 个调度器,使用者也可以自定义线程池上下文供协程使用。比如通过 newFixedThreadPoolContext() 创建一个固定数量的线程池的上下文:
@DelicateCoroutinesApi
public actual fun newFixedThreadPoolContext(nThreads: Int, name: String): ExecutorCoroutineDispatcher {
require(nThreads >= 1) { "Expected at least one thread, but $nThreads specified" }
val threadNo = AtomicInteger()
val executor = Executors.newScheduledThreadPool(nThreads) { runnable ->
val t = Thread(runnable, if (nThreads == 1) name else name + "-" + threadNo.incrementAndGet())
t.isDaemon = true
t
}
return executor.asCoroutineDispatcher()
}
将这个上下文传给 CoroutineScope 再启动协程:
val context = newFixedThreadPoolContext(4, "MyThread")
val scope = CoroutineScope(context)
scope.launch {}
注意 newFixedThreadPoolContext() 被标记为 @DelicateCoroutinesApi,delicate 译为“虚弱的、易碎的”,目的是提醒我们使用它容易犯错,易错点就在于返回值类型 ExecutorCoroutineDispatcher 上:
public abstract class ExecutorCoroutineDispatcher: CoroutineDispatcher(), Closeable {
/** @suppress */
@ExperimentalStdlibApi
public companion object Key : AbstractCoroutineContextKey<CoroutineDispatcher, ExecutorCoroutineDispatcher>(
CoroutineDispatcher,
{ it as? ExecutorCoroutineDispatcher })
/**
* Underlying executor of current [CoroutineDispatcher].
*/
public abstract val executor: Executor
/**
* Closes this coroutine dispatcher and shuts down its executor.
*
* It may throw an exception if this dispatcher is global and cannot be closed.
*/
public abstract override fun close()
}
实现了 Closeable 的对象在使用完毕后需要调用 close() 进行关闭,这一点容易被使用者忽略,因此才打了 @DelicateCoroutinesApi 注解提醒使用者。那为什么 Dispatchers 中提供的 Default 和 IO 不用手动关闭呢?因为那些是全局的,永久存活,不需要使用者手动关闭。
与 newFixedThreadPoolContext() 类似的还有一个 API newSingleThreadContext():
@DelicateCoroutinesApi
public actual fun newSingleThreadContext(name: String): ExecutorCoroutineDispatcher =
newFixedThreadPoolContext(1, name)
2.4 withContext() 手动切线程
前面讲过的 launch() 以及后续要讲的 async() 都是并行切线程,而 withContext() 则是串行切线程。
举个例子:
CoroutineScope(Dispatchers.Main).launch {
// 1
launch(Dispatchers.IO) {
delay(100)
// 2
}
// 3
}
最外面用 CoroutineScope 的 launch() 启动的协程我们称为父协程,在里面用 launch() 启动的协程我们称为子协程。子协程启动后,父协程会继续执行后续代码,而不会等待子协程内容执行完再继续。因此上述代码的执行顺序为 1 -> 3 -> 2。
launch() 就是一个协程的启动器,它将协程启动后,launch() 使命也就结束了,其 lambda 代码块中的代码会由协程负责执行,launch() 不会等到协程运行完这些代码再结束,而是在启动协程后就结束了。
使用 withContext() 这个挂起函数可以达到串行执行的效果:
CoroutineScope(Dispatchers.Main).launch {
// 1
withContext(Dispatchers.IO) {
// 2
}
// 3
}
由于挂起函数在运行完之前,协程不会运行内部的其他内容,因此此处的执行顺序为 1 -> 2 -> 3。
我们马上就要介绍挂起函数,但是这里先提一下,withContext() 能实现串行切线程的本质。
withContext() 能实现串行切协程,并不是这个函数单独实现了某种特殊功能,而是因为它是一个挂起函数。挂起函数的特性就是在运行时,协程需要等待该挂起函数运行完毕后才能继续执行后续内容,也就是协程在运行挂起函数时被挂起了,运行完毕后再恢复。所以才能达到串行切协程的效果。
需要注意区分的是,协程被挂起时,会让出当前线程的执行权,让该线程运行其他协程的内容。因此协程挂起不会阻塞线程,不会影响线程的运行。
3、挂起函数
3.1 让线程自动切回来
在后台线程执行完任务自动切回原线程是通过挂起函数实现的。挂起函数的特性是把协程与线程暂时分离,在挂起函数结束之后再切回到线程,因此挂起函数只能在协程中调用才有意义。
挂起函数通过自动切回原来的线程消除了回调,这样就简化了代码结构从而大大降低了写并发代码的难度。
比如说,通过 Retrofit 发送网络请求,传统方式需要通过回调切换回原来的线程,并且如果有需求,需要拿到前一次的结果之后再发出二次请求,还会有嵌套回调甚至回调地狱的情况发生:
const val GITHUB_API = "https://api.github.com"
data class Contributor(val login: String, val contributions: Int)
interface GitHub {
@GET("/repos/{owner}/{repo}/contributors")
fun contributorsCall(
@Path("owner") owner: String,
@Path("repo") repo: String
): Call<List<Contributor>>
}
执行异步任务代码如下:
private fun callbackStyle() {
gitHub.contributorsCall("square", "retrofit")
// enqueue 会进入子线程发送网络请求,在拿到结果通过回调返回
// 结果之前,再切回到当前线程之中
.enqueue(object : Callback<List<Contributor>> {
override fun onResponse(
call: Call<List<Contributor>>,
contributors: retrofit2.Response<List<Contributor>>
) {
// 倘若这里需要利用 contributors 发出二次请求,那么就会出现嵌套的回调
showContributors(contributors)
}
override fun onFailure(p0: Call<List<Contributor>>, p1: Throwable) {
TODO("Not yet implemented")
}
})
}
假如使用挂起函数与协程:
interface GitHub {
// 返回值直接就是数据,而不是 Call<List<Contributor>>
@GET("/repos/{owner}/{repo}/contributors")
suspend fun contributors(
@Path("owner") owner: String,
@Path("repo") repo: String
): List<Contributor>
}
执行请求发现代码大大减少了:
private fun coroutineStyle() = CoroutineScope(Dispatchers.Main).launch {
// 在子线程中执行 gitHub.contributors(),执行完毕后回到主线程赋值,然后进行后续代码
val contributors = gitHub.contributors("square", "retrofit")
showContributors(contributors)
}
执行挂起函数的协程会被暂停,让出所在的线程,与该线程暂时脱离。此时挂起函数切换到 launch() 指定的线程,在该线程中执行网络请求,完成后切换回原来的线程,协程恢复(resume),执行后续代码。
按照这种说法,上面的例子,contributors() 这个挂起函数实际上也是在主线程中启动的,只不过由于 Retrofit 对 Kotlin 的挂起函数有一个优化,它会将挂起函数中的网络请求通过适配器 SuspendForBody 转换成一个通过 OkHttp 的线程池中的子线程发出的请求。相当于框架帮助使用者实现了线程切换,无需在执行网络请求前手动切换线程了。
由于挂起函数会挂起协程,因此挂起函数在协程里调用才有意义。或者在其他挂起函数中被调用,间接的在协程中被调用。所以才有挂起函数只能在协程或其他挂起函数中调用的规则。
由于挂起函数能自动切回原线程,因此就不用再回调了,从而简化了代码形式。
3.2 自定义挂起函数
使用 suspend 关键字即可定义挂起函数,问题是,什么时候需要把一个函数写成挂起函数呢?通常是内部会调用到其他挂起函数的时候,有点“被迫”地声明为挂起函数的意味。这是因为挂起函数只能在协程或其他挂起函数内调用,正是因为这个限制使得挂起函数的使用范围收窄了,在你需要调用到其他挂起函数时,为了满足这一限制,才需要把自己也声明为挂起函数。
除此之外,无需主动的将函数声明为挂起函数,因为如果内部没有调用挂起函数却主动将自己声明为挂起函数,反而会导致自己的使用范围变窄。
写代码的时候不用特别关注哪些函数应该写成挂起函数,IDE 让你 suspend 的时候加上就可以了。这是被动的要求,而不是主动的需求。
3.3 性能优势
协程相比于线程的性能优势体现在协程的机制上。
先来看一个示例代码:
CoroutineScope(Dispatchers.Main).launch {
val data = withContext(Dispatchers.IO) {
// 网络数据
"data"
}
// 数据计算,交给 Default
val processedData = withContext(Dispatchers.Default) {
"processed $data"
}
// 最终结果在主线程中呈现,比如显示到 UI 上
println("Processed data: $processedData")
}
思考一个问题:假如想把得到 processedData 变量的处理过程抽取出来,是应该只抽取处理数据的逻辑本身:
private fun getProcessData1(data: String) = "processed $data"
还是应该连同 withContext() 一起抽取:
private suspend fun getProcessData2(data: String) = withContext(Dispatchers.Default) {
"processed $data"
}
之所以有此疑问,是因为 withContext() 并不面向具体业务,并不像一般的挂起函数那样拥有具体的业务功能:
interface GitHub {
@GET("/repos/{owner}/{repo}/contributors")
suspend fun contributors(
@Path("owner") owner: String,
@Path("repo") repo: String
): List<Contributor>
}
可以使得在使用与抽取 contributors() 时毫不犹豫的加上 suspend。
答案是,应该带着 withContext() 抽取。原因如下:
-
首先,一段代码在哪种协程环境下执行,实际上是已经绑定了的。比如说
"processed $data"
它模拟的就是经过大量计算才得出的结果,也就是计算密集型任务,它理应就在 Default 中执行。所以,带着 withContext() 抽取是直接将该代码的使用环境一起封装了,这样调用端就不用考虑切线程的问题了(也能防止调用端忘记切线程) -
如果带着 withContext() 抽取,假如调用端将其放在同样的线程环境下,比如:
CoroutineScope(Dispatchers.Default).launch { getProcessData2("data") }
那么会出现已经在 Default 环境下,在 getProcessData2() 内部又通过 withContext() 切换到 Default 的情况,是否会有性能损失?没有,协程做了针对性优化,如果发现在切换时 ContinuationInterceptor 没有改变,那么就不会切换线程,而是保持在原线程继续执行代码。也就是说,抽取 withContext() 没有什么坏处
带着 withContext() 封装函数实际上是系统设计层面、语法层面提供的一种优势。因为任何耗时操作属于那种类型在一开始就已经确定了。比如网络访问就是 IO 密集型,数据处理就是计算密集型。那么在封装函数时,将耗时操作通过 withContext() 放到对应的 ContinuationInterceptor 当中,就能保证它们在正确的线程或线程池中执行。这样做能提升软件性能,因为以往很大一部分的性能问题就是因为一些耗时操作没能放到它应该放入的线程中执行造成的(比如看似不像耗时操作的耗时操作放在主线程了)。
以前,线程受语法特性限制,只能通过函数创建者以注释的方式来提醒函数的调用者应该在子线程中调用,无法完全避免开发者不遵从或忽略掉这些提醒的问题。而挂起函数由于语法设计的优势,可以让函数的创建者直接在函数内部把线程锁死。这样让负责线程管理的人从使用者变为函数的创建者,从根源上解决了性能问题。
总结起来,协程利用语法上的优势,帮助我们可以百分百确保耗时操作的工作一定在正确线程中执行。这相对于线程 API 在系统设计层面是一个巨大的优势。
因此,对于如下的代码:
CoroutineScope(Dispatchers.Main).launch {
val data = withContext(Dispatchers.IO) {
// 网络数据
"data"
}
// 数据计算,交给 Default
val processedData = withContext(Dispatchers.Default) {
"processed $data"
}
// 最终结果在主线程中呈现,比如显示到 UI 上
println("Processed data: $processedData")
}
应该改造为如下方式(项目中也常用这种方式):
CoroutineScope(Dispatchers.Main).launch {
val data = getData()
val processedData = getProcessData(data)
println("Processed data: $processedData")
}
private suspend fun getData() = withContext(Dispatchers.IO) {
"data"
}
private suspend fun getProcessData(data: String) = withContext(Dispatchers.Default) {
"processed $data"
}
3.4 为什么不卡线程
协程底层从子线程切换回主线程,实际上还是用的主线程 Handler.post():
// HandlerDispather.kt 中的 HandlerContext 类:
override fun dispatch(context: CoroutineContext, block: Runnable) {
if (!handler.post(block)) {
cancelOnRejection(context, block)
}
}
协程在执行挂起函数之前和之后,会在状态机上进行切换。执行之前切换到指定线程,执行之后切换回原来的线程。协程内部通过回调的形式帮助使用者自动完成了线程的切换,因此挂起函数不会阻塞当前线程。
举个例子,前面有通过 Retrofit 发送网络请求的代码:
private fun coroutineStyle() = lifecycleScope.launch {
val contributors = gitHub.contributors("square", "retrofit")
showContributors(contributors)
}
contributors() 是挂起函数,在开始执行它之前,协程会先切换到子线程中,然后执行 contributors() 的任务内容,执行完毕后,再通过回调(一般是 Handler.post())切换回原本线程,最后再执行赋值以及后续的操作。也就是说,contributors() 执行完毕之后,才把后续任务交给 Handler.post() 发送到指定线程中,Handler 拿到消息之后才会继续执行后续代码,所以赋值等后续操作会在 contributors() 执行完毕后再执行,但是挂起函数并没有阻塞当前线程,因为挂起函数实际上是在另一个子线程中执行的,当然不会阻塞当前线程了。
总结:挂起函数在执行前后实际上是通过状态机进行了线程切换的,执行前切到指定线程,执行后通过回调切回原线程。原线程并没有等待挂起函数执行,因此挂起函数不卡线程。
4、Android 项目中协程的写法
前面举例的 coroutineStyle() 就是一个典型的 Android 协程的写法:
private fun coroutineStyle() = CoroutineScope(Dispatchers.Main).launch {
val contributors = gitHub.contributors("square", "retrofit")
showContributors(contributors)
}
Dispatchers.Main 会指定在主线程中启动协程,这样的话,launch() 内的非挂起函数代码就都会在主线程中执行。
对于 Android 开发而言,KTX 库提供了很多扩展属性可以结合实际场景替代 CoroutineScope。比如 Lifecycle 库提供的 LifecycleOwner 的扩展属性 lifecycleScope:
public val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope
get() = lifecycle.coroutineScope
由于 LifecycleOwner 接口的实现类 ComponentActivity 和 Fragment 基本上会作为所有 Activity 和 Fragment 的基类,因此 lifecycleScope 就适用于 Activity 和 Fragment,它会与 Activity 或 Fragment 进行生命周期的绑定,在其生命周期结束时(如 Activity.onDestroy())自动取消 CoroutineScope 包含的所有协程。
lifecycleScope 内置了 Dispatchers.Main.immediate
使得其默认在主线程中启动协程:
public val Lifecycle.coroutineScope: LifecycleCoroutineScope
get() {
while (true) {
val existing = mInternalScopeRef.get() as LifecycleCoroutineScopeImpl?
if (existing != null) {
return existing
}
val newScope = LifecycleCoroutineScopeImpl(
this,
SupervisorJob() + Dispatchers.Main.immediate
)
if (mInternalScopeRef.compareAndSet(null, newScope)) {
newScope.register()
return newScope
}
}
}
Dispatchers.Main.immediate
相比于 Dispatchers.Main
做了一些优化,后者是在任何情况下都使用 Handler.post() 将任务切换到主线程,而前者则会进行一次判断,只有在当前不在主线程时才通过 Handler.post() 切回主线程,否则就直接开始执行后续代码了,有一定的性能优化作用。
有时我们不直接使用 Activity 或 Fragment 来管理数据,而是使用 ViewModel,KTX 也提供了对应的 Scope,即 viewModelScope,使用前需先导入依赖:
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7'
导入后可以在 ViewModel 范围内使用 viewModelScope:
class MyViewModel : ViewModel() {
fun test() {
viewModelScope.launch { }
}
}
viewModelScope 是 ViewModel 的扩展属性:
public val ViewModel.viewModelScope: CoroutineScope
get() {
val scope: CoroutineScope? = this.getTag(JOB_KEY)
if (scope != null) {
return scope
}
return setTagIfAbsent(
JOB_KEY,
CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
)
}
你可以看到其内部也使用了 Dispatchers.Main.immediate
。
5、结构化并发
结构化并发作为协程中一个很宏大的概念,会在系列的下两篇文章中讲解,这里只是先提一下概念。
挂起函数之所以关键,是因为它简化了并发任务的代码结构,使得用协程写出的并发代码比线程的更好写也更好读,它面向的是并发任务的写法。
而结构化并发面向的是任务的管理,比如协程的取消与异常管理。CoroutineScope 可以启动协程,因为它提供了协程运行的环境 CoroutineContext,比如前面提到的 ContinuationInterceptor,以及后续会讲到的 CoroutineName、CoroutineExceptionHandler 等。除此之外,CoroutineScope 的 cancel() 可以取消所有由该 CoroutineScope 启动的协程,也可以通过 Job.cancel() 取消由某一个 launch() 启动的协程。
协程取消可以有效的避免内存泄漏,在某些代码流程即将走完之时,应该取消协程。当然像 LifecycleScope 以及 ViewModelScope 这种与特殊组件绑定的 CoroutineScope,它们会在组件生命周期结束时自动调用 cancel() 结束所有协程以避免内存泄漏。
协程的结构化并发不仅仅是一对多(指父协程可以有多个子协程),也可以是一层对多层的(指父协程内可以有多层子协程)。结构化是指协程具备的一层层的结构化关系,而结构化并发,就是对这种结构的管理。具体来说,就是流程上的相互配合,以及在协程取消和遇到异常时的结构化合作。
6、并行协程的启动和交互
前面已经讲过两种切线程的方式 —— launch() 与挂起函数。从代码运行角度来说,launch() 启动多个协程时,它们之间是并行关系:
// 两个协程并行运行
CoroutineScope(Dispatchers.Main).launch {
}
CoroutineScope(Dispatchers.Main).launch {
}
而挂起函数是串行:
private fun coroutineStyle() = lifecycleScope.launch {
val contributors = gitHub.contributors("square", "retrofit")
showContributors(contributors)
}
对 contributors 变量的赋值以及后续操作需要等待挂起函数执行完毕再按顺序执行。虽然挂起函数与为 contributors 赋值的操作不在同一个线程(挂起函数运行在子线程,而赋值操作则是在 lifecycleScope 绑定的主线程中执行),但是这之中隐含了线程之间的交互。
那么,两个并行的协程如何交互呢?可以通过 async() 和 await() 以及 join()。比如,两个并行的协程,需要将运行结果相加:
// async 启动的协程有返回结果 Deferred
val deferred1 = lifecycleScope.async {
delay(300)
20
}
val deferred2 = lifecycleScope.async {
delay(500)
30
}
lifecycleScope.launch {
// 1.通过 await() 能拿到 async 开启的协程的返回结果
// 2.两个协程并行执行,这里耗时是二者较长的一个而不是总和
deferred1.await() + deferred2.await()
}
上述代码可以化简:
lifecycleScope.launch {
// 为了让两个协程作为 launch 的子协程以便管理,所以 async 前就没有再加 lifecycleScope,
// 否则这两个协程就是 lifecycleScope 的子协程
val deferred1 = async {
delay(300)
20
}
val deferred2 = async {
delay(500)
30
}
deferred1.await() + deferred2.await()
}
还可以进一步优化:
lifecycleScope.launch {
coroutineScope {
val deferred1 = async {
delay(300)
20
}
val deferred2 = async {
delay(500)
30
}
deferred1.await() + deferred2.await()
}
}
用 coroutineScope 包一层,能提供协程的异常的结构化管理很大方便,具体原因在介绍结构化并发的异常处理部分时再详细介绍。
如果希望几个协程在执行顺序上有依赖,但不依赖于彼此的结果,可以使用 join()。比如某些数据处理工作需要在初始化工作完成之后才能执行,那么:
lifecycleScope.launch {
// 初始化放在子协程中
val initJob = launch {
init()
}
// 获取网络数据
val data = gitHub.data()
// 执行 processData 之前需要初始化已经完成,此时对 initJob 使用 join
initJob.join()
processData(data)
}
7、协程与线程交互
由于老项目或者一些尚未支持协程的第三方库可能还是使用线程进行并发管理的,假如我们在新项目中使用协程要用到线程的并发功能,就需要在协程中接入线程代码;反之,老项目无法使用协程,只能用线程的情况下,但第三方库只提供了协程的并发支持,此时就需要在线程中接入协程。
7.1 连接线程世界
先来看如何将线程代码接入协程。
现有通过线程 + 回调方式完成 Retrofit 网络请求的代码:
private fun request() {
gitHub.contributorsCall("square", "retrofit")
// enqueue 会进入子线程发送网络请求,在拿到结果通过回调返回
// 结果之前,再切回到主线程之中
.enqueue(object : Callback<List<Contributor>> {
override fun onResponse(
call: Call<List<Contributor>>,
contributors: retrofit2.Response<List<Contributor>>
) {
// 倘若这里需要利用 contributors 发出二次请求,那么就会出现嵌套回调
showContributors(contributors)
}
override fun onFailure(p0: Call<List<Contributor>>, p1: Throwable) {
TODO("Not yet implemented")
}
})
}
使用 suspendCoroutine 包裹上面的函数体,可以将其改造为挂起函数:
private suspend fun callbackToSuspend() = suspendCoroutine {
gitHub.contributorsCall("square", "retrofit")
.enqueue(object : Callback<List<Contributor>> {
override fun onResponse(
call: Call<List<Contributor>>,
contributors: retrofit2.Response<List<Contributor>>
) {
// 1.传回数据
it.resume(response.body()!!)
}
override fun onFailure(p0: Call<List<Contributor>>, p1: Throwable) {
// 2.如果发生异常,会结束 suspendCoroutine 并抛出 t 这个异常
it.resumeWithException(t)
}
})
}
调用 callbackToSuspend() 时要为其加上 try-catch:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
lifecycleScope.launch {
try {
val contributors = callbackToSuspend()
showContributors(contributors)
} catch (e: Exception) {
// 异常处理
}
}
}
注意这个异常捕获不能写成如下的形式:
lifecycleScope.launch {
try {
// launch 是协程的启动器,在它启动协程之后,launch 本社就结束了。launch 代码块的内容
// 由协程负责执行。因此,launch 内部执行代码发生异常的话,这样 try-catch 是捕获不到的
launch {
val contributors = callbackToSuspend()
showContributors(contributors)
}
} catch (e: Exception) {
// 异常处理
}
}
相关细则在后续结构化并发的协程异常部分会讲到。
除了 suspendCoroutine(),还有 suspendCancellableCoroutine(),唯一区别是后者支持取消。由于取消协程是一个正常的需求,大多数时候我们都希望可以取消协程,因此我们通常会使用 suspendCancellableCoroutine()。在取消时,可以通过 invokeOnCancellation() 这个回调函数做一些取消时的收尾工作:
suspendCancellableCoroutine {
// 注册取消时的回调函数
it.invokeOnCancellation { ... }
}
注意,这种取消需要协程配合,也就是所谓的“协作式取消”。意思是除了在协程外部调用取消方法之外,协程内部也应做出配合,检查相关标记位,并在做完收尾清理工作后结束协程,内外协作让协程顺利取消。具体内容会在下一篇讲解结构化并发时说明。
7.2 回到线程世界
前面已经讲过两个协程自带的启动协程的函数 launch() 和 async(),实际上还有一个 runBlocking(),它有两点特殊:
- 无需 CoroutineScope 就可以启动协程
- 会阻塞当前线程
之所以有这两点,是因为它的定位与另外两个不同。它的定位是将挂起式的代码转换成阻塞式的。实际上就是与上一节用协程调用线程代码刚好反过来(虽然不常用,但确实有需求),它用于在线程环境中调用协程封装的代码。
比如:
fun main() = runBlocking {
val contributors = gitHub.contributors("square", "retrofit")
}
runBlocking() 开启了一个会阻塞当前线程的协程环境,在其内部执行挂起函数 contributors() 获取相关数据。