- 什么是多任务?什么是协作式多任务?什么是抢占式多任务?
多任务就是操作系统能够同时处理多个任务,例如我可以使用笔记本电脑打开 AndroidStudio 和网易云音乐,一边撸码一边听歌
协作式多任务就是一个任务得到了 CPU 时间,除非它自己放弃使用 CPU ,否则将完全霸占 CPU ,所以任务之间需要协作,使用一段时间的 CPU 后,放弃使用,其它的任务也如此,才能保证系统的正常运行。一般出现在早期的操作系统中,如 Windows 3.1
抢占式多任务就是由操作系统来分配每个任务的 CPU 使用时间,在一个任务使用一段时间 CPU 后,操作系统会剥夺当前任务的 CPU 使用权,把它排在询问队列的最后,再去询问下一个任务。一般出现在现在使用的操作系统,如 Window 95及之后的 Windows 版本
协作式多任务和抢占式多任务区别:在协作式多任务中,如果一个任务死锁,则系统也会死锁。而抢占式多任务中,如果一个任务死锁,系统仍能正常运行
- 什么是阻塞?什么是非阻塞?
阻塞很简单,就是字面意思,在 Android 中的体现,其实就是阻塞了主线程的运行,那么非阻塞就是没有卡住主线程的运行 - 什么是挂起?
挂起就是保存当前状态,等待恢复执行,在 Android 中的体现,挂起就是不影响主线程的工作,更贴切的说法可以理解为切换到了一个指定的线程, - 什么是非阻塞式挂起?
非阻塞式挂起就是不会卡住主线程且将程序切换到另外一个指定的线程去执行 - 什么是协程?
它是一种协作式多任务实现,是一种编程思想,并不局限于特定的语言。协程设计的初衷是为了解决并发问题,让协作式多任务实现起来更加方便 - 什么是 Kotlin 协程?
Kotlin 协程简单来说是一套线程操作框架,详细点说它就是一套基于线程而实现的一套更上层的工具 API,类似于 Java 的线程池,你可以理解 Kotlin 新造了一些概念用来帮助你更好地使用这些 API - Kotlin 协程有什么用?
Kotlin 协程可以用看起来同步的方式写出异步的代码,帮你优雅的处理回调地狱。
创建协程的三种方式
- 使用 runBlocking 顶层函数创建
runBlocking {
...
}
- 使用 GlobalScope 单例对象创建
GlobalScope.launch {
...
}
3.自行通过 CoroutineContext 创建一个 CoroutineScope 对象
val coroutineScope = CoroutineScope(context)
coroutineScope.launch {
...
}
- 方法一通常适用于单元测试的场景,而业务开发中不会用到这种方法,因为它是线程阻塞的。
- 方法二和使用 runBlocking 的区别在于不会阻塞线程。但在 Android 开发中同样不推荐这种用法,因为它的生命周期会只受整个应用程序的生命周期限制,且不能取消。
- 方法三是比较推荐的使用方法,我们可以通过 context 参数去管理和控制协程的生命周期(这里的 context 和 Android 里的不是一个东西,是一个更通用的概念,会有一个 Android 平台的封装来配合使用)
协程的取消
与线程类比,java 线程其实没有提供任何机制来安全地终止线程。
Thread 类提供了一个方法 interrupt() 方法,用于中断线程的执行。调用interrupt()方法并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。然后由线程在下一个合适的时机中断自己。
但是协程提供了一个 cancel() 方法来取消作业。
协程并不是一定能取消,协程的取消是协作的。一段协程代码必须协作才能被取消。
所有 kotlinx.coroutines 中的挂起函数都是 可被取消的 。它们检查协程的取消, 并在取消时抛出 CancellationException。
如果协程正在执行计算任务,并且没有检查取消的话,那么它是不能被取消的。
fun main() = runBlocking {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (i < 5) { // 一个执行计算的循环,只是为了占用 CPU
// 每秒打印消息两次
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: hello ${i++} ...")
nextPrintTime += 500L
}
}
}
delay(1300L) // 等待一段时间
println("main: ready to cancel!")
job.cancelAndJoin() // 取消一个作业并且等待它结束
println("main: Now cancel.")
此时的打印结果:
job: hello 0 ...
job: hello 1 ...
job: hello 2 ...
main: ready to cancel!
job: hello 3 ...
job: hello 4 ...
main: Now cancel.
可见协程并没有被取消。为了能真正停止协程工作,我们需要定期检查协程是否处于 active 状态。
- 检查 job 状态
- 一种方法是在 while(i<5) 中添加检查协程状态的代码
代码如下:
while (i < 5 && isActive)
- 使用协程标准库中的函数 ensureActive(), 它的实现是这样的
public fun Job.ensureActive(): Unit {
if (!isActive) throw getCancellationException()
}
代码如下:
while (i < 5) { // 一个执行计算的循环,只是为了占用 CPU
ensureActive()
...
}
ensureActive() 在协程不在 active 状态时会立即抛出异常。
- 使用 yield()
yield() 和 ensureActive 使用方式一样。
yield 会进行的第一个工作就是检查任务是否完成,如果 Job 已经完成的话,就会抛出 CancellationException 来结束协程。yield 应该在定时检查中最先被调用。
while (i < 5) { // 一个执行计算的循环,只是为了占用 CPU
yield()
...
}
CoroutineScope 协程作用域
- Kotlin 中规定协程必须在 CoroutineScope 中运行;
- Kotlin 中规定协程只有在 CoroutineScope 才能被创建
CoroutineScope 是一个接口,它为协程定义了一个范围「或者称为 作用域」,每一种协程创建的方式都是它的一个扩展「方法」,要是查看这个接口的源代码的话就发现这个接口里面只定义了一个属性 CoroutineContext
CoroutineScope包含了协程的上下文、Job、子协程等。通过扩展函数launch、async、cancel等,实现了开启子协程、取消所有子任务等功能。在这个作用域下新开启的协程,则是当前协程的子协程。CoroutineScope可以管理协程的生命周期。
目前常用的两种方式:
- launch 异步启动一个子协程, 一般用在不需要返回结果的地方
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit): Job {...}
launch方法的参数:
- context:协程上下文,可以指定协程运行的线程。默认与指定的CoroutineScope中的coroutineContext保持一致,比如GlobalScope默认运行在一个后台工作线程内。也可以通过显示指定参数来更改协程运行的线程,Dispatchers提供了几个值可以指定:Dispatchers.Default、Dispatchers.Main、Dispatchers.IO、Dispatchers.Unconfined。
- start:协程的启动模式。默认的(也是最常用的)CoroutineStart.DEFAULT是指协程立即执行,除此之外还有CoroutineStart.LAZY、CoroutineStart.ATOMIC、CoroutineStart.UNDISPATCHED。
- block:协程主体。也就是要在协程内部运行的代码,可以通过lamda表达式的方式方便的编写协程内运行的代码。
- CoroutineExceptionHandler:处理协程内部的异常除此之外,还可以指定CoroutineExceptionHandler来处理协程内部的异常。
返回值Job:对当前创建的协程的引用。可以通过Job的start、cancel、join等方法来控制协程的启动和取消。
- async 异步启动一个子协程,并返回Deffer对象(也是一种 job),可通过调用Deffer.await()方法等待该子协程执行完成并获取结果,常用于并发执行-同步等待的情况
deferred 也是可以取消的,对于已经取消的 deferred 调用 await() 方法,会抛出
JobCancellationException 异常。同理,在 deferred.await 之后调用 deferred.cancel(), 那么什么都不会发生,因为任务已经结束了。
launch 更多是用来发起一个无需结果的耗时任务(如批量文件删除、创建),这个工作不需要返回结果。async 函数则是更进一步,用于异步执行耗时任务,并且需要返回值(如网络请求、数据库读写、文件读写),在执行完毕通过 await() 函数获取返回值
使用CoroutineScope 实现协程的两种方式
- 手动实现 CoroutineScope 接口
- 在 ViewModel 中实现协程
- 手动实现 CoroutineScope 接口
class MyActivity : AppCompatActivity(), CoroutineScope by MainScope() {
override fun onDestroy() {
cancel() // cancel is extension on CoroutineScope
}
// 实现接口后,便可以调用它的扩展方法去创建协程.
fun showSomeData() {
launch {
// <- extension on current activity, launched in the main thread
// ... here we can use suspending functions or coroutine builders with other dispatchers
draw(data) // draw in the main thread
}
}
}
实现该接口的注意事项
当前的类,必须要是一个定义好的,带有生命周期的对象 -> 便于我们释放协程。
有些时候,根据需求会需要你实现 CoroutineScope, 在你定义的生命周期里,例如和 Application 的生命周期一致,在后台继续工作。
- 在 ViewModel 中实现协程
/**
* 有关协程测试的 demo
*
*/
class CoroutineDemoViewModel : ViewModel() {
/**
* 开启协程方式: 1. launch; 2. async
*/
fun startCoroutine() {
// viewModelScope 是 ViewModel 的一个成员变量「扩展而来」
viewModelScope.launch(Dispatchers.IO) {
delay(1000)
// async
val result = async {
delay(2000)
}
result.await()
}
}
suspend fun test() {
coroutineScope {
}
}
}
首先, viewModelScope 是 ViewModel 的一个扩展的成员变量,是 CoroutineScope的一个对象实例。
也就是说,在 ViewModel 中,默认帮忙开发者创建了这么一个对象,也是为了便于在 ViewModel 中使用协程。
为什么推荐在 ViewModel 中使用呢
- ViewModel 是具有生命周期的,跟随当前的 Activity 或者 Fragment ;
- ViewModel 本身是为了处理一些耗时操作设计的,从 UI 中剥离出来;
- ViewModel 在销毁时,同时会销毁它里面所有正在运行的协程;
ViewModel 自动销毁 CoroutineScope 的逻辑
viewModelScope() 源码如下:
val ViewModel.viewModelScope: CoroutineScope
get() {
val scope: CoroutineScope? = this.getTag(JOB_KEY)
if (scope != null) {
return scope
}
return setTagIfAbsent(JOB_KEY,
CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate))
}
其中 setTagIfAbsent(xxx) 会把当前 CloseableCoroutineScope 存放在 mBagOfTags 这个 HashMap 中。
当 ViewModel 被销毁时会走 clear() 方法:
MainThread
final void clear() {
mCleared = true;
// Since clear() is final, this method is still called on mock objects
// and in those cases, mBagOfTags is null. It'll always be empty though
// because setTagIfAbsent and getTag are not final so we can skip
// clearing it
if (mBagOfTags != null) {
synchronized (mBagOfTags) {
for (Object value : mBagOfTags.values()) {
// see comment for the similar call in setTagIfAbsent
closeWithRuntimeException(value);
}
}
}
onCleared();
}
CoroutineContext
协程上下文表示协程的运行环境,包括协程调度器、代表协程本身的Job、协程名称、协程ID等
CoroutineScope和CoroutineContext非常类似,最终目的都是协程上下文,但正如Kotlin协程负责人Roman Elizarov在Coroutine Context and Scope中所说,二者的区别只在于使用目的的不同——作用域用于管理协程;而上下文只是一个记录协程运行环境的集合
协程的代码运行过程分析
首先明确几个概念:
- Coroutine 协程中是可以新建协程的「不断套娃」;
- 什么时候协程算运行结束了呢?当它运行结束,并且它所有的子协程执行结束才算结束;
- 同时……当里面的某个子协程发生异常时,整个协程都会停止运行,抛出异常;
- suspend 关键字标注的方法,只能被协程里或者另外一个 suspend方法调用;
关键字 suspend 的意义:「挂起」
挂起函数必须在协程或者其他挂起函数中被调用,换句话说就是挂起函数必须直接或者间接地在协程中执行
挂起就是保存当前状态,等待恢复执行,在 Android 中的体现,挂起就是不影响主线程的工作,更贴切的说法可以理解为切换到了一个指定的线程
- 当代码运行到这里时,会挂起当前的协程,不在继续向下执行,直到该方法运行结束, 协程恢复,继续往运行。
- 协程 aync 是并发的
- 协程的挂起与线程的执行状态没有任何关系,Thread-A 为执行协程 coroutine-a的线程名称,当该 coroutine-a 协程被挂起时,Thread-A 可能会转去做其他事情,Thread-A 的状态与 coroutine-a的状态 没有关系。
- 调度器中 DefaultScheduler.IO 里面不止一个线程
- 协程恢复后,会自动帮我们检测是否需要切换调度器,如果需要,则切换为原本协程的调度器,在其中线程池中选择一个线程,继续运行该协程。
- suspend 函数在运行结束后,会自动切换到原来的协程调度器内
coroutine-a 协程被挂起,开启 coroutine-b 协程,本质上是,先切换为 coroutine-b 所在的 协程调度器内,然后在该调度器内调度一个线程给该协程运行,当再次恢复协程 coroutine-a, 会在 coroutine-a 的调度器里面选择一个线程供协程运行。
实现原理
- 每个 suspend 方法在编译成java 后,它可能会被调用不止一次
- 挂起函数的实现原理,仍然是我们熟悉的回调,只不是协程帮忙我们封装好了一套完整的回调流程
CoroutineDispatcher 协程调度器
- Default
默认的调度器, 在 Android 中对应的为「线程池」。
在新建的协程中,如果没有指定 dispatcher和 ContinuationInterceptor 则默认会使用该 dispatcher。
线程池中会有多个线程。它使用JVM的共享线程池,该调度器的最大并发度是CPU的核心数,默认为2
适用场景:此调度程序经过了专门优化,适合在主线程之外执行占用大量 CPU 资源的工作。用法示例包括对列表排序和解析 JSON
- Main
在主线程「UI 线程」中的调度器。
只在主线程中, 单个线程。
适用场景:使用此调度程序可在 Android 主线程上运行协程。此调度程序只能用于与界面交互和执行快速工作。示例包括调用 suspend 函数、运行 Android 界面框架操作,以及更新 LiveData 对象。
- Unconfined
非受限调度器,它不会将操作限制在任何线程上执行——在发起协程的线程上执行第一个挂起点之前的操作,在挂起点恢复后由对应的挂起函数决定接下来在哪个线程上执行。 - IO
在 IO 线程的调度器,里面的执行逻辑会运行在 IO 线程, 一般用于耗时的操作。
对应的是「线程池」,会有多个线程在里面。IO 和 Default 共享了线程,因此使用withContext(Dispatchers.IO)创建新的协程不一定会导致线程的切换。
IO调度器,他将阻塞的IO任务分流到一个共享的线程池中,使得不阻塞当前线程。该线程池大小为环境变量kotlinx.coroutines.io.parallelism的值,默认是64或核心数的较大者。
适用场景: 此调度程序经过了专门优化,适合在主线程之外执行磁盘或网络 I/O。示例包括使用 Room 组件、从文件中读取数据或向文件中写入数据,以及运行任何网络操作。