前言
协程系列文章:
- 一个小故事讲明白进程、线程、Kotlin 协程到底啥关系?
- 少年,你可知 Kotlin 协程最初的样子?
- 讲真,Kotlin 协程的挂起/恢复没那么神秘(故事篇)
- 讲真,Kotlin 协程的挂起/恢复没那么神秘(原理篇)
- Kotlin 协程调度切换线程是时候解开真相了
- Kotlin 协程之线程池探索之旅(与Java线程池PK)
- Kotlin 协程之取消与异常处理探索之旅(上)
- Kotlin 协程之取消与异常处理探索之旅(下)
- 来,跟我一起撸Kotlin runBlocking/launch/join/async/delay 原理&使用
原计划本篇要深入分析挂起原理,有小伙伴说能不能再出一篇拟物拟人的故事简单了解一下协程挂起与恢复原理?最好能和线程的阻塞/唤醒关联起来。我想了一阵子,没找到比较好的素材,直到前天下班搭公交回家,坐在车上福至心灵...
通过本篇文章,你将了解到:
1、协程、线程、CPU 在现实世界的映射
2、协程/线程 挂起 与 公交车上下车的关联
3、线程池的调度 与 公交车调度的关联
4、总结
1、协程、线程、CPU 在现实世界的映射
没错,这就是传说中的 "开局一张图,内容全靠编..."
接下来开始"编"。
名词映射关系
先看看对应关系:
动作映射关系
- 公交车跑在车道上,类似线程运行在CPU 上,每根车道代表一个CPU,多个车道表示多CPU,多个公交车跑在不同的车道上,就是并行的效果。公交车可以切换车道,类比线程可能被不同的CPU调度。
- 乘客类比每行代码,公交车搭载乘客表示执行代码。
- 公交车停靠公交站点,表示线程受到阻塞/协程挂起。
- 公交车从起点跑到终点表示一次线程执行动作完成(Runnable)。
- 公交车没事暂不调度,需要停靠到公交停车场休息,类似线程回归线程池等待任务来临。
- 当有人排队的时候,公交车从停车场调度出来,类似线程池里有任务需要执行了。
2、协程/线程 挂起 与 公交车上下车的关联
好了,上面已经将车准备好了,大家扶好坐稳,准别发车!
线程阻塞/唤醒 与公交车关联
线程的阻塞
公交车在平稳地开着,突然有个乘客说到:"快停车,我要下车。"
司机道:"马上到站了,请耐心等候。"
乘客接着对司机说道:"我下车后,你不要开走,就停在边上,等我拿点东西。”
其他乘客不服气了:"凭什么让车等你啊,我们也在赶时间。"。
司机安抚大家:"大家稍安勿躁,这位师傅是咱们公交维修师傅,他要去取一些必要的零件过来才能让我们的公交车继续运行,因此我们需要耐心等到他回来。"
司机一边说着一边靠站停车,此时公交车已经不在主路上了,其它公交车可以畅通无阻地行驶。
回到代码的世界:
fun drive() {
println("我坐车,我快乐")
//车要停一会
Thread.sleep(3000)
println("车继续开")
}
咱们对比一下:
- 公交车等待下车的乘客返回,不能继续往前行驶,此时整个公交车上的乘客都需要等待,这就类比线程的阻塞Thread.sleep(xx)[LockSupport类操作],后边的代码都无法执行。
- 公交车离开了主干道,停靠路边,这就类比线程放弃CPU,其它车可以在道上行驶(其它线程占用CPU)。
线程的唤醒
过了一会,乘客(维修师傅)回来后,告诉公交师傅可以走了,于是公交车发动,重新起步变道驶入主路,这就是线程被唤醒了,重新抢占CPU,最后得以运行。
剩下的乘客将逐步被运往各个公交站点下车(类比之后的代码可以继续执行)。
线程切换为什么耗费资源
公交车往路边停靠,降速直至停稳,甚至可能还需要熄火,等到时机成熟再点火、逐渐往主路上行驶,这个过程都需要一些列操作,并且重新启动也比较耗油费电。
而对于线程来说,线程的切换涉及到上下文切换,每个线程都有自己的环境变量记录在寄存器里,CPU切换线程的时候需要将信息保存,当线程再次唤醒时将这些信息恢复,这个过程比较耗费资源。
对于线程来说,我们通常说的是:
线程的阻塞与唤醒,从阻塞到唤醒、从唤醒到阻塞之间切换。
对于协程来说,我们通常说的是:
协程的挂起与恢复。
协程的挂起/恢复 与公交车关联
协程的挂起
公交车上有一群人,原来是一个导游带着一个老年旅游团。
公交车平稳运行着,导游突然对司机说:"师傅,我要提前下车去订酒店,我把旅游团都带下车了。"
司机答应到:"哦,可以的,你们都下车了,我也回场了吧"
导游说:"嗯,好的,我们下车后自己换车"
- 所有乘客下车后,司机就开车回场了。
- 导游下车后,立马换另一辆公交车去订酒店,其余的乘客在公交站台等待导游的信号,当导游确定了酒店后通知等待的乘客们上车前去酒店。
回到代码的世界:
fun drive2() {
GlobalScope.launch(Dispatchers.Main) {
println("我坐车,我快乐")
withContext(Dispatchers.IO) {
println("导游去订酒店")
}
println("车继续开,剩下团友在车上")
}
}
如上,在主线程开启一个协程,协程里有三部分代码:
其中两个是简单打印,另一个是开启了子协程。
- 协程1执行在主线程,此时公交车正常运行,当协程执行到withContext(xx)后,发现它是个挂起的方法,对应到导游换车去订酒店,需要停车。
- 导游下车并把旅游团都带下车,他自己搭乘另一辆公交车去订酒店,剩下的团友们在站台等待导游的通知。
- 导游对应到协程体2,他将乘坐另一辆车(协程体2运行在另一个线程)。
协程的恢复
估计你已经发现了一些端倪:
团友该啥时候上车?
答案:旅游团里的老人将电话留给导游,导游定好酒店后电话通知他们可以上车了(类似代码里的回调,子协程持有父协程的调度器)。
导游定好酒店对应于协程2执行结束,收到导游的电话后,团友们将上车,此时对应于协程1里挂起方法后的代码得到了执行。
协程的挂起与线程阻塞的区别
对比两种公交车场景可知。
第一种场景:
公交车停车等待乘客,并且让出了车道,对应线程阻塞,让出CPU。
第二种场景:
公交车停车下客人,之后回场,等待调度。对应于线程执行到某个地方就停止了,相当于一个Runnable执行返回了(Runnable 被分为两部分,上部分和下部分,只执行了上部分就返回了),线程等待执行另一个新的Runnable,而下部分的Runnable(对应下车的旅游团)将等待,这对应于协程的挂起。
很明显,不管第一种场景还是第二种场景都会涉及到线程,它们的区别在于:
协程挂起时,没有将所在的线程阻塞,所在线程可以被继续调度。
这就是众多文章说的:协程效率高、不阻塞线程、协程运行在用户态的依据。
此处再次强调,协程和线程不是同类型的概念范畴,不要将两者杂糅比较。
3、线程池的调度 与 公交车调度的关联
还能上原来的车吗?
细心的你可能会问:
对于第二种场景,旅游团收到导游消息后上的车还是之前的那辆车吗?
前边说到公交车将乘客全部放到站台后就返场了,它返场后是否需要出场则要看停车场根据实际情况(是否有人在站点等候上车)进行发车。
由之前的映射关系可知,公交停车场对应着线程池调度,因此协程挂起之后的代码被哪个线程执行由线程池决定。
在Android 这块比较特殊,因为有个主线程,主线程不在协程的线程池内,主线程一直在执行,对应于UI公交车一直在运行。
看代码:
fun drive2() {
GlobalScope.launch(Dispatchers.Main) {
println("我坐车,我快乐")//①
withContext(Dispatchers.IO) {②//
println("导游去订酒店")//③
}
println("车继续开,剩下团友在车上")//④
}
}
协程1运行在主线程,其中① 运行在主线程,②处检测到阻塞,主线程将退出执行该Runnable,当③执行完毕后,通知主线程去执行④。
也就是说①和④ 都是在主线程执行。
此种情况下,旅游团的老人在等待后继续坐上了之前的那俩公交车。
这也是有些小伙伴使用协程来切换到主线程执行的原理。
再看另一种调用:
fun drive2() {
GlobalScope.launch(Dispatchers.IO) {
println("我坐车,我快乐")//①
withContext(Dispatchers.Default) {//②
println("导游去订酒店")//③
}
println("车继续开,剩下团友在车上")//④
}
}
Dispatchers.IO、Dispatchers.Default 表示协程运行的线程将由线程池调度,因此假若①执行在thread-1上,在④ 执行时不一定是thread-1,没有任何的标记指明④应该执行在thread-1上,从线程池的设计的角度想也很正常,因为线程池关注的是任务的执行,而非具体的某个线程。
此种情况下,旅游团的老人在等待后可能没法坐上当初的那辆车。
主线程、子线程区别
在回答这问题之前,先问大伙一个问题:你觉得同一批公交车功能上有区别吗?
答案当然是否定的。
举一反三,对于进程来说,线程之间在功能上没有任何区别,只是Android 设计之初为了保证UI 刷新数据的安全性,选择某个线程专门用来做UI 绘制,一般称之为主线程/UI 线程,而剩余的叫做子线程。
其实就是在公交车的外层贴个膜表示该公交车专用来运送UI 设计师。
剩下的公交车用来运一些搬砖人,这些搬砖人不擅长UI设计,不要随意改动UI,若要改动UI,交给UI公交车上的设计师来做,这不就是子线程切换到主线程吗?
4、总结
至此,我们"编"的故事结束了。
你可能觉得故事本身牵强附会,漏洞百出,不过没关系,只要你弄懂了协程的挂起/恢复,那么这个故事就有意义。
下一篇,我们将会探究协程是如何挂起的?不用withContext(xx)如何切换一个线程?相信你看完一定会有所收获。
本文基于Kotlin 1.5.3,文中完整Demo请点击
您若喜欢,请点赞、关注,您的鼓励是我前进的动力
持续更新中,和我一起步步为营系统、深入学习Android/Kotlin
1、Android各种Context的前世今生
2、Android DecorView 必知必会
3、Window/WindowManager 不可不知之事
4、View Measure/Layout/Draw 真明白了
5、Android事件分发全套服务
6、Android invalidate/postInvalidate/requestLayout 彻底厘清
7、Android Window 如何确定大小/onMeasure()多次执行原因
8、Android事件驱动Handler-Message-Looper解析
9、Android 键盘一招搞定
10、Android 各种坐标彻底明了
11、Android Activity/Window/View 的background
12、Android Activity创建到View的显示过
13、Android IPC 系列
14、Android 存储系列
15、Java 并发系列不再疑惑
16、Java 线程池系列
17、Android Jetpack 前置基础系列
18、Android Jetpack 易懂易学系列
19、Kotlin 轻松入门系列