协程是 Kotlin 中的一个重要部分,协程是一种并发设计模式,那么在了解协程之前,我们需要了解一些概念。
一、什么是线程
线程是一个基本的 cpu 执行单元,也是程序执行流的最小单位。
Android 中会有一个主线程,也就是 UI 线程,负责界面渲染。
二、什么是并发
并发是指两个或多个事件在同一时间间隔内发生,这些事件宏观上是同时发生的,但微观上是交替发生的。单核 CPU 同一时刻只能执行一个程序,但由于 CPU 时间片非常小,多个指令前快速切换,会给人一种同时执行的感觉。
而容易混淆的计算机的另一个特性是并行,并行是指两个或多个事件在同一时刻同时发生。它存在于多核 CPU 中,同一时刻可以同时执行多个程序,多个程序可以并行执行。
举个栗子,一个人要画一个圆和一个正方形,他一会儿画圆,一会儿画正方形,这叫并发。但如果另一个人左右脑都很发达,可以左手画圆右手画正方形同时进行,这就叫并行。
三、什么是异步
异步是指在多道程序环境下,允许多个程序并发执行,但由于资源有限,进程的执行不是一贯到底的,而是走走停停,以不可预知的速度向前推进,这就是进程的异步性。
简单来说;
- 如果系统在发送一个指令后,需要等待该指令执行完才能继续往下进行,系统就是串行的处理方式,也就是同步;
- 而发送一个指令后,无需等待该执行完就可以继续往下进行,系统就是并行的处理方式,也就是异步。
举个栗子,假如我去路边摊买了一份烤冷面,需要等待摊主煎蛋、煎饼、煎烤肠、放佐料、打包全部完成后,我才能取走,这中间的过程只有等待,这就是同步,而如果我去店里取了个号点餐,那我点完餐后就可以离开一会儿,自己做自己的事,等待后厨出完餐,叫到我的号了我再去取,这就是异步。
异步通常会以回调的方式来唤起刚才的线程,叫号就相当于一个唤醒操作。由此可见,异步的效率会更高一点,但是从编程角度来说需要注意的问题也更多一点。
四、什么是阻塞
阻塞其实就是字面意思,就是当前线程停在这里不往下继续执行了。
五、协程
1.什么是协程
了解上述基本概念后,我们可以明白一个问题,因为 Android 的主流刷新率是 60Hz,即 1 秒绘制 60 帧,也就是约 16ms 绘制一帧,而界面绘制是由主线程去负责,所以主线程是不能阻塞的,否则轻则卡顿,重则 ANR,但程序中不可避免的会有一些阻塞的操作,比如读写文件、网络请求、数据库操作等,这时候我们就需要引入异步的概念,没有引入协程的概念前我们通常是将这些操作放到子线程中去进行,然后完成后通过回调的方式获取结果,再通知界面刷新。但创建线程是需要耗费内存资源的,而且线程间的切换是由系统决定,也是消耗一些内存资源,因此协程应用而生。
协程是一种编程思想,并不局限于特定的语言。协程设计的初衷是为了解决并发问题,让协作式多任务实现起来更加方便。
协程可以看作是轻量级线程,它相较于线程而言,是由应用程序去控制的,不涉及到系统内核状态的切换,因此会提高执行效率,并且 kotlin 的语法让我们更方便的控制协程的挂起和恢复,还能获取异步执行的结果,可以说是简化了很多操作。
2.基本使用
首先我们需要添加协程依赖:
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
}
启动一个协程:
GlobalScope.launch {
Log.i(TAG, "Current thread: ${Thread.currentThread().name}.Current coroutine: ${coroutineContext[CoroutineName]}")
}
可以看到这样就启动了一个协程,看着还是挺简单的,我们可以看到打印的线程并不是主线程,协程名是 null,它所在的线程和协程是由什么确定的呢,来看看 launch 方法:
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}
launch 是 CoroutineScope 的一个扩展方法,CoroutineScope 的意思是协程作用域,该方法由三个参数,第一个参数是 CoroutineContext,即协程上下文;第二个参数为 CoroutineStart,即协程启动方式;第三个参数为一个挂起函数,即真正执行我们逻辑的代码块。该方法还返回了一个 Job 对象,通过这个对象我们取消协程和观察协程的执行状态。Ok,那我们就逐一分析协程作用域以及这几个参数。
3.协程作用域
我们刚才使用了 GlobalScope 去启动一个协程,它从字面意思来说是全局作用域,它在虚拟机中只有一份对象实例,而且它的生命周期贯穿整个 JVM,所以我们在使用它的时候需要警惕内存泄漏。所以一般并不推荐使用 GlobalScope 去启动协程,在 AS 中如果使用 GlobalScope 也会出现警告,而协程的作用域,大范围的有以下几个:
- GlobalScope:全局作用域,调度器默认为 Dispatchers.DEFAULT。
- LifecycleCoroutineScope:与生命周期绑定的作用域,会在 Lifecycle 被销毁时取消所有协程任务,调度器默认为 Dispatchers.MAIN。
- ViewModel.viewModelScope:与 ViewModel 生命周期绑定的作用域,会在 ViewModel 被清除时取消所有协程任务,调度器默认为 Dispatchers.MAIN。
除了这几个大的启动协程的作用域范围,协程作用域还可以细分为:
- 顶级作用域:没有父协程。
- 协同作用域:我觉得也叫父子作用域,协程中启动子协程,子协程的作用域默认为协同作用域,子协程如果失败,那父协程也会失败。
- 主从作用域:类似于协同作用域,唯一的区别就是子协程如果失败,不会影响父协程。
用两段代码来演示一下协同作用域和主从作用域的区别:
lifecycleScope.launch {
coroutineScope {
val job1 = async {
Log.i(TAG, "Job1 start.")
delay(200)
Log.i(TAG, "Job1 end.")
}
val job2 = async {
Log.i(TAG, "Job2 start.")
delay(500)
val a = 1 / 0
Log.i(TAG, "Job2 end.")
}
Log.i(TAG, "coroutineScope")
}
}
Job1 正常执行,Job2 中抛出异常并且中断了程序执行。
lifecycleScope.launch {
supervisorScope {
val job1 = async {
Log.i(TAG, "Job1 start.")
delay(200)
Log.i(TAG, "Job1 finish.")
}
val job2 = async {
Log.i(TAG, "Job2 start.")
delay(500)
val a = 1 / 0
Log.i(TAG, "Job2 finish.")
}
Log.i(TAG, "supervisorScope")
}
}
Job1 正常执行,Job2 执行到异常代码后,后续代码没有执行,但并没有影响其他协程以及没有导致整个程序 crash。
4.CoroutineContext
协程上下文用来定义协程行为的关键信息,它包含如下几个东西:
- Job:协程的生命周期的句柄
- CoroutineDispatcher:协程调度器
- CoroutineName:协程的名字
- CoroutineExceptionHandler:协程的异常处理
我们在启动一个协程时它是有默认参数 EmptyCoroutineContext 的,通常情况下我们不需要去自定义上下文,最常用的操作也就是指定协程调度器了,而 kotlin 默认提供了几种调度器已满足我们的大部分使用场景,我们通过 Dispatcher 创建如下调度器:
- Default:默认调度器,CPU 密集型任务调度器,适合处理后台计算。通常处理一些单纯的计算任务,或者执行时间较短任务,如数据解析,数据计算等;
- IO:IO 调度器,IO 密集型任务调度器,适合执行 IO 相关操作。如网络请求、数据库操作、文件读写等。
- Main:UI 调度器, 即在主线程上执行。
- Unconfined:非受限调度器,又或者称为“无所谓”调度器,不要求协程执行在特定线程上。
其中前三种使用更多。EmptyCoroutineContext 使用的默认调度器,所以上面的打印中,当前的线程是子线程的名字,如果指定 Dispatcher.Main,那么打印的线程名则会是 main。
lifecycleScope.launch(Dispatchers.Default) {
Log.i(TAG, "Dispatchers.Default,Current thread: ${Thread.currentThread().name}.Current coroutine: ${coroutineContext[CoroutineName]}")
}
lifecycleScope.launch(Dispatchers.IO) {
Log.i(TAG, "Dispatchers.IO,Current thread: ${Thread.currentThread().name}.Current coroutine: ${coroutineContext[CoroutineName]}")
}
lifecycleScope.launch(Dispatchers.Main) {
Log.i(TAG, "Dispatchers.Main,Current thread: ${Thread.currentThread().name}.Current coroutine: ${coroutineContext[CoroutineName]}")
}
5.CoroutineStart
协程的启动模式也有默认值,通常我们不需要去特别指定,它的可选值有以下四种:
- DEFAULT:默认启动模式,我们可以称之为饿汉启动模式,因为协程创建后立即开始调度,虽然是立即调度,但不是立即执行,有可能在执行前被取消。
- LAZY:懒汉启动模式,启动后并不会有任何调度行为,直到我们需要它执行的时候才会产生调度。也就是说只有我们主动的调用 Job 的 start、join 或者 await 等函数时才会开始调度。
- ATOMIC:一样也是在协程创建后立即开始调度,但是它和 DEFAULT 模式有一点不一样,通过 ATOMIC 模式启动的协程执行到第一个挂起点之前是不响应 cancel() 取消操作的,ATOMIC一定要涉及到协程挂起后 cancel() 取消操作的时候才有意义,也就是说如果没有挂起操作的话,那是 cancel() 不掉的。
- UNDISPATCHED:协程在这种模式下会直接开始在当前线程下执行,直到运行到第一个挂起点,同样,如果没有挂起操作的话,也是 cancel() 不掉的。这听起来有点像 ATOMIC,不同之处在于 UNDISPATCHED 是不经过任何调度器就开始执行的。当然遇到挂起点之后的执行,将取决于挂起点本身的逻辑和协程上下文中的调度器。
用不同启动方式看一下执行效果:
val defaultJob = lifecycleScope.launch(start = CoroutineStart.DEFAULT) {
Log.i(TAG, "Default job start.")
}
defaultJob.cancel()
val lazyJob = lifecycleScope.launch(start = CoroutineStart.LAZY) {
Log.i(TAG, "Lazy job start.")
}
val atomicJob = lifecycleScope.launch(context = Dispatchers.IO, start = CoroutineStart.ATOMIC) {
Log.i(TAG, "Atomic job Before delay,Current thread: ${Thread.currentThread().name}")
delay(1000)
Log.i(TAG, "Atomic job After delay,Current thread: ${Thread.currentThread().name}")
}
atomicJob.cancel()
val undispatchedJob = lifecycleScope.launch(context = Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
Log.i(TAG, "Undispatched job Before delay.")
delay(1000)
Log.i(TAG, "Undispatched job After delay.")
}
undispatchedJob.cancel()
执行上述代码,可能产生如下几种结果:
DEFAULT 方式启动的协程紧接着执行了 cancel() 方法,由于立即调度但不是立即执行,所以有可能执行也有可能不执行;
LAZY 方式启动的协程,由于没有调用 start() 或其他操作,所以一直没有执行;
ATOMIC 和 UNDISPATCHED 也是紧接着执行了 cancel() 方法,但由于它们都是在遇到第一个挂起点之前是不响应 cancel() 操作的,所以挂起点之前的代码都执行了,并且这两个协程的先后顺序并不固定。
上面还提到一点,UNDISPATCHED 直接开始在当前线程下执行,直到运行到第一个挂起点,遇到挂起点之后的执行,将取决于挂起点本身的逻辑和协程上下文中的调度器。这一点我们也可以用代码演示一下:
lifecycleScope.launch(Dispatchers.Main) {
val undispatchedJob = launch(context = Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
Log.i(TAG, "Undispatched job before delay,Current thread: ${Thread.currentThread().name}")
delay(1000)
Log.i(TAG, "Undispatched job after delay,Current thread: ${Thread.currentThread().name}")
}
Log.i(TAG, "Undispatched job before join,Current thread: ${Thread.currentThread().name}")
undispatchedJob.join()
Log.i(TAG, "Undispatched job after join,Current thread: ${Thread.currentThread().name}")
}
执行上述代码,会看到如下结果:
可以看到以 UNDISPATCHED 方式启动的协程,虽然指定了调度器是 IO,但是挂起点之前却是执行在 main 里,在 join 之后才切换到了子线程中执行。
6.挂起函数
什么是挂起呢?挂起就是保存当前状态,等待恢复执行,可以理解为切换到了一个指定的线程。launch 方法传递的第三个参数就是一个挂起函数,而 launch 方法后面如果还有代码的话,那么就会它们时并发执行的,而且极大概率是 launch 后的代码紧接着执行一段时间后才会执行挂起函数里面的代码。
这一点,我们通过刚才协程的启动方式就能很好理解,协程只是立即调度了,但不一定立即执行,从刚才 DEFAULT 方式启动的协程有可能被 cancel() 掉,我们就能明白。那么有什么办法让协程的挂起函数执行完成后再往下执行吗?答案是肯定的。
a.runBlocking
使用 runBlocking 函数可以保证在协程作用域内的所有代码和子协程没有全部执行完之前一直阻塞当前线程。
lifecycleScope.launch(Dispatchers.IO) {
runBlocking {
Log.i(TAG, "Run blocking before delay.")
delay(2000)
Log.i(TAG, "Run blocking after delay.")
}
Log.i(TAG, "Continue after run blocking finish.")
}
runBlocking 作为顶层函数,可以在任意地方独立调用,但据说有性能问题,所以通常用于单元测试中,实际开发中并不推荐使用,那真正开发中我们应该用什么呢?
b.async
async 不同于 runBlocking 直接返回结果,async 会返回一个 Deferred<T> 对象,这个对象有点类似 Java 中的 Future,我们可以通过调用它来获取协程的执行状态或者获取传入的函数的执行结果。
async 中传入的函数会在调用后立即执行。我们可以调用它返回的 Deferred<T> 对象的 await() 方法来获取传入的函数的执行结果,如果在调用 await() 方法时,传入的函数中的代码还没有执行完,那么就会将当前协程阻塞住,直到结果返回时才重新唤醒。因此我们也知道了 async 必须在协程作用域下才能调用,而不像 runBlocking 可以在任意地方调用。
lifecycleScope.launch(Dispatchers.IO) {
Log.i(TAG, "Before async.")
val a = async {
Log.i(TAG, "Before delay.")
delay(2000)
Log.i(TAG, "After delay.")
return@async 1 + 1
}
Log.i(TAG, "After async,before await.")
delay(1000)
val result = a.await()
Log.i(TAG, "After await,result is : $result")
}
通过上述代码的 log 可以看出来,async 是立即执行的,并不是调用 await 才执行的,而调用 await() 时由于里面的代码块还没有执行完,所以当前协程阻塞,直到代码块执行完成才继续向下执行。
c.withContext
withContext 相当于 async 的简化版,直接返回函数体的执行结果,而不需要调用 await() 方法,但是 withContext 需要我们传入调度器参数。
lifecycleScope.launch(Dispatchers.IO) {
Log.i(TAG, "Before withContext.")
val result = withContext(Dispatchers.Default) {
Log.i(TAG, "Before delay.")
delay(2000)
Log.i(TAG, "After delay.")
1 + 1
}
Log.i(TAG, "After withContext,result is : $result")
}
7.Job
Job 作为启动协程的返回值,我们可以通过它来获取协程的状态,也可以用于唤醒或者取消协程,Job 有如下状态:
- New:新创建
- Active:活跃状态
- Completing:完成中
- Cancelling:取消中
- Cancelled:已取消
- Completed:已完成
我们通过 Job 的 isActive、isCompleted、isCancelled 来判断当前是否正处于某个状态。
在取消协程时,需要注意一个作用域的问题,当我们取消一个父协程时,它的所有子协程都会被取消。
lifecycleScope.launch(Dispatchers.IO) {
val parentJob = launch {
Log.i(TAG, "Parent job start.")
withContext(Dispatchers.IO) {
Log.i(TAG, "withContext job start.")
delay(2000)
1 + 1
Log.i(TAG, "withContext job finish.")
}
Log.i(TAG, "Parent job finish.")
}
Log.i(TAG, "Before delay,parentJob.isActive:${parentJob.isActive},parentJob.isCompleted:${parentJob.isCompleted},parentJob.isCancelled:${parentJob.isCancelled}")
delay(1000)
parentJob.cancel()
Log.i(TAG, "After delay,parentJob.isActive:${parentJob.isActive},parentJob.isCompleted:${parentJob.isCompleted},parentJob.isCancelled:${parentJob.isCancelled}")
}
协程取消的时候还需要注意一点,就是资源的释放问题,可以对阻塞的代码块使用 try-finally{} 代码块包裹,保证资源的释放。
六、总结
以上是协程的初步了解,通过对这几个点的整理,基本可以正常的使用协程,以及可以通过协程的一些特性来简化一些异步操作,当然,如果想更透彻的理解协程是如何实现的,还需要仔细翻看源码,抽丝剥茧后方可融会贯通。