前言
上一篇文章 深入理解Android多线程开发:场景应用与解决方案解析 针对Android开发中的多线程应用场景和相应的解决方案做了一个梳理。
总结出了Android开发中多线程编程的几个重要点:
- 资源复用和优化
- 切线程
- 任务编排
并结合示例说明了Kotlin协程在处理上述问题时的优势。
Kotlin协程自从2018年底成为kotlin语言的正式特性后,到现在已经5个年头了。而kotlin这门语言也在最近正式推出了 2.0版本,带来了更好的支持多平台开发以及更快的编译速度。
我自己算是比较早就开始尝试使用协程来解决开发中的各种需求。体验到协程的优势后,我迅速从RxJava转向了协程。
随着项目经验的积累,对协程进行了封装,以消除模版代码,简化使用并提升稳定性。
期间也断断续续的写了几篇关于协程使用上的文章,如:
Retrofit+kotlin Coroutines(协程)+mvvm(Jetpack架构组件)实现更简洁的网络请求
Android使用协程(Coroutine)优雅的处理多个接口同时请求(网络并发请求)
kotlin协程async await的异常踩坑以及异常处理的正确姿势
这些文章主要停留在使用层面,并未对协程的系统性知识进行深入说明。
本专栏的初衷是尽可能的把Java线程和kotlin协程体系化的知识点给讲清楚,让读者能够更好地理解线程和协程的底层原理,以及如何更好地使用协程。
之前的文章已经对Java线程及多线程开发进行了系统性的讲解,从这篇文章开始,我们将重点学习Kotlin协程的相关知识。
kotlin 协程的基本概念
首先来简单说一下kotlin语言和kotlin协程之间的关系:
- Kotlin 协程是一种用于简化异步编程和并发操作的工具,但它并不是 Kotlin 语言的内置部分,而是通过附加库提供的特性。所以,使用kotlin协程需要依赖相关的库。
- kotlin 是一门跨平台的语言,协程作为 Kotlin 语言的特性,在不用的平台上有对应的支持库,如 JVM、Android、JavaScript、iOS 有对应的库支持。
Kotlin 协程不仅支持 JVM 和 Android,还支持其他平台,如 JavaScript 和 iOS。
Android是运行在 JVM 平台上的,所以我们本系列文章讨论的协程都是基于 JVM 平台的。
一个重要的点是,Kotlin 代码最终会编译成 Java 字节码运行在 JVM 上,而 JVM 本身只有线程的概念,并不认识协程。因此,Kotlin协程的底层实际上是通过 Java 线程实现的,只是对线程进行了封装,提供了简单易用的 API 供我们使用。
结合官方文档中介绍:
- 协程是一个可挂起计算的实例,在概念上跟线程类似,都是用来实现并发的工具。
- 协程可以在一个线程中暂停并在另一个线程中恢复继续执行,这期间不会阻塞线程,可以提高并发性能。
- 协程可以被认为是轻量级的线程,但是在使用上有所差异。
到这里,协程的基本概念就可以有个定义了,我们可以这样理解:
kotlin 协程跟Java线程概念类似,都是用来管理并发的工具。它的底层是使用Java线程实现的,并基于线程封装了一套 API给我们使用,让我们能够更容易的管理并发任务。
协程的依赖
上面说过,kotlin协程在不同平台上有对应的支持库,使用kotlin协程需要单独添加依赖。
如果是服务端开发,核心依赖是
kotlinx-coroutines-core ,它内部有kotlinx-coroutines-core-jvm的依赖
当然,你也可以显示的指定kotlinx-coroutines-core-jvm的依赖,也没问题
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-reactor
runtimeOnly("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.7.1")
在Android开发中,我们依赖kotlinx-coroutines-android 即可,因为它内部已经包含了kotlinx-coroutines-core的依赖。
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-android
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
从这里可以看出,Android开发对比服务端开发,多了一个kotlinx-coroutines-android的依赖。
这也很好理解,因为服务端主要面向的是数据方面的处理,而Android开发在此基础上还需要处理UI方面的操作。所以,kotlinx-coroutines-android 中主要就是为了适配Android平台的特性,其中就包含了切换到主线程相关的代码。
协程的使用
协程的创建与启动
完成依赖后,我们就可以愉快的使用kotlin协程了。
我们都知道,使用Java线程池可以这么写:
//创建线程池
val executorService = Executors.newFixedThreadPool(10)
//在线程池中执行任务
executorService.execute {
Logger.i("executorService 开始")
Thread.sleep(2000)
Logger.i("executorService 结束")
}
那使用kotlin协程实际上也类似
//创建协程作用域
val coroutineScope = CoroutineScope(EmptyCoroutineContext)
//在协程作用域中,开启一个协程执行任务
coroutineScope.launch(Dispatchers.Default) {
Logger.i("coroutineScope 开始")
delay(2000)
Logger.i("coroutineScope 结束")
}
执行结果:
可以看到,任务是在子线程中执行的。
这里出现了几个新角色,先简单介绍一下
概念 | 解释 |
---|---|
CoroutineScope | 协程的作用域,用来管理协程的生命周期 |
EmptyCoroutineContext | 一个空的上下文实例,实现了 CoroutineContext 接口 |
CoroutineContext | 创建 CoroutineScope 时需要传递的参数,包含了协程的各种上下文信息,可以自定义协程行为 |
Dispatchers | 调度器,用来指定协程执行的线程 |
一些概念不清楚问题不大,先有一个简单的认识,知道协程是怎么用的即可,后面再深入理解。
除了launch,还可以通过async来创建一个协程
val coroutineScope = CoroutineScope(EmptyCoroutineContext)
coroutineScope.launch {
val deffered = async(Dispatchers.IO) {
Logger.i("async 开始")
delay(2000)
Logger.i("async 结束")
"async 结果"
}
//等待deffered执行完毕,获取结果
val resul = deffered.await()
Logger.i("result:${resul}")
}
跟launch的区别在于,async 返回一个 Deferred类型的对象,可以用于获取async创建的协程的执行结果。
切线程
在上面的例子中可以看出来,在协程中可以通过Dispatchers
指定协程执行的线程。
Dispatchers
是一个枚举类,里面包含了几个调度器,用来指定协程执行的线程。
调度器 | 作用 |
---|---|
Dispatchers.Default | 适用于CPU密集型任务。是默认的调度器, 如果没有显式的指定调度器,那么,默认就是使用Default来执行协程任务的。 |
Dispatchers.IO | 适用于IO密集型任务,比如网络请求、文件读写等 |
Dispatchers.Main | 适用于UI操作,在主线程执行 |
Dispatchers.Unconfined | 不受限制的调度器,会在当前线程立即执行,不推荐使用。 |
我们可以在开启一个协程时指定调度器,这样该协程就会在指定的线程中执行任务。
//开启一个协程执行任务
coroutineScope.launch(Dispatchers.IO) {
Logger.i("coroutineScope 开始")
delay(2000)
Logger.i("coroutineScope 结束")
}
也可以在创建协程作用域时就把调度器指定好,这样在该作用域下开启的协程在没有指定调度器时就会使用该作用域的调度器。
//创建协程作用域,指定调度器
val coroutineScope = CoroutineScope(Dispatchers.IO)
//开启一个协程执行任务
coroutineScope.launch {
Logger.i("coroutineScope 开始")
delay(2000)
Logger.i("coroutineScope 结束")
}
Dispatchers.Main
在介绍 kotlinx-coroutines-android 这个依赖时我们提到了这个库针对Android平台提供了特性支持,其中就包含切换到UI线程的代码,
我们来看下源码验证一下。
内部实际上是执行了loadMainDispatcher()
方法,最终是通过 MainDispatcherFactory
接口load进来的
MainDispatcherFactory
有一个实现类是 AndroidDispatcherFactory
,可以看到它是 kotlinx-coroutines-android
中的代码,也是专门为Android平台提供的。
可以看到 AndroidDispatcherFactory
中实际上就是对Handler的包装,最终通过handler.post方法切换到主线程执行任务。
Dispatchers.Default、Dispatchers.IO
在上篇文章中也说过,我们在开发时一般会有一个自定义的全局的线程池管理类,其中包含CPU线程池和IO线程池等。
代码片段
//cpu核心数,最小为2
private val cpuCoreCount = Runtime.getRuntime().availableProcessors().coerceAtLeast(2)
//核心线程数,默认为cpu核心数减2,最小为2.留两个线程以免任务过多导致cpu跑满造成卡顿
private val corePoolSize = (cpuCoreCount - 2).coerceAtLeast(2)
//cpu密集型任务一般比较耗费cpu时间片,线程数量不宜过多,设置多了反而会增加线程上下文切换的开销
val cpuThreadPoolExecutor by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
log("cpuCoreCount:$cpuCoreCount,maxPoolSize:${maxPoolSize}")
newThreadPool(
corePoolSize,
cpuCoreCount,
poolWorkQueue = LinkedBlockingQueue(cpuCoreCount),
threadFactory = DefaultThreadFactory("CPU")
)
}
//io 密集型的任务不会占用太多cpu时间片,线程数量可以设置的较大
val ioThreadPoolExecutor by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
log("cpuCoreCount:$cpuCoreCount,corePoolSize:${corePoolSize},maxPoolSize:${maxPoolSize}")
newThreadPool(
cpuCoreCount * 2,
(cpuCoreCount * 2).coerceAtLeast(maxPoolSize),
poolWorkQueue = LinkedBlockingQueue(maxPoolSize),
threadFactory = DefaultThreadFactory("IO")
)
}
同样的,Default 和 IO 这两个调度器在协程中也是全局的
他们主要的区别就在于内部线程池维护的线程数量
调度器 | 描述 |
---|---|
Default | 对应自定义的 CPU 线程池,线程数量与设备 CPU 核心数一致 |
IO | 对应自定义的 IO 线程池,默认线程数为 64,如果设备 CPU 核心数小于 64,则使用 64,如果 CPU 核心数大于 64,则使用 CPU 核心数作为线程数 |
资源复用和优化
上面说到了协程的底层实际上就是线程池。这里以 Dispatchers.Default 为例,来简单看看它的源码。
DefaultScheduler
继承自 SchedulerCoroutineDispatcher
,它是一个调度器,用来指定协程执行的线程。
SchedulerCoroutineDispatcher
又继承自 ExecutorCoroutineDispatcher
,内部有一个 executor
属性,实际上就是线程池,用来执行协程任务。
也就是说,你只要是使用了协程,就不必考虑资源复用和优化的问题了,协程内部已经帮你做了这些事情。
协程默认给我们提供了几个调度器,我们可以根据任务的不同特性来选择不同的调度器。
当然,如果你本身有自己的线程池,希望协程使用自定义的线程池来执行任务,也是可以的。
在创建协程作用域时,需要传递一个 CoroutineContext
参数。
CoroutineContext
其中有一个子类是 ExecutorCoroutineDispatcher
,可以用来指定协程使用的线程池。
上面分析了 Dispatchers.Default
时可以看到,它本身也是一个 ExecutorCoroutineDispatcher
。
而且协程提供了 ExecutorService
的扩展方法asCoroutineDispatcher()
,可以把自己的线程池转换成协程调度器。
那我们就可以很方便的把自己的线程池转换成协程调度器了。
代码示例:
//把自己的线程池转换成协程调度器
val customDispatcher =
ThreadPoolManager.instance.ioThreadPoolExecutor.asCoroutineDispatcher()
val myIoCoroutineScope = CoroutineScope(customDispatcher)
myIoCoroutineScope.launch {
Logger.i("myIoCoroutineScope 开始")
delay(2000)
Logger.i("myIoCoroutineScope 结束")
}
执行结果:
任务编排
kotlin在处理这种复杂的任务编排时非常的得心应手。在上一篇文章中已经感受过了,示例代码如下:
//创建协程作用域
val mainScope = CoroutineScope(Dispatchers.Main)
//开启一个新的协程
mainScope.launch {
//启动一个协程,执行IO密集型的耗时任务,没有返回结果
launch(Dispatchers.IO) {
Logger.i("IO密集型任务开始")
delay(2000)
Logger.i("IO密集型任务执行完毕")
}
//执行cpu密集型的耗时任务,挂起函数,在任务没有执行完之前,后面的代码不会执行,直到执行完毕才会执行。
val result = withContext(Dispatchers.Default) {
Logger.i("withContext:cpu密集型任务开始")
delay(1000)
"cpu密集型任务执行完毕"
}
Logger.i("withContext:$result")
val deffered1 = async(Dispatchers.Default) {
Logger.i("async:任务1开始")
delay(2000)
"async 任务1的执行结果"
}
deffered1.await().let {
Logger.i(it)
}
val deffered2 = async(Dispatchers.IO) {
//执行IO密集型的耗时任务
Logger.i("async:任务2开始")
delay(2000)
"async 任务2的执行结果"
}
val deffered3 = async(Dispatchers.IO) {
Logger.i("async:任务3开始")
//执行IO密集型的耗时任务
delay(2000)
"async 任务3的执行结果"
}
//等待deffered2和deffered3都执行完毕
val stringList = awaitAll(deffered2, deffered3)
stringList.forEach {
Logger.i(it)
}
}
}
执行结果:
把代码中出现的概念列一下:
概念 | 解释 |
---|---|
launch | 启动一个新的协程,执行给定的代码块,没有返回值。 |
withContext | 是一个挂起函数,在当前协程中切换上下文,并执行指定的代码块,直到withContext代码块执行完毕,后面的代码才能继续执行。 |
async | 启动一个新的协程,返回一个Deferred对象,可以通过await方法获取执行结果,也可以通过awaitAll等待多个执行完毕,并获取执行结果 |
await | 挂起并等待Deferred 对象执行完毕 |
awaitAll | 可以传入多个 Deferred 对象,也可以传入一个List,会挂起并等待所有 Deferred 对象执行完毕 |
注意上面的操作均不会阻塞线程。
根据上面的说明,其实也比较容易地匹配到应用场景:
- 如果希望在协程中执行一个耗时任务,并且不需要返回值,可以使用
launch
- 如果希望等待某个耗时任务执行完毕后再执行后续代码,可以使用
withContext
- 如果希望异步执行一个耗时任务,并且需要获取执行结果,可以使用
async
,并通过await
获取结果 - 如果希望并行的执行多个耗时任务,并且等待所有任务执行完毕后再执行后续代码,可以使用
async
和awaitAll
总之,使用 kotlin 协程来编排任务,代码更加简洁,逻辑也更加清晰。
Android中协程的模版写法
在Android开发中,我们经常会使用协程来处理网络请求、数据库操作、文件读写等IO密集型任务,以及一些耗时的计算任务。
然后在主线程中更新UI。
那么就会出现下面这种模版化的写法:
- 创建一个协程作用域,指定调度器为主线程
- 在协程作用域中开启一个协程执行耗时任务,此时可以根据任务类型指定不同的调度器
- 任务执行完毕后,会自动切回到主线程,可以直接更新UI
示例代码:
CoroutineScope(Dispatchers.Main).launch {
launch(Dispatchers.IO) {
//启动一个协程,执行IO密集型的耗时任务
}
val result = withContext(Dispatchers.Default) {
//切换到io线程执行耗时任务,并挂起当前协程,直到任务执行完毕
"返回结果"
}
val deffered1 = async(Dispatchers.Default) {
//启动一个新的协程,执行IO密集型的耗时任务,并返回一个Deferred对象
}
val deffered2 = async(Dispatchers.IO) {
//启动一个新的协程,执行CPU密集型的耗时任务,并返回一个Deferred对象
}
//等待deffered1和deffered2都执行完毕
awaitAll(deffered1, deffered2)
//更新ui
}
在日常开发中,我们一般不会自己去创建协程作用域,而是使用Android官方提供的协程作用域来执行任务。
- MainScope : 主线程的协程作用域
- lifecycleScope : 具备生命周期感知的协程作用域,默认调度器是主线程
- viewModelScope : ViewModel的协程作用域,同样具备生命周期感知能力,默认调度器是主线程
这些协程作用域默认的调度器都是主线程,其中的一些作用域还具备生命周期感知能力的,当页面销毁时,会自动取消协程任务,能够很好地避免内存泄漏。
一些流行成熟的官方或三方库,很多都支持了协程,比如Retrofit、Room、LiveData等,这些库都提供了协程的扩展方法,可以很方便地在协程中执行网络请求、数据库操作等任务。
以Retrofit为例,可以看之前写过的文章:
Retrofit+kotlin Coroutines(协程)+mvvm(Jetpack架构组件)实现更简洁的网络请求
总结
协程在处理多线程异步开发这类需求非常的得心应手,代码更加简洁,逻辑更加清晰。
得益于协程良好的封装,我们不必再关心线程的创建、销毁、线程池的优化等问题,协程内部已经帮我们做了这些事情。
我们只需要关心任务的执行逻辑,以及任务之间的关系即可。
在Android开发中,我们可以使用Android官方提供的协程作用域来执行任务,这样可以很好的避免内存泄漏问题,大多数情况下协程的使用也都是模版化的,只需要根据任务的不同特性来选择不同的调度器即可。
后续我们会基于这些基础知识,深入学习协程的更多知识,例如协程的取消、异常处理、协程的原理等,并且针对模版化的代码进一步封装,提供更加便捷的使用方式。
下一篇:kotlin 协程之初识挂起函数
感谢阅读,如果对你有帮助请点赞支持。有任何疑问或建议,欢迎在评论区留言讨论。如需转载,请注明出处:喻志强的博客