Kotlin协程-到底好在哪
Kotlin协程到底好在哪
在基本梳理完协程的代码后,不禁产生了对自己一致持有的“协程非常轻量”的观点的质疑。且在学习一些视频和讲解后发现确实不是如此,因此便准备好好总结一下。
回顾协程的特点
轻量级
协程只是一个状态机对象,不像线程需要指配1MB的栈空间,同一线程上可调度成千上万协程,内存开销小。
挂起函数
协程的函数可通过suspend关键字修饰,将异步或阻塞操作封装程挂起点,实现“写同步代码,跑异步IO效果”。
非阻塞
调用挂起函数时,不会卡住当前线程,而是让出线程给其他协程使用;底层I/O可交给少量线程池完成。
结构化并发
Scope与Job构成层级关系,父协程自动管理、取消、捕获子协程异常,避免“野生”协程泄漏。
可取消
协程挂起时会响应取消信号,可优雅地取消挂起任务及下游
调度器
内置Main/IO/Default等调度器,按需指定执行线程或线程池,实现线程切换和并发
Flow操作
基于协程,Kotlin还提供了一系列方便的Flow操作。
常规的阻塞请求
常规的阻塞请求是怎样的
我们需要先理清楚,在协程没出来之前,常规的一些阻塞请求(网络/数据库查询)是怎样进行的?
IO操作在一个新的线程中完成,并一定会造成线程阻塞,不占用CPU资源,并且随后的唤醒是被动过程,毋庸置疑。
比如在Android中创建一个HTTP请求,实质是新建一个线程,跑一个会导致线程阻塞的操作,线程阻塞时,比如调用read等方法时,内核将当前线程放到对应IO设备的等待队列中,标记它为可中断阻塞或不可中断阻塞。且随后这条线程从用户态进入内核态,被挂起(不消耗CPU),IO完成时,内核将等待队列上的线程唤醒,将其从等待队列移动到就绪队列,随后调度器会在某个可用的CPU核心上恢复它。
而并不是一个线程在持续地进行while循环检测某个标志。
纠错
因此来纠正一些自己之前的错误观点。
协程的轻量&非阻塞
错误观点:协程比线程轻量多了,所以只用协程就好了
- 协程比线程占用的空间少多了,因此我们进行网络请求的时候用协程就好了,不用线程。
- 协程在进行网络请求其实还是用的线程,但因为协程是挂起式的,所以在遇到线程阻塞时,这个执行网络请求的线程能够停下来转过来去做别的事。
这两个观点可以说在我这里有些根深蒂固了,转变这个观点花费了一些时间。但同时这可能也是很多初学协程的人会有的观点,因为Kotlin对外宣布一直是以“轻量”作为一大特性。但事实其实与此有不同
首先我们从上文明白了,IO操作(在Android)大多都是另开一个线程处理,不会阻塞主线程。
比如在协程之前,我们会使用 线程池ThreadPool创建网络请求 + Android Handler 来进行网路请求并进行UI回调。
对于上述的观点,我们有更正:
- 协程是基于线程的,协程运行在线程之上。Kotlin协程本质上是一个具有阻塞调度feature的线程池。因此无法抛开线程谈协程
- 协程进行网络请求必须是基于线程的,不管是 协程启动 还是 传统OkHttp方式,内部都会新建一个线程来处理网络IO。
- 执行网络请求的线程在阻塞时,不会因为协程而回过头去做别的事,线程在阻塞时就被操作系统挂起,不占用CPU空间,此时CPU将调度其他的线程。
其实这一点在Kotlin官网的说明中也可能会引起误解
官网中说道
Coroutines are light-weight
Coroutines are less resource-intensive than JVM threads. Code that exhausts the JVM's available memory when using threads can be expressed using coroutines without hitting resource limits. For example, the following code launches 50,000 distinct coroutines that each waits 5 seconds and then prints a period ('.') while consuming very little memory:
import kotlinx.coroutines.*
fun main() = runBlocking {
repeat(50_000) { // launch a lot of coroutines
launch {
delay(5000L)
print(".")
}
}
}
If you write the same program using threads (remove runBlocking
, replace launch
with thread
, and replace delay
with Thread.sleep
), it will consume a lot of memory. Depending on your operating system, JDK version, and its settings, it will either throw an out-of-memory error or start threads slowly so that there are never too many concurrently running threads.
这里官网对比的是启动50_000个线程来执行print操作并使用delay,会发现OOM错误。但这里其实不应该拿协程跟线程比,协程的本质其实还是属于线程池以及线程切换,因此应该跟Java中线程池做对比。同时这里delay和sleep也有本质的区别,一个只是通知线程当前协程挂起(基于延迟队列),一个则是让整个线程阻塞。
其实Kotlin官方想表达的意思跟读者理解的意思可能不一致。
这里是想通过异步的方式顺序打印50000个 . ,传统方式下用线程执行,因此要创建50000个线程,肯定会造成OOM。但使用协程就可以做到用一个或少量的线程来实现。因此在这个层面上,协程更加轻量。因为内部使用delay操作并不是真正让线程阻塞,而只是告诉协程调度器当前任务的一部分执行完毕,需要挂起,让其为线程分配下一个任务。
**在这个场景下,确实是协程更适合。**当然你也可以使用Executors.newSingleThreadScheduledExecutor()
来模拟
但其实更多地,大多数实际场景都是发送网络请求,因此这里的对比关系便不再适用。
那在那个场景下,对于协程启动的方式,更适合的对比对象应该是okhttp基于线程池 submit或enqueue的案例,而两者其实没有太大的区别。
协程的可挂起
对于协程的可挂起并不是在操作系统层面的挂起,这一点在协程的源码中也有体现。
实质基于Runnable的状态机,让当前线程执行完 一个协程任务的某个状态下的代码后,让这个线程休眠或去做其他的任务。
我们说的挂起,是基于状态机的任务切换,而不是说IO任务的线程在阻塞后(进行该IO操作的网络线程) 转去做其他事。这属于内部的事了(比如OkHttp内部网络请求线程池),与启动该协程对应的线程无关系。
总结
协程的本质其实就是用线程池切线程以及为每个线程安排任务,我们说的协程可挂起是可以自动切回来的线程,非阻塞式则是指协程可以用看起来阻塞的代码,来写出非阻塞的操作。
因此我们不能直接地把协程与线程做对比,要根据具体情况具体分析。