美文网首页
Kotlin协程详解(学习协程这一篇就够了)

Kotlin协程详解(学习协程这一篇就够了)

作者: 落寞1990 | 来源:发表于2024-11-01 22:47 被阅读0次

一、什么是协程

协程是一种优雅处理异步任务的解决方案 ,可以理解为是Kotlin对「线程」和「Handler」 API 的一种封装形式。它可以在不同的线程间来回切换,这样就可以让代码通过编写的顺序来执行,并且不会阻塞当前线程,省去了在各种耗时操作写回调的情形。

在 JVM 平台上,Kotlin 协程是无栈协程,所谓无栈协程,是指协程在 suspend 状态时不需要保存调用栈。

二、suspend 关键字

可以看成是一种提醒,表示这是一个耗时方法,不能直接执行,需要在协程中或者被suspend修饰的方法中调用,所以我们在写某个耗时方法的时候需要给它加上 suspend 关键字,这样可以有效避免我们在主线程中调用耗时操作造成应用卡顿的情况。

比如你在普通函数中调用「suspend fun test()」这个函数,IDE会提示你:


edfc03627887aac60de09e9c20a07d94_MhmAa2cx7l3ruviItC1T0KQfJwqb.png

三、runBlocking、launch、async 操作符

  1. runBlocking
    会阻塞当前线程
 println("测试开始 ${Thread.currentThread().name}")
 runBlocking {
   println("延迟开始 ${Thread.currentThread().name}")
 
   delay(2000)
    
   println("延迟结束")    
 }    
 println("测试结束")
    
 /** * 结果:   
 * 14:46:31.965 12741-12741      I  测试开始 main 
 * 14:46:31.967 12741-12741      I  延迟开始 main
 * 14:46:33.969 12741-12741      I  延迟结束
 * 14:46:33.970 12741-12741      I  测试结束
 */

我们可以看到:测试的时候是主线程,runBlocking中依然是主线程,从执行时间可以看到delay(2000)阻塞了主线程两秒后才执行的后边代码,所以这是会阻塞当前线程的。

思考:既然如此那runBlocking到底有什么用呢?

  1. launch
    不会阻塞当前线程,会异步执行
 println("测试开始 ${Thread.currentThread().name}")  
 GlobalScope.launch {
     println("延迟开始 ${Thread.currentThread().name}")
     delay(2000)
     println("延迟结束")
 } 
 println("测试结束")
    
 /** * 15:07:22.478 14699-14699        I  测试开始 main
 * 15:07:22.489 14699-14699        I  测试结束
 * 15:07:22.490 14699-14726        I  延迟开始 DefaultDispatcher-worker-1
 * 15:07:24.498 14699-14726        I  延迟结束
 */

我们可以看到:测试的时候是主线程,但是到了 launch 中就会变成子线程,从执行时间可以看到delay(2000) 并没有阻塞main线程的执行。

  1. async
    跟 launch 相似,唯一不同的是它可以有返回值,配合await使用
 println("测试1 ${Thread.currentThread().name}")  
 val async = GlobalScope.async {
     println("延迟1 ${Thread.currentThread().name}")
     delay(3000)
     println("延迟2")
     return@async "延迟结果666"
 }
    
 println("测试2")
    
 GlobalScope.launch {
     println("返回值1 ${Thread.currentThread().name}")
     println("返回值2 " + async.await())
     println("返回值3 ${Thread.currentThread().name}")
    
 }
    
 println("测试3")
 /** * 第一次执行结果:
 * 15:27:02.188 16262-16262       I  测试1 main
 * 15:27:02.198 16262-16262       I  测试2
 * 15:27:02.199 16262-16262       I  测试3
 * 15:27:02.199 16262-16297       I  返回值1 DefaultDispatcher-worker-2
 * 15:27:02.200 16262-16296       I  延迟1 DefaultDispatcher-worker-1
 * 15:27:05.207 16262-16296       I  延迟2
 * 15:27:05.208 16262-16296       I  返回值2 延迟结果666
 * 15:27:05.208 16262-16296       I  返回值3 DefaultDispatcher-worker-1
 * 第二次执行结果:
 * 15:27:55.650 16527-16527       I  测试1 main
 * 15:27:55.652 16527-16527       I  测试2
 * 15:27:55.652 16527-16527       I  测试3
 * 15:27:55.653 16527-16555       I  延迟1 DefaultDispatcher-worker-1
 * 15:27:55.654 16527-16556       I  返回值1 DefaultDispatcher-worker-2
 * 15:27:58.657 16527-16556       I  延迟2
 * 15:27:58.658 16527-16556       I  返回值2 延迟结果666   
 * 15:27:58.658 16527-16556       I  返回值3 DefaultDispatcher-worker-2
 */

我们可以看到:
延迟1和返回值1的执行是随机的,也就是说async和launch是随机抢占执行的。
当执行async.await()之后,launch会等到async的返回结果回来后才继续向下执行。
测试的时候是主线程,但是到了 async 中就会变成子线程。
从执行时间可以看到delay(3000) 并没有阻塞main线程的执行。而且async 可以有返回值,通过它的 await 方法进行获取。
需要注意的是这个方法只能在协程的操作符或者被 suspend 修饰的方法中才能调用。
四、delay关键字
首先想一下,在 GlobalScope.launch(Dispatchers.Main) 中执行 delay(5000) 为什么不会阻塞主线程呢?

这需要我们立即它的挂起机制和恢复机制:

  1. 挂起 (Suspension) 机制
    挂起函数 :挂起函数(如 delay)是 Kotlin 协程的核心概念。它们通过 suspend 关键字标记,表示这些函数可以挂起协程的执行,而不阻塞线程。delay 的内部实现并不直接使用 Thread.sleep(),因为 Thread.sleep() 会阻塞线程。而是利用协程调度器和挂起机制来实现延迟。

挂起操作 :

* 当协程调用挂起函数(例如 `delay(5000)`)时,它会将协程的执行挂起,而不是线程。这意味着线程资源会被释放,主线程可以继续处理其他任务。
* 挂起函数会将协程的状态和上下文保存,然后释放线程,使得线程可以用于其他任务或协程。

挂起的含义 :挂起是指协程的执行被暂停,线程资源被释放,而不是线程被阻塞。协程可以在指定时间后恢复执行,同时主线程可以继续处理其他任务。

  1. 恢复 (Resumption) 机制

恢复执行 :挂起的协程会在指定的时间(如 delay 完成后)恢复执行。协程调度器负责将挂起的协程重新调度到合适的线程上,继续执行挂起时之后的代码。

挂起队列 :在协程被挂起期间,调度器维护一个挂起队列,用于追踪哪些协程在等待恢复。挂起时间结束后,协程会从队列中取出并恢复执行。

  1. delay和hanler&looper机制的关系

在 Android 上,delay 函数和协程的挂起恢复机制确实依赖于 Handler 和 Looper 机制,特别是在使用 Dispatchers.Main 时,这是因为主线程的调度通常是通过 Handler 和 Looper 来实现的。

当你在 Dispatchers.Main 中调用 delay 时,实际上是向主线程的消息队列中添加了一个定时任务。
delay 的内部通过 Handler 向主线程的 Looper 发送一个延时的消息。当延时结束时,Handler 会处理这个消息,触发协程的恢复。
以 Dispatchers.Main 上的 delay 为例,协程的挂起与恢复过程可以简化为以下步骤:

1挂起协程 :

  • 当调用 delay 时,协程的状态会被保存(例如当前的执行位置、局部变量等)。
  • delay 使用 Handler.postDelayed 向主线程的 Looper 发送一个延时消息。

2消息队列等待 :

  • Looper 在等待延时结束的同时,继续处理其他消息或任务(例如其他协程或 UI 事件)。这意味着主线程并没有被阻塞。

3恢复协程 :

  • 当延时结束,Handler 会从消息队列中取出延时消息并执行。这时,delay 触发协程的恢复。
  • 恢复后,协程从挂起点继续执行。
    五、线程调度器
  1. 主要作用

1、线程分配 :决定了协程在哪个线程或线程池上执行。

2、上下文切换 :调度器在协程挂起和恢复时管理上下文切换。它负责将协程的执行从一个线程转移到另一个线程(如果需要),确保协程在正确的线程上执行。

  1. 常见的调度器

1、Dispatchers.Main:在主线程中执行。用于需要更新 UI 的任务或与 Android 的主线程相关的操作

2、Dispatchers.IO:在一个共享的 IO 线程池中执行协程,适合执行网络请求、数据库操作或文件读写等 IO 密集型任务。

3、Dispatchers.Default:默认调度器,没有设置调度器时就用这个,经过测试效果基本等同于 Dispatchers.IO,在一个 CPU 密集型的线程池中执行协程,用于计算密集型任务,如复杂的算法或数据处理。

4、Dispatchers.Unconfined:无指定调度器,根据当前执行的环境而定,会在调用协程的线程上执行,直到第一次挂起。恢复时的线程可能与挂起时的线程不同。不常用,适合一些特定的用途,如需要精确控制线程的场景。另外有一点需要注意,由于是直接拿当前线程执行,经过实践,协程块中的代码执行过程中不会有延迟,会被立马执行,除非遇到需要协程被挂起了,才会去执行协程外的代码,这个也是跟其他类型的调度器不相同的地方

使用线程调度器可以控制协程在哪个线程上面执行,这主要归功于 Dispatchers(调度器),如果不指定 launch 语句的调度器,那么它肯定是要子线程中执行的,但是当指定了 Dispatchers.Main 之后,它就会变成在主线程中执行了,且不会阻塞主线程。

六、withContext操作符

  1. withContext是什么

withContext 是 Kotlin 协程库中的一个重要函数,用于在不同的协程上下文中切换执行代码块。它允许你在协程内切换到另一个调度器或更改协程的执行环境,而无需手动管理线程切换。withContext 是一个挂起函数,因此它不会阻塞当前线程。

withContext 只能在协程的操作符或者被 suspend 修饰的方法中才能调用!!

  1. withContext 的作用
    切换协程上下文 :withContext 的主要作用是在当前协程中切换上下文(通常是切换到另一个调度器),以便在不同的线程或线程池中执行指定的代码块。
    挂起并恢复 :在切换到新上下文时,withContext 会挂起当前协程,将其状态保存并暂停执行。代码块执行完毕后,协程会恢复到原来的上下文并继续执行。
    线程切换 :withContext 常用于在不同的线程或调度器之间切换执行环境。例如,你可以在主线程上启动协程,然后在 IO 线程池中执行网络请求,最后切换回主线程更新 UI。
  2. withContext 使用场景
    IO 操作 :在主线程上启动协程,但在 IO 线程池中执行耗时操作,如网络请求或文件读写。
    计算密集型任务 :在主线程上启动协程,但将复杂计算任务切换到默认调度器(Dispatchers.Default)中执行,以避免阻塞主线程。
    线程切换 :在一个线程上执行一部分逻辑,然后切换到另一个线程执行另一部分逻辑。
  3. withContext 使用示例
 println("测试开始 ${Thread.currentThread().name}")
 GlobalScope.launch(Dispatchers.Main) {
         withContext(Dispatchers.IO) {
     delay(2000)
     println("测试是否为主线程 ${Thread.currentThread().name}")
     }
     println("测试延迟开始 ${Thread.currentThread().name}")
     delay(5000)
     println("测试延迟结束")
    
 }

 /** * 结果:
 * 15:55:08.965  8617-8617             I  测试开始 main
 * 15:55:08.965  8617-8617             I  测试结束
 * 15:55:11.031  8617-8675             I  测试是否为主线程 DefaultDispatcher-worker-1
 * 15:55:11.033  8617-8617             I  测试延迟开始 main
 * 15:55:16.036  8617-8617             I  测试延迟结束
 */

从打印的日志来看:

withContext 将当前线程挂起,切换到其他线程,withContext 执行完毕后,协程恢复到主线程,继续执行后续代码。
只有当 withContext 里面的代码执行完了,才会恢复当前线程的执行。
代码执行顺序是按照代码的编顺序来的,这也是协程的魅力所在,尽管代码需要在不同线程上面执行,但是线程切换的效果十分优雅,代码从上向下执行。
其他用法:

 suspend fun getUserName(userId: Int): String = withContext(Dispatchers.IO) {
     delay(20000)
     return@withContext "Hello World"
    
 }
  1. withContext 的内部原理
    挂起与恢复 :当 withContext 被调用时,协程挂起并将当前的上下文保存。随后,withContext 切换到目标上下文(如另一个调度器或线程池)并执行代码块。代码块执行完毕后,协程恢复到原来的上下文。
    节约资源 :withContext 在切换上下文时,不会创建新线程,而是利用已有的线程池。这种机制避免了传统线程切换的开销,使得资源利用更加高效。
  2. withContext 和 launch 的区别
    launch :启动一个新的协程,并立即返回 Job 对象,它并不会等待协程体完成。
    withContext :挂起当前协程,并在指定上下文中执行代码块,等待代码块执行完成后继续执行。
  3. 总结
    withContext 是一种切换协程上下文的强大工具,使你能够灵活地在不同线程和调度器之间切换执行环境,避免线程阻塞并提高代码的响应性和性能。
    常见用途 :适用于需要在不同线程间执行不同任务的场景,特别是避免阻塞主线程的任务,如 IO 操作和计算密集型任务。

七、协程的启动模式
总共有四种:

  1. CoroutineStart.DEFAULT
    默认模式,会立即执行

  2. CoroutineStart.LAZY
    协程在调用 launch 或 async 时不会立即执行,而是处于未启动状态。需要显式调用 start()、join() 或等待 async 返回的 Deferred 对象的结果时,协程才会启动并执行。

  3. CoroutineStart.ATOMIC
    原子模式,跟 CoroutineStart.DEFAULT 类似,协程在启动后立即进入执行状态,且在进入执行状态前不会响应取消操作。也就是说,协程的启动和第一个挂起点之间的代码将始终被执行。需要注意的是,这是一个实验性的 api,后面可能会发生变更。

  4. CoroutineStart.UNDISPATCHED
    未指定模式,会立即执行协程,经过实践得出,会导致原先设置的线程调度器失效,一开始会在原来的线程上执行,类似于 Dispatchers.Unconfined,但是一旦协程被挂起,再恢复执行,会变成线程调度器的设置的线程上面去执行。使用时候要特别注意。

例子:

 val job = GlobalScope.launch(Dispatchers.Default, CoroutineStart.LAZY) {
     //do something
 }
    
 job.start()

八、关于Job

  1. job.start

通常情况下,你会在启动协程时隐式地创建并启动一个 Job,但有时你可能希望显式地控制 Job 的启动。

Job 是协程的基本构建块之一,表示一个可以被取消的异步任务。它有三种状态:New 、Active 和 Completed 。当你创建一个协程时,它会自动启动并进入 Active 状态。

然而,如果你创建了一个 Job 并不希望立即启动它,你可以在创建时使用 start = CoroutineStart.LAZY 参数。这会使 Job 进入 New 状态,而不会自动启动。此时,你可以通过调用 job.start() 来显式启动它。

  1. job.cancel

job.cancel() 的作用是请求取消与该 Job 对象关联的协程,但是不会立马生效 。

具体来说,当你调用 job.cancel() 时,会向协程发送一个取消信号,请求它停止执行。然而,这并不意味着协程会立即终止,因为协程的取消是协作性的。换句话说,协程必须在合适的时机检查取消状态并进行响应。通常协程会通过挂起函数(如 delay 或 yield)来检测是否被取消,并在取消时抛出一个 CancellationException。

  1. job.join
    会挂起调用该函数的协程,直到目标协程执行完毕。这样可以确保当前协程不会继续执行后续的代码,直到与Job关联的协程已经结束。join函数是非阻塞的,即使它会挂起当前协程,它不会阻塞底层线程。因此,它在并发编程中非常有用,可以用来控制协程的执行顺序,确保某些任务在其他任务之前完成。
 val job = launch {
     // 执行一些任务
 }
 job.join()  // 等待job完成
 println("Job已完成")
 //例如:在这个例子中,println("Job已完成")只有在job协程完成后才会执行。
  1. job.cancelAndJoin
    是一个复合操作,首先取消关联的协程,然后等待它完成。这一步通常用于确保协程的取消过程顺利完成,并且当前协程不会继续执行,直到目标协程完全停止。即:等待协程执行完毕然后再取消,需要在协程中使用[Suspend function 'cancelAndJoin' should be called only from a coroutine or another suspend function]
 val job = launch {
     // 执行一些任务
     delay(1000)
     println("任务完成")
 }
 // 在某些条件下取消协程
 job.cancelAndJoin()  // 取消并等待结束
 println("协程已取消并结束")
 //在这个例子中,job.cancelAndJoin() 会取消job协程,并等待它的取消过程完成,之后才会执
 //行println("协程已取消并结束")。

九、关于协程的作用域
在 Kotlin 协程中,作用域(CoroutineScope) 决定了协程的生命周期、上下文以及它们的父子关系。协程作用域用于管理协程的生命周期,并确保协程能够在适当的时机被取消或完成。

GlobalScope :全局协程,生命周期与应用程序相同,不建议频繁使用。
CoroutineScope :手动创建作用域,适合灵活控制协程生命周期。
MainScope :适合 Android UI 操作的协程作用域,默认运行在主线程。
ViewModelScope :与 ViewModel 生命周期绑定,适合与 ViewModel 相关的异步操作。
LifecycleScope :与 Lifecycle 绑定,自动取消与生命周期相关的协程。
SupervisorScope :子协程独立运行,适用于多个协程同时运行的场景。

相关文章

网友评论

      本文标题:Kotlin协程详解(学习协程这一篇就够了)

      本文链接:https://www.haomeiwen.com/subject/zeywdjtx.html