Kotlin 朱涛-13 协程 思维模型 非阻塞 coroutine 线程
目录
13 | 协程思维模型
- 原文
- Kotlin 协程源码:kotlinx.coroutines
为什么协程如此重要
协程是 Kotlin 对比 Java 的最大优势。
Java 也在计划着实现自己的协程
Loom
,不过目前还处于相当初级的阶段。
Kotlin 协程目前在业界的普及率并不高:
- 协程是一种
颠覆性
的技术,因此,刚开始时的学习难度比较大 - 协程的语法表面上看很简单,但其
行为模式
却让新手难以捉摸 - 协程是一个典型的
易学难精
的框架,如果理解不透彻,使用时一定会遇到各种各样的问题
学习协程,相当于一次编程思维的升级。协程思维与线程思维迥然不同,当我们能够用协程的思维来分析问题以后,线程当中某些棘手的问题在协程面前都会变成小菜一碟。
学习协程,相当于为我们打开了一扇新世界的大门。当我们对 Kotlin 协程有了透彻的认识以后,再去看 C#
、Python
、Dart
、JS
、Golang、Rust、C++20、Java Loom 中的类协程
概念,就会觉得无比亲切。
什么是协程
从广义上来讲,协程就代表了 互相协作的程序。这个标准,几乎适用于所有语言的协程。
协程案例:yield
注意,协程没有直接集成在标准库
中,需要手动依赖:
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val sequence: Sequence<Int> = getSequence()
val iterator: Iterator<Int> = sequence.iterator()
println("Get ${iterator.next()}")
println("Get ${iterator.next()}")
println("Get ${iterator.next()}")
}
fun getSequence() = sequence {
print("Add 1 - ").also { yield(1) } // yield 代表【产出】一个值,并【让步】
print("Add 2 - ").also { yield(2) }
print("Add 3 - ").also { yield(3) }
}
Add 1 - Get 1
Add 2 - Get 2
Add 3 - Get 3
从程序的运行结果会发现,main() 与 getSequence() 这两个函数,是交替执行
的。这样的运行模式,就好像两个函数在协作
一样。
- 普通程序在被调用以后,
只会在末尾的地方返回,并且只会返回一次
- 协程可以每次只
yield
(产出) 一个值,并在任意yield
(让步) 的地方挂起(suspend),让出执行权,然后等到合适的时机再恢复(resume)
协程案例:Channel
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
fun main() = runBlocking {
val channel: ReceiveChannel<Int> = getProducer(this) // Receiver's interface to Channel
delay(100).also { println("Receive ${channel.receive()}") }
delay(100).also { println("Receive ${channel.receive()}") }
delay(100).also { println("Receive ${channel.receive()}") }
}
fun getProducer(scope: CoroutineScope) = scope.produce {
print("Send 1 - ").also { send(1) }
print("Send 2 - ").also { send(2) }
print("Send 3 - ").also { send(3) }
}
Send 1 - Receive 1
Send 2 - Receive 2
Send 3 - Receive 3
以上代码中的 main() 和 getProducer() 之间,也是交替执行
的。
协程的发展历史
Kotlin 协程很年轻:
- 2017 年初,Kotlin 1.1 版本中,协程以实验性被加入进来
- 2018 年底,Kotlin 1.3 版本中,协程正式成为 Kotlin 的特性
- 2019 年,Kotlin 协程推出 Flow 相关的 API
但协程这个概念本身并不年轻:
- 早在 1967 年的 Simula 语言中,就已经出现了协程
- 但是在之后的几十年里,协程并没有被推广开
- 直到 2012 年,
C#
重新拾起了协程这个特性,实现了 async、await、yield - 之后,JavaScript、Python、Kotlin 等语言也跟进实现了对应的协程
Kotlin 中的协程
- 协程,可以想象成是
更加轻量的线程
,成千上万个协程可以同时运行在一个线程中 - 协程,可以理解为是
运行在线程中的、更加轻量的 Task
- 协程,不会与特定的线程
绑定
,它可以在不同的线程之间灵活切换
- 协程跟线程的关系,有点像线程与进程的关系,因为协程不可能脱离线程运行
业界一直有个说法:Kotlin 协程其实就是一个封装的线程框架,其本质是对线程池的进一步封装。
线程中可以运行多个协程
PS:执行下面的代码前,要先配置特殊的 VM 参数:-Dkotlinx.coroutines.debug
这样一来,Thread.currentThread().name
就能会包含协程的名字 @coroutine#1
案例一
import kotlinx.coroutines.*
fun main() = runBlocking {
println("1-" + Thread.currentThread().name) // 先打印 1-main @coroutine#1
launch { // launch 是创建协程
println("2-" + Thread.currentThread().name) // 后打印 2-main @coroutine#2
}
delay(10L)
}
可以看到,main 函数中出现了两个协程。
案例二
fun main() = runBlocking() {
val time = System.currentTimeMillis()
printInfo(time, 0)
launch {
delay(200L)
printInfo(time, 1)
}
launch {
delay(100L)
printInfo(time, 2)
}
printInfo(time, 3)
delay(500L)
printInfo(time, 4)
}
fun printInfo(time: Long, text: Any) = println("$text -" + Thread.currentThread().name + " - " + (System.currentTimeMillis() - time))
0 -main @coroutine#1 - 1
3 -main @coroutine#1 - 6
2 -main @coroutine#3 - 114
1 -main @coroutine#2 - 226
4 -main @coroutine#1 - 520
案例三
fun main() = runBlocking() {
val time = System.currentTimeMillis()
printInfo(time, 0)
for (i in 1..98) {
launch {
delay(i.toLong())
printInfo(time, "1-$i")
}
}
printInfo(time, 2)
delay(500L)
printInfo(time, 3)
}
fun printInfo(time: Long, text: Any) =
println("$text -" + Thread.currentThread().name + " - " + (System.currentTimeMillis() - time))
0 -main @coroutine#1 - 1
2 -main @coroutine#1 - 7
1-2 -main @coroutine#2 - 15
1-3 -main @coroutine#3 - 23 // 为什么有大量重复的值?
1-4 -main @coroutine#4 - 23 // 为什么有大量重复的值?
1-5 -main @coroutine#5 - 23 // 为什么有大量重复的值?
1-6 -main @coroutine#6 - 23 // 为什么有大量重复的值?
1-7 -main @coroutine#7 - 23 // 为什么有大量重复的值?
1-8 -main @coroutine#8 - 23 // 为什么有大量重复的值?
//...
1-97 -main @coroutine#97 - 119
1-98 -main @coroutine#98 - 119
3 -main @coroutine#1 - 518
有无数个协程运行在一个线程上。
协程是非常轻量的
在下面的代码中,我们尝试启动 10 亿个线程
,这样的代码,在大部分的机器上运行时,都会因为内存不足
等原因而异常退出。
import kotlin.concurrent.thread
fun main() {
repeat(1000_000_000) { // repeat 是重复,这里是重复启动 10 亿个线程
thread { // OutOfMemoryError: unable to create new native thread
Thread.sleep(1000000L)
}
}
}
下面改用协程
来实现:
import kotlinx.coroutines.*
fun main() = runBlocking {
var count = 0L
repeat(1000_000_000) { //启动 10 亿个协程
println(++count)
launch {
delay(1000000L)
}
}
}
由于协程是非常轻量
的,所以代码不会因为内存不足而异常退出。
协程不会和线程绑定
协程虽然运行在线程之上,但协程并不会和某个线程绑定,协程是可以在不同的线程之间切换的。
import kotlinx.coroutines.*
fun main() = runBlocking(Dispatchers.IO) {
repeat(3) {
launch {
repeat(3) {
println(Thread.currentThread().name)
delay(100L)
}
}
}
}
// 代码运行的结果是随机的
DefaultDispatcher-worker-3 @coroutine#2 // 协程 @coroutine#2 第一次在 worker-3 线程执行
DefaultDispatcher-worker-2 @coroutine#3
DefaultDispatcher-worker-4 @coroutine#4
DefaultDispatcher-worker-1 @coroutine#2 // 协程 @coroutine#2 第二次在 worker-1 线程执行
DefaultDispatcher-worker-4 @coroutine#4
DefaultDispatcher-worker-2 @coroutine#3
DefaultDispatcher-worker-2 @coroutine#2 // 协程 @coroutine#2 第三次在 worker-2 线程执行
DefaultDispatcher-worker-1 @coroutine#4
DefaultDispatcher-worker-4 @coroutine#3
可以看到,coroutine#2
的三次执行,每一次都在不同的线程上。
Kotlin 协程的非阻塞
协程对比线程还有一个特点,那就是非阻塞(Non Blocking),而线程则往往是阻塞式的。
- Kotlin 协程的
非阻塞
只是语言层面
的 - 当调用 JVM 层面的 Thread.sleep() 的时候,它仍然会变成
阻塞式
的 - 在协程中应该尽量避免出现
阻塞式
的行为,即尽量使用delay
,而不是sleep
阻塞案例:线程 + sleep
fun main() {
repeat(3) {
Thread.sleep(100L)
println("Print-1: ${Thread.currentThread().name}")
}
repeat(3) {
Thread.sleep(90L)
println("Print-2: ${Thread.currentThread().name}")
}
}
Print-1: main
Print-1: main
Print-1: main
Print-2: main
Print-2: main
Print-2: main
由于线程的 sleep() 方法是阻塞式的,所以程序的执行流程是线性的。也就是说,Print-1
会连续输出三次,然后 Print-2
会连续输出三次。即使 Print-2
休眠的时间更短。
非阻塞案例:协程 + delay
import kotlinx.coroutines.*
fun main() = runBlocking {
launch { // 创建一个协程
repeat(3) { // 重复打印三次
delay(100L) // delay-1
println("Print-1: ${Thread.currentThread().name}")
}
}
launch {
repeat(3) {
delay(90L) // delay-2
println("Print-2: ${Thread.currentThread().name}")
}
}
delay(10L)
}
注意:上面代码中的两个 delay 的大小关系,是会影响输出结果的:
delay-1 < delay-2
时(比如delay-2 = 110
),Print-1 肯定全部在 Print-2 之前输出delay-1 > delay-2
时(比如delay-2 = 90
), Print-2 和 Print-1 可能交替输出delay-1 >> delay-2
时(比如delay-2 = 10
),Print-2 可能全部在 Print-1 之前输出
Print-2: main @coroutine#3
Print-1: main @coroutine#2
Print-2: main @coroutine#3
Print-1: main @coroutine#2
Print-2: main @coroutine#3
Print-1: main @coroutine#2
可以看到,Print-2 和 Print-1 是交替输出
的,coroutine#2
、coroutine#3
这两个协程是并行的
(Concurrent)。
同时,由于协程的 delay() 方法是非阻塞
的,所以,即使会先执行 delay(100L),但它也并不会阻塞 delay(90L) 的运行。
阻塞案例:协程 + sleep
如果我们将上面代码中的 delay 修改成 sleep,程序的运行结果就不一样了。
import kotlinx.coroutines.*
fun main() = runBlocking {
launch { // 创建一个协程
repeat(3) { // 重复打印三次
Thread.sleep(100L) // delay 是非阻塞的,而 sleep 是阻塞的
println("Print-1: ${Thread.currentThread().name}")
}
}
launch {
repeat(3) {
Thread.sleep(90L)
println("Print-2: ${Thread.currentThread().name}")
}
}
delay(10L)
}
// 上面代码中的两个 delay 的大小关系,不会影响输出结果
Print-1: main @coroutine#2
Print-1: main @coroutine#2
Print-1: main @coroutine#2
Print-2: main @coroutine#3
Print-2: main @coroutine#3
Print-2: main @coroutine#3
由此可见,Kotlin 协程的非阻塞
其实只是语言层面
的,当我们调用 JVM 层面的 Thread.sleep() 的时候,它仍然会变成阻塞式
的。
所以,在协程当中应该尽量避免出现阻塞式的行为。即尽量使用 delay,而不是 sleep。
挂起和恢复:抓手、挂钩、hook
该如何理解 Kotlin 协程的非阻塞
呢?答案是:挂起和恢复。
站在 CPU 的角度上看,对于执行在普通线程
中的程序来说,它会以类似这样的方式执行:
这时候,当某个任务发生了阻塞
行为的时候,比如 sleep,当前执行的 Task 就会阻塞
后面所有任务的执行:
那么,协程是如何通过挂起和恢复
来实现非阻塞的呢?
- 大部分语言中都存在一个类似
调度中心
的东西,它用来实现对 Task 任务的执行和调度 - 协程除了拥有
调度中心
以外,对于每个协程的 Task,还会多出一个类似抓手、挂钩、hook
的东西,通过这个东西,我们可以方便对它进行挂起和恢复
协程任务的总体执行流程,大致会像下图描述的这样:
- 线程的 sleep 之所以是阻塞式的,是因为它会阻挡后续 Task 的执行
- 协程之所以是非阻塞式的,是因为它
支持挂起和恢复
,当 Task 由于某种原因被挂起
后,后续的 Task 并不会因此被阻塞
小结
- 广义的协程,可以理解为
互相协作的程序
,也就是Cooperative-routine
- 协程框架,封装了 Java 的线程,对开发者暴露了协程的 API
- 程序当中运行的
协程
,可以理解为轻量的线程 - 一个线程当中,可以运行成千上万个协程
- 协程,也可以理解为运行在线程当中的非阻塞的 Task
- 协程,通过挂起和恢复的能力,实现了
非阻塞
- 协程不会与特定的线程绑定,它可以在不同的线程之间灵活切换,这其实也是通过
挂起和恢复
来实现的
疑问:一个线程中能运行成千上万的协程,那么操作系统和 CPU 是如何调度的呢?毕竟操作系统和 CPU 对协程是无感知的。
猜测:协程本质上只是封装线程的框架,底层还是线程,效率并没有超越线程,只是让我们程序员使用的得更方便而已。
所以:协程只是比乱用线程要高效,但是和合理使用线程池的效率是一致的。
2016-06-21
本文来自博客园,作者:白乾涛,转载请注明原文链接:https://www.cnblogs.com/baiqiantao/p/5604121.html