Kotlin协程基础
从这一篇开始,了解Kotlin的协程。
在这之前,需要先了解一下协程存在的意义。
协程并不是一个新的概念,它并不是 Kotlin 发明的。它们已经存在了几十年,并且在 Go 等其他一些编程语言中很受欢迎。协程是为了解决防止我们的应用程序被阻塞,从而达到不影响用户的体验。
解决应用程序阻塞的方式有很多,比如线程、响应式拓展、著名的Rx(如RxJava等),各有优缺点,尤其是RxJava等,学习成本比较高。Kotlin 编写异步代码的方式是使用协程,这是一种计算可被挂起的想法。即一种函数可以在某个时刻暂停执行并稍后恢复。协程的一个好处是,当涉及到开发人员时,编写非阻塞代码与编写阻塞代码基本相同。编程模型本身并没有真正改变。也就是说,使用协程编写的代码,异步和同步看起来一样,不用再使用异步的方式去理解。
协程是运行在线程池中的,也就是说,一个进程可能拥有多个线程,一个线程可能会运行很多个协程。协程是很轻量级的,启动协程消耗的资源很少。而启动线程的代价是比较昂贵的,一般情况下,一个进程都会有一个线程的上限。在了解了协程之后,针对创建线程和创建协程做一个对比,将会认识的更加清晰。
在协程中执行的函数,必须是可挂起函数,在Kotlin中,使用关键字suspend。
Kotlin的协程有几个特点
-
挂起函数的签名与普通函数保持完全相同。唯一的不同是它被添加了
suspend
修饰符。但是返回类型依然是我们想要的类型。 -
使用协程编写代码就好像我们正在编写同步代码,自上而下,不需要任何特殊语法,除了使用一个名为
launch
的函数。 -
编程模型和 API 保持不变。我们可以继续使用循环,异常处理等,而且不需要学习一整套新的 API。
-
它与平台无关。无论我们是面向 JVM,JavaScript 还是其他任何平台,我们编写的代码都是相同的。编译器负责将其适应每个平台。
协程不在Kotlin的标准库中,是单独的一个协程库,因此我们需要单独导入kotlinx.coroutines
。
创建一个Gradle项目,以方便添加额外的支持库。
在Idea中点击File->New Project->Gradle->Kotlin/JVM。这里有一篇现成的教程,引用一下,感谢作者大大。IDEA创建Gradle项目。需要注意的是,第二步选择Kotlin/JVM。
创建完成后,在
dependencies {
...
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7"
}
中添加kotlinx.coroutines
依赖。
添加后,同步gradle。同步成功之后,就可以使用Kotlin协程啦。
先举个栗子。
在新建的项目中,创建src
目录及自己想定义的报名,直接创建一个Kotlin File。
在新创建的Kotlin File输入以下内容(偷懒了,直接扒的官方示例~~)。
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
fun main() {
GlobalScope.launch { // 在后台启动一个新的协程并继续
delay(1000L) // 非阻塞的等待 1 秒钟(默认时间单位是毫秒)
println("World!") // 在延迟后打印输出
}
println("Hello,") // 协程已在等待时主线程还在继续
Thread.sleep(2000L) // 阻塞主线程 2 秒钟来保证 JVM 存活
}
导包什么的,Idea会提示你导入。
解释一下这段代码,顺便认识一下协程。
启动协程需要有CoroutineScope,库中提供了一个全局的CoroutineScope即GlobalScope
。可以直接使用GlobalScope.launch{}
启动一个协程,例子中就是使用这种方式。Thread.sleep(2000L)
是因为main
是进程的入口主函数,而我们仅有一个进程,它启动了我们的主线程运行了main
函数,如果我们不阻塞它,它执行完毕进程也将停止,我们的异步也就不会再被执行了。
启动运行这段代码,会看到如下的输出
在Kotlin中,阻塞线程可以使用runBlocking{}
替代Thread.sleep()
函数并达到通用的效果。
GlobalScope.launch { // 在后台启动一个新的协程并继续
delay(1000L)
println("World!")
}
println("Hello,") // 主线程中的代码会立即执行
runBlocking { // 但是这个表达式阻塞了主线程
delay(2000L) // ……我们延迟 2 秒来保证 JVM 的存活
}
runBlocking
的主线程会一直 阻塞 直到 runBlocking
内部的协程执行完毕。
如果使用函数表达式,也可以这样写。
fun main() = runBlocking<Unit> { // 开始执行主协程
GlobalScope.launch { // 在后台启动一个新的协程并继续
delay(1000L)
println("World!")
}
println("Hello,") // 主协程在这里会立即执行
delay(2000L) // 延迟 2 秒来保证 JVM 存活
}
这里的 runBlocking<Unit> { …… }
作为用来启动顶层主协程的适配器。 我们显式指定了其返回类型 Unit
,因为在 Kotlin 中 main
函数必须返回 Unit
类型。
通常情况下,如上述例子中的等待一定时间来让协程执行完的做法是不可取的,因为在实际中,协程执行结束的时间可能无法明确地知道,比如访问网络。
因此我们需要借助launch{}
的返回值来做处理。
协程的返回值是一个Job
对象,显示地等待Job
执行完是较好的做法,如:
val job = GlobalScope.launch { // 启动一个新协程并保持对这个作业的引用
delay(1000L)
println("World!")
}
println("Hello,")
job.join() // 等待直到子协程执行结束
这样,我们无需考虑协程的执行时间,当前线程总会在等待job
执行完毕之后结束。
但是这样也存在一个问题。
首先,使用GlobalScop.launch{}
创建协程,虽然资源占用很小,但是也是有一定的开销的。而且,如果我们有多个协程的话,使用job.join()
的地方相对就会较多,容易出错。
因此,我们可以和前面的例子一样,使用全局的协程上下文,从而可以直接使用launch{}
启动一个协程,而且无需使用job.join()
来等待协程的结束,因为外部协程(runBlocking{}
)直到在其作用域中启动的所有协程都执行完毕后才会结束。
import kotlinx.coroutines.*
fun main() = runBlocking { // this: CoroutineScope
launch { // 在 runBlocking 作用域中启动一个新协程
delay(1000L)
println("World!")
}
println("Hello,")
}
除了由不同的构建器提供协程作用域之外,还可以使用 coroutineScope
构建器声明自己的作用域。它会创建一个协程作用域并且在所有已启动子协程执行完毕之前不会结束。
import kotlinx.coroutines.*
fun main() = runBlocking { // this: CoroutineScope
launch {
delay(200L)
println("Task from runBlocking")
}
coroutineScope { // 创建一个协程作用域
launch {
delay(500L)
println("Task from nested launch")
}
delay(100L)
println("Task from coroutine scope") // 这一行会在内嵌 launch 之前输出
}
println("Coroutine scope is over") // 这一行在内嵌 launch 执行完毕后才输出
}
可以修改例子中的不同的delay
时间,查看运行结果。
runBlocking
与 coroutineScope
可能看起来很类似,因为它们都会等待其协程体以及所有子协程结束。 这两者的主要区别在于,runBlocking
方法会阻塞当前线程来等待, 而 coroutineScope
只是挂起,会释放底层线程用于其他用途。 由于存在这点差异,runBlocking
是常规函数,而 coroutineScope
是挂起函数。
当代码块中代码行数较多时,可以针对性的按照需要,将部分代码重构为函数。
将上面例子中的协程作用域构建起部分单独重构为一个函数。
在Idea中,选中这几行代码,按下快捷键:CTRL+ALT+M
,在弹出的对话框中输入对应的函数名称。
重构完成后,可以看到重构出了一个函数。
private suspend fun innerScope() {
coroutineScope { // 创建一个协程作用域
launch {
delay(500L)
println("Task from nested launch")
}
delay(100L)
println("Task from coroutine scope") // 这一行会在内嵌 launch 之前输出
}
}
因为它是在协程中调用的,因此它有一个suspend
修饰符,也就意味着,这个函数只能在协程中使用,和库函数delay
一样。
前面有说,协程是很轻量级的,我们使用一个对比例子,来从数据上直观认识一下。同时创建10万个协程和10万个线程,我们看它们执行的时间。
首先,创建10万个协程。
fun main(args: Array<String>) {
val timeTakes = measureTimeMillis {
runBlocking {
repeat(100_000) { // 启动大量的协程
launch {
delay(1000L)
print(".")
}
}
}
}
println(timeTakes)
}
我们这里使用了measureTimeMillis{}
,可以通过它计算所消耗的时间。
在执行结果中,可以看到10万个协程创建执行了1683毫秒,不到2秒的时间。
接下来创建10万个线程。
fun main(args: Array<String>) {
repeat(100_000) { // 启动大量的线程
Thread{
Thread.sleep(1000L)
print(".")
}.start()
}
}
计算多个线程的执行时间需要借助其他方法,感兴趣的同学可以自行执行一下创建大量线程的示例,可以直观的感受一下它的开销有多大(至少几十秒时间)。
像守护线程一样,在 GlobalScope
中启动的活动协程并不会使进程保活。作用域协程执行结束,GlobalScope
启动的协程也将被结束。
GlobalScope.launch {
repeat(1000) { i ->
println("I'm sleeping $i ...")
delay(500L)
}
}
delay(1300L) // 在延迟后退出
上面的示例,仅仅会打印如下的输出。
I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...