目录
Kotlin 协程
为什么引入协程?
1. 本质上,协程是轻量级的线程 —— Kotlin 官方文档.
2. 协程就是一个轻量单线程通过快速切换来执行不同的任务,从而实现了非阻塞的并发执行 —— 我的总结
3. 你也可以这样理解:协程对应的是线程池的 newSingleThreadExecutor 类型
在传统的线程模型中,如果一个线程需要等待某个操作完成,那么他就会阻塞在那里,直到操作完成. 这样一来,阻塞期间,线程就不能去执行其他任务,造成了资源的浪费. 而协程不会阻塞住,他可以通过 delay函数 暂时挂起当前任务去执行其他任务,等待操作完成后再回来继续执行.
协程和线程有什么区别?
- 调度级别:线程是操作系统级别的并发执行单位,由操作系统负责调度和管理. 协程则是用户态的轻量级线程,由程序自身控制切换,不受操作系统的直接调度(每创建一个协程,都有一个内核态的线程动态绑定,用户态下实现调度,切换,而真正执行任务还是内核线程).
- 资源消耗:线程创建和销毁的成本相对较高,会涉及到用户态到内核态的转变. 而协程创建和销毁的成本较低,因为他们几乎是用户态的模拟线程操作.
- 控制方式(调度速度):线程是抢占式的,切换都有操作系统来决定,并且线程上下文的切换也会带来一定的开销,因此调度速度较慢. 而协程是协作式的,由程序主动控制何时挂起和恢复执行,没有线程切换的开销,调度速度更快.
- 并发性:线程可以充分利用多核 CPU 的并发计算能力,实现真正的并发执行. 而协程主要适用于 IO 密集型任务,通过非阻塞的方式提高程序的并发性能.
Kotlin 协程的核心竞争力在于:可以让单个线程,实现类似于 IO 多路复用机制(一个线程同时管理多个 Socket 通道).
什么是一个线程同时管理多个 Socket 通道?
举个例子,我小时候生活的那个小区门口,有很多小摊,早上的时候,家里人一般都起不来,不想自己做饭,那么一般就是由我来给我爸妈买饭,但是呢,他们又吃不到一块去,我妈喜欢吃面皮,俺爸就喜欢吃肉夹馍,而我喜欢吃炒面,那我买早饭就有一下三种策略:
- 我自己去买,先买面皮,等面皮好了以后再去买肉夹馍,等肉夹馍好了以后再去买炒面,当然啦,这种效率是最低的~
- 我们三个人一起去买,我去买炒面,我妈去买面皮、我爸去买肉夹馍,这样,虽然效率大大提升了,但是系统开销大了~
- 我自己去买,先去买面皮,等的过程中再去买肉夹馍,再等的过程中去买炒面,然后这三份哪个先好了,对应的老板就可以喊我一嗓子(epoll 事件通知/回调机制),此时就能让我一个线程同时做三件事,但前提是,这三件事的交互不频繁,大部分时间都在等~ 如果这三件事都是交互特别频繁的(比如联机游戏、线上直播、上传视频...)还是多搞几个线程靠谱,一个线程就忙不过来了~
协程的使用
依赖
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core</artifactId>
</dependency>
GlobalScope.launch
a)GlobalScope.launch 可以创建一个顶层线程,这种协程当应用程序运行结束,也会跟着一起结束.
如下代码:
fun main() {
GlobalScope.launch {
println("hi~")
}
Thread.sleep(1000)
}
这种写法存在问题,如果 lambda 中的代码不能再 1s 之内结束,就会被强制中断.
b)delay 函数是一个非阻塞式的挂起函数,他只会挂起当前协程,并不会影响到其他协程的运行. 这里使用 delay() 函数,模拟一个耗时 1500ms 的任务,如下:
fun main() {
GlobalScope.launch {
println("hello")
delay(1500)
println("world")
}
Thread.sleep(1000)
}
运行结果如下:
有没有什么办法能让应用程序在协程中的所有代码都运行完了之后再结束呢?当然有,往下看~
runBlocking
runBlocking 函数同样会创建一个协程作用域,它可以保证在协程作用域内所有代码和子协程没有全部执行完之前一直阻塞当前线程. 但需要注意的是,runBlocking 通常只是在测试环境下使用,正式环境使用会产生一些性能上的问题.
fun main() {
runBlocking {
println("hello")
delay(1500)
println("world")
}
Thread.sleep(1000)
}
运行结果如下:
创建多个协程
通过上述案例,貌似没有体会到什么协程到底有什么特别之处?这是因为所有代码都是运行在统一个协程中,一旦涉及到高并发的应用场景,协程相比于线程的优势就体现出来了.
a)我们可以通过在当前协程的作用域下创建子协程的方式来创建多个协程. 子协程的特点就是如果外层作用域的协程结束了,该作用域下的所有子协程也会一同结束,如下:
fun main() {
runBlocking {
launch {
println("hello1")
delay(1000)
println("world1")
}
launch {
println("hello2")
delay(1000)
println("world2")
}
}
}
运行结果如下:
b)另外也可以通过 repeat 创建多个协程
runBlocking {
repeat(100) {
launch {
val num = Random().nextInt(100)
println("hi~ $num")
}
}
}
delay
a)为何引入:随着业务的发展,可能需要协程来处理一些耗时任务,但是又不希望整个耗时的任务阻塞住整个协程.
如下代码,会因为任务A 耗时,阻塞整个协程.
fun main() {
runBlocking {
//执行一个耗时的任务
launch {
println("任务 A 开始处理")
Thread.sleep(3000)
println("任务 A 处理完成")
}
//执行一个耗时较短的任务
launch {
println("任务 B 开始处理")
Thread.sleep(300)
println("任务 B 处理完成")
}
}
}
执行结果如下:
b)为了解决这个问题,就可以通过 delay 函数挂起当前协程,让他先去执行其他任务,当其他任务执行完成之后,就会看 delay 设置的时间是否到了,如果到时间了,就会在回来,继续从上次挂起的地方继续执行.
fun main() {
runBlocking {
//执行一个耗时的任务
launch {
println("任务 A 开始处理")
delay(500) //等待另一个耗时短的任务处理完成,再来处理这个耗时任务
Thread.sleep(3000)
println("任务 A 处理完成")
}
//执行一个耗时较短的任务
launch {
println("任务 B 开始处理")
Thread.sleep(500)
println("任务 B 处理完成")
}
}
}
suspend 关键字
随着业务的发展,launch 函数中的代码越来越复杂,需要将部分代码封装到一个函数中,但是这个函数是没有 launch 协程作用域的,怎么使用像 delay() 这样的挂起函数呢?
为此 Kotlin 提供了 suspend 关键字,用来声明此次函数是挂起函数,就可以在此函数中使用 delay 了.
suspend fun longTimeTask() {
println("任务 A 开始处理")
delay(500)
println("任务 A 处理完成")
}
Ps:
- 标记:suspend 只是一个标记,本身并不实现挂起功能,只是用来提醒开发者. 真正实现挂起的是 suspend 修饰的函数中的 delay 函数.
- 使用范围:suspend 只能再协程体或者其他挂起函数中调用.
但是 suspend 关键字只能将一个函数声明成挂起函数,是无法给他提供协程的作用域的,比如在 hi() 函数中是不可以调用 launch 函数的,因为 launch 函数要求必须在协程作用域中才能调用.这个问题可以借助 coroutineScope 函数来解决~
coroutineScope
coroutineScope 函数也是一个挂起函数,因此可以在其他挂起函数中调用.
a)他的主要特点是会继承外部协程的作用域并创建一个子协程,因此就可以给挂起函数提供协程作用域了,如下:
suspend fun longTimeTask() = coroutineScope {
launch {
println("任务 A 开始处理")
delay(500)
println("任务 A 处理完成")
}
}
b)另外 coroutineScope 函数可以保证其作用域内的所有代码和子协程在全部执行完之前,一直挂起外部协程. 如下:
fun main() {
runBlocking {
coroutineScope {
launch {
println("开始执行任务 A")
delay(1000)
println("任务 A 执行完成")
}
}
launch {
println("开始执行任务 B")
println("任务 B 执行完成")
}
}
}
执行结果如下:
由此可见,coroutineScope 函数确实是将外部协程挂起了(delay 只在 coroutineScope 作用域内会挂起,让出线程给作用域内的其他任务)只有当它作用域内的所有代码和子协程都执行完毕之后,coroutineScope 函数以外的代码才能得到运行。
看上去 coroutineScope 和 runBlocking 函数作用类似,但是 coroutineScope 函数只会阻塞当前协程,不影响其他协程,也不影响任何线程,因此不会造成性能上损失. 而 runBlocking 会挂起外部线程(上述栗子中就挂起了 main 线程),因此就有可能造成页面卡死的情况,因此实际项目中不推荐使用.
job
前面讲到 runBlocking 会阻塞线程,因此只建议在测试环境下使用. 而 GlobalScope.launch 由于每次创建的都是顶层协程,也不建议使用(作用域广,不好管理),除非你就是明确要创建顶层协程.
a)不管是 GlobalScope.launch 函数还是 launch 函数,他们都会返回一个 job 对象,只需要调用 Job 对象的 cancel 方法就可以取消协程,如下:
val job = GlobalScope.launch {
//处理
}
job.cancel()
b)由于 GlobalScope 作用域太广,不方便管理,因此实际项目中更常见的使用方式如下:
val job = Job()
val scope = CoroutineScope(job)
scope.launch {
//处理
}
job.cancel()
CoroutineScope() 函数会返回一个 CoroutineScope 对象,有了 CoroutineScope 对象之后,就可以随时调用它的 launch 来创建一个协程.
并且所有调用 CoroutineScope 的 launch 函数所创建的协程,都会被关联在 Job 对象的作用域下. 这样只需要调用依次 cancel() 方法,就可以将统一作用域内的所有协程全部取消,大大降低管理成本
c)launch 函数只能用于执行一段逻辑,并且返回值永远是一个 Job 对象. 有没有什么办法可以创建一个协程并获取到他的执行结果呢?async 就可以实现~
async
a)async 用来创建一个子协程,传入一个 Lambda,调用后立即执行.
async 创建的子协程的返回值是 Deferred,通过调用 await 就可以获取到 Lambda 表达式的结果.
如下代码:
runBlocking {
val result = async {
println("进行了一些处理")
1 + 1
}.await()
println("拿到处理结果: $result") //输出 2
}
b)值得注意的是调用 await 会立即阻塞当前协程,直到当前协程返回结果.
错误的写法如下:
runBlocking {
val startTime = System.currentTimeMillis()
val v1 = async {
delay(1000)
1 + 1
}.await()
val v2 = async {
delay(1000)
2 + 2
}.await()
val endTime = System.currentTimeMillis()
println("耗时: ${endTime - startTime}")
}
这种写法,实际上就是用了双倍的时间,因为 await 会阻塞当前协程,导致串行执行.
正确的写法因该是延缓 await 调用,如下:
runBlocking {
val startTime = System.currentTimeMillis()
val v1 = async {
delay(1000)
1 + 1
}
val v2 = async {
delay(1000)
2 + 2
}
val r1 = v1.await()
val r2 = v2.await()
val endTime = System.currentTimeMillis()
println("耗时: ${endTime - startTime}")
}
分析原因:
我们知道,协程本质上是一个单线程快速切换,实现任务完成.
延缓 await 调用中:首先这个线程先遇到 delay 挂起 1s,挂起的期间发现还有一个任务,因此就切换到第二个 async,又遇到 delay 挂起 1s. 此时线程又会挂起. 由于两个async 中的 delay 的时间也相同,因此等待 1s后 就会执行剩下的任务,而剩下的任务都是不耗时的任务,因此时间就差不多是 1s.
而第一种错误的方式:由于直接调用了 await,因此就会阻塞住这个线程,使得整个线程会串行执行,先执行完第一个 async ,然后再执行第二个 async.
withContext 函数
withContext 是一个挂起函数,可以理解为 async + await 的简化版写法.
当调用 withContext 之后,会立即执行代码块中的代码,同时将外部协程挂起. 当 Lambda 中的代码执行完后,会将最后一行的执行结果作为函数的返回值返回.
如下代码:
runBlocking {
val result = withContext(Dispatchers.Default) {
1 + 1
}
println(result)
}
上述和 async { 1 + 1 }.await 这种写法唯一不同的就是,withContext 强制要求我们指定一个线程参数.
主要有三个参数可选:
Dispatchers.Default:表示会使用一种默认低并发的线程策略,当你要执行的代码属于计算密集型任务时,开启过高的并发反而可能会影响任务的运行效率
Dispatchers.IO:表示会使用一种较高并发的线程策略,当你要执行的代码大多数时间是在阻塞和等待中,比如说执行网络请求时,为了能够支持更高的并发数量
Dispatchers.Main:表示不会开启子线程,而是在Android主线程中执行代码,但是这个值只能在Android项目中使用
Kotlin SpringBoot Test 单元测试
这里和 Java 基本没什么差别
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>2.6.3</version>
<scope>test</scope>
</dependency>
@SpringBootTest(classes = [AlbumApplication::class])
class ESDocumentInit {
// @Resource private lateinit var restTemplate: ElasticsearchRestTemplate
@Resource private lateinit var albumInfoRepo: AlbumInfoRepo
@Test
fun run() {
initAlbumAgg()
}
fun initAlbumAgg() {
val result = albumInfoRepo.queryById(20)
println(result)
}
}