前言
使用 kotlin 协程已经几年了,可以说它极大地简化了多线程问题的复杂度,非常值得学习和掌握。此文介绍并梳理协程的相关概念:suspend、non-blocking、Scope、Job、CoroutineContext、Dispatchers 和结构化并发。
进入协程世界
简而言之,协程是可以在其内部进行挂起操作的实例,是否支持挂起函数也是协程世界和非协程世界的最大区别。初学者可以把协程看作是"轻量级线程"以做对比,但实际上他依然是跑在线程上的,所以也可以将它看作是一个强大的异步框架。
要使用协程,需要添加 kotlinx-coroutines-core
库的依赖。
挂起函数与非阻塞
先看一段代码:
kotlin
import kotlinx.coroutines.*
fun main() = runBlocking { // this: CoroutineScope
println("Hello World!")
}
runBlocking 是一个协程构造器,他连接了非协程和协程世界,{ } 里便是协程世界。这两个世界的差异在于是否可支持挂起操作:
kotlin
import kotlinx.coroutines.*
fun main() = runBlocking { // this: CoroutineScope
delay(1000L) // non-blocking delay for 1 second (default time unit is ms)
println("Hello World!")
}
"Hello World!" 将会在进入协程 1s 后打印,delay 便是一个挂起函数,类似线程中 sleep 的作用。区别是挂起函数 delay 并不会阻塞当前线程。这里有两个概念,挂起和非阻塞,挂起的是协程,非阻塞的是线程。
从线程的角度看,假如进入协程时运行在线程 A,那么执行 delay 函数时运行在线程 B,执行完 delay 之后又在线程 C 上执行 println,线程 C 可能就是线程 A,也可能不是,整个过程可以简化为:在某个线程执行协程,在遇到挂起函数 delay 时切换到另一个线程,执行完 delay 后又切回某个线程继续执行协程。不阻塞线程即不阻塞挂起前协程所在的线程,即 A 线程,A 线程可以继续执行其他任务。那这有什么意义呢?试想协程一开始运行在 Android 中的 ui 线程,在挂起函数里执行耗时的网络请求,网络请求结束自动回到协程,继续在 ui 线程上的执行。一方面耗时任务未阻塞 ui 线程,另一方面完全消除了异步回调,这使异步任务变得极为简单:以同步的方式书写异步的代码。
从协程的角度看,协程遇到挂起函数时会被挂起,即暂停了,等待挂起函数执行完成,相比于线程阻塞,协程挂起几乎没有任何资源消耗,其本质上是回调。
另外,挂起函数都有 suspend 关键字修饰,编译器也会在此施加魔法:
kotlin
public suspend fun delay(timeMillis: Long) { ... }
注:例子中的线程 A、B、C 是通过协程中的 Dispatcher 控制的,后面聊到。
CoroutineScope、Job 与 Context
协程都是由 CoroutineScope
创建的(launch、async 等),协程在创建时,都会关联到一个新的的 CoroutineScope
。
CoroutineScope
即协程作用域,顾名思义是为了限制和控制协程的作用范围或者说生命周期。不仅当前协程会受其影响,所有在协程作用域内创建的子协程也会有关联。当调用 CoroutineScope
的 cancel 方法时,会取消当前协程以及其关联的所有下层协程。
通过 CoroutineScope
创建的协程即 Job
,可以认为 Job
就是协程的实例。一个 Job 可以有多个子 Job
,也即一个协程可以有多个子协程。具有父子关系的协程,父协程取消时,所有子协程都会取消,CoroutineScope
的 cancel 本质就是通过取消 Job
实现的,因此 CoroutineScope
的 cancel 等价于 Job
的 cancel。
kotlin
val job = launch { // 1
launch { // 2
...
}
launch { // 3
...
}
}
job.cancel()
上面代码中 job 取消时会把 2、3处协程也取消。
协程层次化的好处是便于管理,再多的协程,只要它们具有相同的父协程,就可以方便地控制其生命周期。在层次化的协程结构中,取消事件自上而下,异常事件自下而上,这背后是结构化并发的思想。
CoroutineContext
,协程上下文,是 CoroutineScope
的唯一成员,是用于存放协程执行环境的地方,如调度器(Dispatcher
)、异常处理器(CoroutineExceptionHandler
)、Job
等。
CoroutineScope
在创建协程时会把 CoroutineContext
传递下去,也就是说新创建的协程会继承父协程的 CoroutineContext
。
CoroutineContext
数据的使用类似 Map,根据 Key 取值,如果子协程创建时指定了 CoroutineContext
,则会合并,相同 Key 的值会被覆盖。CoroutineContext
的主要目的是提供一个统一的方式来管理协程的执行环境和属性。
进入协程世界除了上面使用的 runBlocking 方式。还可以自定义 CoroutineScope
:
kotlin
fun main() {
CoroutineScope(Dispatchers.IO).launch {
...
}
}
上面代码创建了一个运行在 IO 线程环境的协程作用域并创建了一个协程,该协程继承了 CoroutineScope
的 CoroutineContext
,所以运行在 IO 线程。
此外,还可以使用 GlobalScope
这个全局的协程作用域进入协程世界:
kotlin
fun main() {
GlobalScope.launch(Dispatchers.IO) {
...
}
}
💡 runBlocking、自定义`CoroutineScope`、`GlobalScope` 三者都可以进入协程世界,但实际使用时应该选择自定义的方式。runBlocking 会阻塞当前线程直到协程执行完毕,因此适合单元测试。`GlobalScope` 虽拿来即用,但它是全局的,生命周期太长,使用不当会导致内存泄露风险。
如果已经在协程世界中了,那么创建新的协程的方式就比较多了,launch、async、coroutineScope、supervisorScope 等都可以方便地创建不同需求的协程。
Dispatchers 与线程
Dispatchers
可以指定协程的执行的线程环境,不过它强调的是线程的类别而不是哪一个具体的线程。如:Dispatchers.IO 表示 IO 密集型线程池,Dispatchers.Default 表示 cpu 密集型线程池,特别的是,Dispatchers.Main 特指 Android 中的主线程。在协程中可以使用 withContext
进行线程池的切换:
kotlin
fun main() {
CoroutineScope(Dispatchers.Main).launch {
withContext(Dispatchers.IO) {
...
}
}
}
💡 如果是网络数据传输等 io 任务,一定要使用 [Dispatchers.IO](http://Dispatchers.IO),其余计算类耗时任务使用 Dispatchers.Default,这是因为 Dispatchers.Default 线程池的线程数较少(和 cpu 核心数有关),而 [Dispatchers.IO](http://Dispatchers.IO) 线程池的线程数更多且可动态调整。io 任务往往等待时间更长,使用 Dispatchers.Default 的话很容易占满所有线程资源。
结构化并发
它是一种编程范式,旨在通过结构化的方式使并发编程 更清晰明确、更高质量、更易维护。
其核心有几点:
- 通过把多线程任务进行结构化的包装,使其具有明确的开始和结束点,并确保其孵化出的所有任务在退出前全部完成。
- 这种包装允许结构中线程发生的异常能够传播至结构顶端的作用域,并且能够被该语言原生异常机制捕获。
kotlin 协程设计中的协程关系、执行顺序、异常传播/处理 都符合结构化并发。结构化并发明确了并发任务什么时候开始,什么时候结束,异常如何传播,通过控制顶层结构具柄就可实现整个并发结构的取消、异常处理,使复杂的并发问题简单、清晰、可控。可以说,结构化并发大大降低了并发编程的难度。