为什么会有协程 && 什么是协程
当我们最初学习程序之时,我们书写代码、使用指令执行,完成逻辑链条的前后关系。代码执行到哪,逻辑就走到哪。但问题随之出现,有些过程并不是能立即得到结果的,监听按钮或者一些耗时操作如IO操作等,程序为了等待结果就会阻塞。在一些应用场景之下,我们常常会使用异步api,通过一些回调函数操作来完成异步任务。
<div align=center><img src="https://s1.ax1x.com/2022/09/26/xV3LFg.png" width="40%" /></div>
但是异步回调也有本身的问题,第一是,原本的统一的同步逻辑被拆分成几个阶段,造成代码可读性不好。第二是,在复杂的应用场景下,可能造成十分难受的回调地狱。以及难以debug的问题。
// JavaScript展示地狱回调
setTimeout(function () { // 第一层
console.log('第一层'); // 等3秒打印,再执行下一个回调函数
setTimeout(function () { // 第二层
console.log('第二层'); // 等2秒打印,再执行下一个回调函数
setTimeout(function () { // 第三层
console.log('第三层'); // 等1秒打印
}, 1000)
}, 2000)
}, 3000)
协程则应运而生。协程就是协作式多任务,协程本身是一种任务调度机制,也可以说是一种编程设计思想并不限定语言(在1958年就被发明,并用于构建汇编程序)。协程可以使用同步的代码逻辑流去操作异步的控制流。并且不会导致在操作系统的线程阻塞。相对于线程之间的切换,协程是更轻量级的。对于线程的操作是调用了操作系统的功能,而启用协程则是编程语言来完成,所以协程也被称为用户态线程。
协程为并发而生,线程在CPU多核之下,已然能完成并行。如图所示,并发本身是对CPU时间片的争夺。而对于异步任务进行线程切换,这才是协程能做的事。一个线程可以有多个等待执行的协程, 它们不像多线程争抢cpu那样, 它们是排队执行。
下图为CPU时间片段对于串行、并行、并发的线程处理的区别。
<div align=center><img src="https://s1.ax1x.com/2022/09/25/xEV9BR.jpg" width="40%" /></div>
协程对于异步任务是主动让出而不是抢占的多任务,突出是主动让出;而线程则是抢占式的多任务,突出被动抢占。
抢占是一个相对低效的操作,“打断”这种操作不是那么容易做的,操作系统级别上之所以需要抢占是为了避免任务占着CPU不走。但在你自己知根知底的代码还要去抢占,这基本上就是牺牲性能的操作。所以使用协程避免抢占可以提高性能。
部分语言对于协程的支持
- go语言的协程,直接在语法层面支持协程。解决了服务端开发中,IO密集型任务,并发性能程序过于复杂的痛点。go可以很优雅地进行高并发场景的开发。go语言的协程叫 Goroutines,从英文拼写就知道它和 Coroutines 还是有些差别的(设计思想上是有关系的),不然Kotlin的协程完全可以叫 Koroutines。
- 之前Java对于协程而言,可以使用NIO(new IO)和一些多线程api进行操作,能模拟出一定的协程的效果,但实际开发方面还是过于麻烦,也许go语言在服务器端异军突起,在协程并发方面Java落于下风不无关系。
- 2022年9月份,Oracle正式发布了最新版本的Java19和对应的Java虚拟线程的特性。是为帮助提高大型服务器的应用性能。Java虚拟线程在设计思想上,就是轻量级线程。值得关注的是,这次改动对于Java各个Api的改动很少,并没有太多新语法。Java19的虚拟线程是预览特性,很可能在Java21才会成为正式特性。
- 很多语言都有自己的协程(虚拟线程)的技术,除了Go、Java,还有C#、Erlang、Lua等等。
kotlin协程
协程概念本身与线程概念是同一级别的东西。
在kotlin-JVM、Android平台中,对于协程的运用本质成为了切换线程,在性能上本身不会比优化后的线程池强。这是kotlin-JVM语言对于协程的实现。甚至在其他平台,kotlin-native、kotlin-javascript上,协程的实现的方法都完全不一样。在这个基础上,可以说,协程未必等于切线程,而未必就不能强于线程池。只是kotlin-JVM的协程实现就是基于切换线程。
kotlin协程写法
kotlin协程的写法和运用方案很多,有:runBlocking顶层函数;GlobalScope(CoroutineScope)单例对象调用launch开启协程;创建一个 CoroutineScope 对象开启协程等等。
这里我主要讲比较简单的开启协程的方法。
CoroutineScope(Dispatchers.Main).launch {
// your code
withContext(Dispatchers.IO) {
// your blocking code
}
// your code
withContext(Dispatchers.IO) {
// your blocking code
}
// your code
}
如上的写法就是在main线程中进行异步操作,将阻塞和耗时操作放入withContext的切换线程的代码块里,kotlin程序就自然帮我们完成了线程之间的切换,以同步的写法完成异步的事情,这就是协程。
如下的代码是实现一个计时器更新主线程button的文字的功能,是kotlin使用协程对比使用线程池,kotlin-JVM协程在写法和性能上不一定更优秀,这些都是设计思想和方案选择的碰撞和抉择。如下代码,进行了简单的时间增加并在Android的UI线程更新的逻辑。
private fun useCoroutines() {
CoroutineScope(Dispatchers.Main).launch {
buttonCount.isEnabled = false
var count = 0
while (count < 10) {
withContext(Dispatchers.IO) {
delay(SECOND_DURATION)
count++
}
buttonCount.text = String.format(
Locale.getDefault(),
"%d",
count
)
}
buttonCount.isEnabled = true
}
}
private fun useExecutor() {
Executors.newSingleThreadExecutor().execute {
var count = 0
while (count < 10) {
SystemClock.sleep(SECOND_DURATION)
count++
val finalCount = count
runOnUiThread {
buttonCount.text = String.format(
Locale.getDefault(),
"%d",
finalCount
)
}
}
runOnUiThread { buttonCount.isEnabled = true }
}
}
suspend关键字
suspend 是 Kotlin 协程最核心的关键字之一。官方解释是,代码执行到 suspend 函数的时候会『挂起』,并且这个『挂起』是非阻塞式的,它不会阻塞你当前的线程。
什么是挂起函数
suspend关键字修饰的函数叫做挂起函数,挂起函数只能在协程体内或者其他挂起函数内使用。协程内部挂起函数的调用处被称为挂起点,也就是Android Studio代码左边会出现的一个箭头加一个绿色波浪线的标志。
网上寻到的gif例子,具体挂起函数演示如下:
<div align=center><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/60453cfebece44779b6581aefef14284~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.image" width="40%" /></div>
getUserInfo()、getFriendList(user)、getFeedList(friendList)三个函数都是挂起函数。内部必然为 suspend修饰的挂起函数。例如getUserInfo函数可为下内容:
suspend fun getUserInfo(): String {
withContext(Dispatchers.IO) { // 切换到IO线程
delay(1000L) // 延迟1s
}
return "content"
}
挂起的对象是协程,挂起的本质“就是这个协程从正在执行它的线程上脱离”。注意,这里不是这个协程停下来,而是脱离,从当前线程脱离,比如上文的代码,就是从主线程脱离,去IO或工作线程上进行处理。紧接着在 suspend 函数执行完成之后,协程为我们做的最爽的事就来了:会自动帮我们把线程再切回来。如上文所示,我们代码本身在主线程允许,当协程切走的函数执行完毕,协程会帮助我们post一个Runnable,让我们剩下的代码和信息继续回到之前的线程去运行。
协程scope
- 值得关注的是,kotlin的协程它也有自己的scope。比如使用api
GlobalScope.launch
不如使用 apiCoroutineScope.launch
,原因就是GlobalScope.launch的协程作用域不受限制, 即除非主进程退出, 否则只要该协程不结束就会占用资源,因为是全局的scope。这导致了如果协程的执行体中出现异常协程仍会占用资源而非释放. 最差的情况下有可能反复调用导致设备资源被占满宕机,这也就是内存溢出。