Kotlin协程核心理解


协程知识点思维导图

一、协程是什么?

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()
      MainScope()

    • lifecycleScope
      在这里插入图片描述
      lifecycleScope中的协程会在Activity销毁时执行cancel

    • viewModelScope
      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线程中,我们可以采用sychronizedReentrantLock方式进程多线程同步处理,保证最终数据的正确性。在协程中,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)
    
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

TechMix

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值