
一、协程是什么?
1.1 概念的理解
1.1.1 什么是协程?
1.1.2 和线程有什么区别?
- (1)实现方式上的区别
我们知道JVM中线程最终是操作系统实现的,JVM只是在上层进行了API的封装,包含常见的有线程的启动方法、状态的管理,比如:Java中抽象出了6种状态,提供了start方法用于启动线程。 - (2)资源占用上的区别
线程是基于操作系统实现的,对于资源占用还是相当大的,每个线程都有独立的函数调用栈、本地变量表、程序计数器(PC)等数据,页正因如此,对于进程中能创建的最大线程数是有限制的,比如某些华为手机系统的上线是500个。
ps: 线程和进程在kernel层的数据结构也是相同的。
协程实现是从kt语言层面实现,占用资源很少,实测创建10000个线程会OOM,但是10000个协程并不会。 - (3)调度方式区分,怎么实现取消??
但是线程一旦调用start()开始执行,那我们是很难再控制线程的停止的,尽管jdk中提供了interrupt()中断线程的方法,但是interrupt()也只是做了标记线程需要中断,最终是否中断,什么时候中断还是依赖操作系统的具体实现逻辑,从语言层面来说是无法直接控制的。
// java线程的状态定义在Java$State枚举对象中
public enum State {
/**
* Thread state for a thread which has not yet started.
*/
NEW,
/**
* Thread state for a runnable thread. A thread in the runnable
* state is executing in the Java virtual machine but it may
* be waiting for other resources from the operating system
* such as processor.
*/
RUNNABLE,
/**
* Thread state for a thread blocked waiting for a monitor lock.
* A thread in the blocked state is waiting for a monitor lock
* to enter a synchronized block/method or
* reenter a synchronized block/method after calling
* {@link Object#wait() Object.wait}.
*/
BLOCKED,
/**
* Thread state for a waiting thread.
* A thread is in the waiting state due to calling one of the
* following methods:
* <ul>
* <li>{@link Object#wait() Object.wait} with no timeout</li>
* <li>{@link #join() Thread.join} with no timeout</li>
* <li>{@link LockSupport#park() LockSupport.park}</li>
* </ul>
*
* <p>A thread in the waiting state is waiting for another thread to
* perform a particular action.
*
* For example, a thread that has called {@code Object.wait()}
* on an object is waiting for another thread to call
* {@code Object.notify()} or {@code Object.notifyAll()} on
* that object. A thread that has called {@code Thread.join()}
* is waiting for a specified thread to terminate.
*/
WAITING,
/**
* Thread state for a waiting thread with a specified waiting time.
* A thread is in the timed waiting state due to calling one of
* the following methods with a specified positive waiting time:
* <ul>
* <li>{@link #sleep Thread.sleep}</li>
* <li>{@link Object#wait(long) Object.wait} with timeout</li>
* <li>{@link #join(long) Thread.join} with timeout</li>
* <li>{@link LockSupport#parkNanos LockSupport.parkNanos}</li>
* <li>{@link LockSupport#parkUntil LockSupport.parkUntil}</li>
* </ul>
*/
TIMED_WAITING,
/**
* Thread state for a terminated thread.
* The thread has completed execution.
*/
TERMINATED;
}
- Java线程六种状态转换关系:
对比 | 线程 | 协程 |
---|---|---|
实现方式 | 操作系统实现 | kotlin语言层面 |
资源占用 | 较大 | 较小 |
最大数量限制 | 每个进程内最大线程数有限制,由操作系统确定 | 基本没有上限要求,因为协程本质上运行在线程中,所以如果线程已经达到了上限无法创建,协程也将无法使用 |
- (4)协程中的代码仍然运行在哪儿?
而协程内的代码依然执行在线程上,因为线程是CPU调度的基本单元这个大前提还是不变的,属于操作系统层面的基本概念了。但是协程通过使用状态机的方式在语言层面上实现了一种状态、生命周期更易管控的线程任务调度框架(语言层面的框架),也可以理解为轻量级线程,但不像线程那样直接使用操作系统实现的线程,一旦启动基本就只能等任务执行结束或请求中断才停止运行。
1.2 协程和线程、进程的关系
- 启动一个线程任务:
val thread = Thread {
val result = requestUserInfo()
println("task1 finished, result = $result")
}
thread.start()
// 或者使用ThreadPoolExecutor.execute(Runnable),
// 最终也是调用了Thread的start方法启动线程
- 启动一个协程任务:
// launch方法会返回协程的Job类型对象
val job = launch {
val result = requestUserInfo()
println("task1 finished, result = $result")
}
// requestUserInfo()切换协程运行的线程需要增加suspend声明为挂起函数
// 因为使用withContext(Dispathcers.IO)指定协程调度器,所以执行在IO的线程池中
suspend fun requestUserInfo(): UserInfo = withContext(Dispatchers.IO) {
delay(500)
return@withContext UserInfo("10000", "zhangsan")
}
- 总结一下,协程和线程的区别:
- 线程一旦开始执行就不会暂停,直到任务结束,这个过程是连续的
- 协程能够自己挂起和恢复,语言层面实现了挂起和恢复流程,能够实现协作式调度
1.3 使用协程的关键API
1.3.1 协程作用域:CoroutineScope
创建协程或调用挂起函数必须有协程作用域,kotlin创建作用域有三种办法,GlobalScope、runBlocking和CoroutineScope()方法。
- Android中提供的协程作用域有:
-
MainScope()
-
lifecycleScope
lifecycleScope中的协程会在Activity销毁时执行cancel -
viewModelScope
-
1.3.2 协程对象:Job
public interface Job : CoroutineContext.Element {
// 注(1)
public companion object Key : CoroutineContext.Key<Job>
// 如果协程还未启动,比如传入的start对象是LAZY,可通过主动调用
// start方法启动协程
public fun start(): Boolean
// 注(2)
public fun cancel(cause: CancellationException? = null)
// 当前协程的子协程
public val children: Sequence<Job>
// 附加子协程,使当前协程对象成为父协程
@InternalCoroutinesApi
public fun attachChild(child: ChildJob): ChildHandle
// 等待当前协程执行完成,比如调用协程的cancel()方法后,调用join()
// 就是等待协程cancel执行完成
public suspend fun join()
// 注册在取消或完成此作业时 同步 调用一次的处理程序
public fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle
@InternalCoroutinesApi
public fun invokeOnCompletion(
onCancelling: Boolean = false,
invokeImmediately: Boolean = true,
handler: CompletionHandler): DisposableHandle
}
- (1)Key : 声明成伴生对象后,只要是同一种类型的Job创建出来的不同Job实例,key都是相同的,也就是同类型的Job对象key也相同
- (2)cancel(): 取消协程
1.3.3 协程上下文:CoroutineContext
存放协程相关的一些信息
1.3.4 协程调度器:CoroutineDispatcher
- Dispatchers.Main: Android中特有的,在主线程中执行协程代码,其他平台使用会抛出异常
- Dispatchers.IO: 用于IO密集型的协程任务
- Dispatchers.Default: 用于CPU密集型的协程任务
- Dispathcers.Unconfined: 不指定协程执行的线程调度器
1.3.5 创建协程:launch()
启动一个新的协程,参数可以传递Dispatcher,指定协程运行在哪个线程调度器中。
1.3.6 切换协程调度器:withContext()
切换协程调度器,也就是切换协程执行的线程,参数是Dispatchers.IO、Dispatchers.Main、Dispatcher.Default
1.3.7 异步并行任务:async()
参数传入协程调度器,指定在哪个线程调度器指定的线程(池)中运行协程,同时返回一个类似于Java的Callback、Future的Deferred类型的Job对象,是Job的子类,最后调用await()方法同步等待执行完成的结果。
public interface Deferred<out T> : Job {
public suspend fun await(): T
}
- kotlin文档对await函数的解释:
在不阻塞线程的情况下等待此值的完成,并在延迟计算完成时恢复,返回结果值或在延迟取消时引发相应的异常。
此挂起功能是可取消的。如果当前协程的 在 Job 等待此挂起函数时被取消或完成,则此函数将立即恢复,并显示 CancellationException。有 及时取消的保证。如果在此功能暂停时取消了作业,则该作业将无法成功恢复。
1.3.8 等待任务执行完毕:join()
调用此方法会挂起协程,直到包含子协程在内的所有任务完成才会结束。
1.4 怎么捕获协程执行异常?
- (1)kotlin中异常和java中基本一致,还是用try-catch捕获,但是throw语句在kotlin中是一个表达式,可作为另外一个表达式的一部分使用。
例如:
val percentage = if (number in 0..100) {
number
} else {
throw IllegalArgumentException(
"A percentage value must be between 0 and 100: $number"
)
}
- (2)Kotlin并不区分受检异常和未受检异常。不用指定函数抛出的异常,而且可以处理也可以不处理异常。
二、协程在Android中的常见用法
2.1 子线程中执行耗时任务后切到主线程更新UI
// 场景1: 在子线程中执行耗时任务后,切到主线程处理
coroutineScope.launch {
// 挂起函数,执行时从当前线程中脱离,执行在dispatcher的IO线程池线程中,执行完毕后再切换原来的线程中
// 挂起后当前协程下一行代码会等待挂起函数执行完成
val result = withContext(Dispatchers.IO) {
// 在Dispatchers.IO(线程调度器)指定的子线程中执行下面的代码
delay(5000)
100
}
Log.d(TAG, "onCreate: main 2 =========> $coroutineContext")
binding.tvNews.text = result.toString()
}
上面的用法对于Android来说,协程是一个异步代码执行框架,相比于Thread+Handler的方式更加简洁,省去了编写从子线程发送消息到主线程的工作,并且也比切换线程资源占用更小,性能更高效。
2.2 多个耗时任务并行执行合并结果【常见的业务模型】
在Android业务中我们经常需要并行开始多个业务接口请求,然后合并成一个结果,进行后续业务逻辑的判断、UI的展示,使用Jdk提供的CountDownLatch,RxJava的zip都可以实现类似的功能逻辑。
如下展示了kotlin在这个业务模型中如何实现:
coroutineScope.launch {
// 在Dispatchers.IO执行的线程中执行任务1
val async1Result = async(Dispatchers.IO) {
Log.d(TAG, "onCreate: async1 $coroutineContext")
executeTask1()
}
// 在Dispatchers.IO执行的线程中执行任务2
val async2Result = async(Dispatchers.IO) {
Log.d(TAG, "onCreate: async2 $coroutineContext")
executeTask2()
}
// 在调用async方法之后两个协程任务都已经并行跑起来了,这时候调用await方法等待执行结果
val result = async1Result.await() + async2Result.await()
Log.d(TAG, "onCreate: async result = $result")
}
- kotlin中使用async实现类似java中Callable的协程任务,但是await方法阻塞等待结果并没有提供超时时间的参数
- async()方法是创建一个可获取返回值的协程对象,类型是Deferred,继承自Job
三、挂起函数的理解
3.1 挂起函数的本质
- 协程的核心是函数或一段程序能够支持挂起,执行完成后又从挂起位置恢复,然后继续执行后面的代码。
- kotlin的是借助线程实现的,是对线程的一个封装框架,通过launch、async启动一个协程,其实就是启动一个闭包中的代码块。
- 当执行到suspend函数时,暂时不执行协程代码了,而是从当前线程中脱离,函数内的逻辑转到协程调度器所指定的线程中去执行,等到挂起函数执行完毕后,又恢复都挂起的位置,继续执行后续逻辑
所以总结来说,挂起函数就是切到别的线程,稍后又能够自动切回来的线程调度操作。
3.2 为什么挂起函数一定要在协程或挂起函数中调用?
挂起函数切到调度器线程中后,是需要协程框架主动调用resumeWith方法再切回来的,如果在非协程非挂起函数调用,那么就没有协程环境,无法切回来,就无法实现挂起的执行逻辑。
四、如何取消协程?
协程提供了cancel方法进行取消。
class Job {
public fun cancel(cause: CancellationException? = null)
}
cancel()方法其实还有另外两个重载方法,但是打上了@Deprecated注解,所以不再使用了。
4.1 可取消点
⚠️ 协程并不是任何时刻都能被取消的,需要执行到可取消点时才能取消从而结束协程后续任务,比如:suspend方法执行时会检查执行该函数的协程是否被取消,如果取消了则不会继续执行suspend方法。
所有 kotlinx.coroutines 中的挂起函数都是可被取消的 。它们检查协程的取消,并在取消时抛出 CancellationException,如果协程正在执行任务,并且没有执行到检查取消的话,那么它是不能被取消的。
如果是想指定代码位置停止执行,这需要在相应位置检查协程的活跃状态,通常有三种方式检查:
- 1、isActive函数: 是一个可以被使用在 CoroutineScope 中的扩展属性。
- 2、ensureActive()函数
- 3、yield()函数
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
var i = 0
while (i < 5) {
println("execute count = ${i++}")
delay(500)
}
}
delay(1000)
job.cancel()
println("ready to cancel!")
job.join()
println("join done")
}
输出:
execute count = 0
execute count = 1
execute count = 2
ready to cancel!
join done
从执行打印可看到取消了协程,但是真正能取消的关键是因为delay()是suspend函数,是4.1节中讲到的可取消点,会检查协程是否取消状态,发现是取消状态会抛出CancellationException异常从而结束协程。
🙋为什么没有看到抛出异常的信息?
这是因为异常被传递到runBlocking协程中了,增加try-catch代码捕获到异常打印如下:
execute count = 0
execute count = 1
ready to cancel!
error = kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@3e4494d1
join done
JobCancellationException是继承CancellationException的,且CancellationException就是java.util.concurrent.CancellationException改了个别名衍生的。
public actual typealias CancellationException = java.util.concurrent.CancellationException
internal actual class JobCancellationException public actual constructor(
message: String,
cause: Throwable?,
@JvmField internal actual val job: Job
) : CancellationException(message), CopyableThrowable<JobCancellationException> {
}
4.2 父子协程取消关系?
- (1)取消父协程后子协程会如何?
- (2)取消子协程后父协程会如何?
五、协程并发数据同步问题
因为协程中的任务仍然在线程中被执行,所以基于java内存模式的框架,还是需要处理多线程并发问题,常见的办法就是共享变量的非原子操作加同步锁。
class Test {
private var count = 0
private val mutex = Mutex()
suspend fun test() = withContext(Dispatchers.IO) {
repeat(100) {
launch {
repeat(1000) {
count++
}
}
}
launch {
delay(3000)
println("finished----> count = $count")
}
}
}
fun main(): Unit = runBlocking {
runBlocking {
Test().test()
}
}
输出结果:
finished----> count = 97861
如上代码,在循环中创建100个协程,每个协程中对count变量累加1000次,最终count的值应该是100000,但是输出是97861,说明存在并发修改数据不一致问题。
在Java线程中,我们可以采用sychronized或ReentrantLock方式进程多线程同步处理,保证最终数据的正确性。在协程中,kotlin提供了Mutext这个类做为同步锁对象,调用Mutext的lockWith方法对共享变量加同步锁,用法如下:
- Mutex是一个接口,提供了以下几个用于加锁、释放锁的方法:
public interface Mutex {
// lock和unlock方法,传入锁对象,不传入锁对象的情况默认为null
public suspend fun lock(owner: Any? = null)
public fun unlock(owner: Any? = null)
// 尝试锁定此互斥锁,如果此互斥锁已经锁定则返回false,
// 使用的是比较交换的加同步锁机制
public fun tryLock(owner: Any? = null): Boolean
// 是Mutex类型的拓展方法,实现了在try语句中执行同步代码块逻辑,
// 在finally代码块中调用unlock释放锁的逻辑,写法跟java是一样的
public suspend inline fun <T> Mutex.withLock(owner: Any? = null, action: () -> T): T
// 被锁定时返回true
public val isLocked: Boolean
public val onLock: SelectClause2<Any?, Mutex>
public fun holdsLock(owner: Any): Boolean
}
⚠️ 上述lock、unlock方法中虽然锁对象owner允许默认为null,但是为null是没有意义的,其实在MutexImpl实现代码中提供了默认的锁对象LOCKED和UNLOCKED,当owner是null时,都会取这两个对象作为lock和unlock的owner变量值。
代码如下:
@SharedImmutable
private val LOCKED = Symbol("LOCKED")
@SharedImmutable
private val UNLOCKED = Symbol("UNLOCKED")
@SharedImmutable
private val EMPTY_LOCKED = Empty(LOCKED)
@SharedImmutable
private val EMPTY_UNLOCKED = Empty(UNLOCKED)
- Mutex提供了lock、unlock方法,也可以使用withLock方法代替lock () {} finally { unLock() }的写法,将上述存在多线程计算数据不一致的代码该用Mutex加上同步锁后如下:
class Test {
private var count = 0
private val mutex = Mutex()
suspend fun test() = withContext(Dispatchers.IO) {
repeat(100) {
launch {
repeat(1000) {
// 使用Mutex代替ReentrantLock,用法类似,
// 使用mutex.withLock{}实现不同协程的数据同步
mutex.withLock {
count++
}
}
}
}
launch {
delay(3000)
println("finished----> count = $count")
}
}
}
修改后能输出正确结果:
finished----> count = 100000
- 注意:Mutext()并不是new一个对象,而是一个方法,kotlin中包括CoroutineScope()这种代码风格也是一个方法,需要注意参阅其实现区分,以免误解
- Mutext()方法是直接new了一个MutextImpl()对象:
public fun Mutex(locked: Boolean = false): Mutex = MutexImpl(locked)