kotlin协程:一文搞懂各种概念
  mkIZDEUN7jdf 2023年11月19日 25 0

前言

使用 kotlin 协程已经几年了,可以说它极大地简化了多线程问题的复杂度,非常值得学习和掌握。此文介绍并梳理协程的相关概念:suspend、non-blocking、Scope、Job、CoroutineContext、Dispatchers 和结构化并发。

进入协程世界

简而言之,协程是可以在其内部进行挂起操作的实例,是否支持挂起函数也是协程世界和非协程世界的最大区别。初学者可以把协程看作是“轻量级线程”以做对比,但实际上他依然是跑在线程上的,所以也可以将它看作是一个强大的异步框架。

要使用协程,需要添加 kotlinx-coroutines-core 库的依赖。

挂起函数与非阻塞

先看一段代码:

import kotlinx.coroutines.*

fun main() = runBlocking { // this: CoroutineScope
    println("Hello World!")
}

runBlocking 是一个协程构造器,他连接了非协程和协程世界,{ } 里便是协程世界。这两个世界的差异在于是否可支持挂起操作:

import kotlinx.coroutines.*

fun main() = runBlocking { // this: CoroutineScope
     delay(1000L) // non-blocking delay for 1 second (default time unit is ms)
    println("Hello World!")
}

"Hello World!" 将会在进入协程 1s 后打印,delay 便是一个挂起函数,类似线程中 sleep 的作用。区别是挂起函数 delay 并不会阻塞当前线程。这里有两个概念,挂起和非阻塞,挂起的是协程,非阻塞的是线程。

从线程的角度看,假如进入协程时运行在线程 A,那么执行 delay 函数时运行在线程 B,执行完 delay 之后又在线程 C 上执行 println,线程 C 可能就是线程 A,也可能不是,整个过程可以简化为:在某个线程执行协程,在遇到挂起函数 delay 时切换到另一个线程,执行完 delay 后又切回某个线程继续执行协程。不阻塞线程即不阻塞挂起前协程所在的线程,即 A 线程,A 线程可以继续执行其他任务。那这有什么意义呢?试想协程一开始运行在 Android 中的 ui 线程,在挂起函数里执行耗时的网络请求,网络请求结束自动回到协程,继续在 ui 线程上的执行。一方面耗时任务未阻塞 ui 线程,另一方面完全消除了异步回调,这使异步任务变得极为简单:以同步的方式书写异步的代码。

从协程的角度看,协程遇到挂起函数时会被挂起,即暂停了,等待挂起函数执行完成,相比于线程阻塞,协程挂起几乎没有任何资源消耗,其本质上是回调。

另外,挂起函数都有 suspend 关键字修饰,编译器也会在此施加魔法:

public suspend fun delay(timeMillis: Long) { ... }

runBlocking 会阻塞当前线程直到协程执行完毕,因此适合单元测试,下面会介绍更合适的进入协程世界的方式。

注:例子中的线程 A、B、C 是通过协程中的 Dispatcher 控制的,后面聊到。

CoroutineScope

协程都是由 CoroutineScope 创建的,协程在创建时,都会关联到一个新的的 CoroutineScope

CoroutineScope 即协程作用域,它限制和控制协程的作用范围或者说生命周期。不仅当前协程会受其影响,所有在协程作用域内创建的子协程也会有关联。当调用 CoroutineScope 的 cancel 方法时,会取消当前协程以及其关联的所有下层协程。

自定义 CoroutineScope

进入协程世界除了上面使用的 runBlocking 方式。还可以自定义 CoroutineScope

fun main() {
    CoroutineScope(Dispatchers.IO).launch {
            ...
    }
}

上面代码创建了一个运行在 IO 线程环境的协程作用域并创建了一个协程,该协程运行在 IO 线程。

GlobalScope

此外,还可以使用 GlobalScope 这个全局的协程作用域进入协程世界:

fun main() {
    GlobalScope.launch(Dispatchers.IO) {
            ...
    }
}

GlobalScope 虽拿来即用,但它是全局的,生命周期太长,使用不当会导致内存泄露风险。

Android 中的 Scope

android 中,推荐使用 LifecycleOwner.lifecycleScope,他和 LifecycleOwner 的生命周期绑定,不会出现内存泄露的问题。

如果使用了 ViewModel,还可以使用 ViewModel.viewModelScope,同样和 ViewModel 生命周期绑定。

它们都在 UI 线程执行。

还有一个 MainScope 也在 UI 线程,有了上面两个,这个基本用不到了,因为他没绑定有生命周期的对象,需要手动 cancel。

Job

通过 CoroutineScope 创建的协程即 Job,可以认为 就是协程的实例。一个 Job 可以有多个子 ,也即一个协程可以有多个子协程。具有父子关系的协程,父协程取消时,所有子协程都会取消, 的 cancel 本质就是通过取消 实现的,因此 的 cancel 等价于 的 cancel。

val job = launch { // 1
    launch { // 2
        ...
    }
    
    launch { // 3
        ...
    }
}
job.cancel()

上面代码中 job 取消时会把 2、3处协程也取消。

协程层次化的好处是便于管理,再多的协程,只要它们具有相同的父协程,就可以方便地控制其生命周期。在层次化的协程结构中,取消事件自上而下,异常事件自下而上,这背后是结构化并发的思想。

特别的,SupervisorJob 是一种特殊的 Job,唯一的区别在于异常传播到 SupervisorJob 层会停止向上传播,将异常交由 SupervisorJob 处理,借助这一特点我们可以把异常传播控制在一定范围内。异常传播与处理的详细介绍之后会单独写~~

CoroutineContext

CoroutineContext ,协程上下文,是 CoroutineScope 的唯一成员,是用于存放协程执行环境的地方,如调度器(Dispatcher)、异常处理器(CoroutineExceptionHandler)、Job 等。CoroutineContext 的主要目的是提供一个统一的方式来管理协程的执行环境和属性。

CoroutineScope 在创建协程时会把 CoroutineContext 传递下去,新创建的协程会继承父协程或Scope 的 CoroutineContext

CoroutineContext 数据的使用类似 Map,根据 Key 取值,如果子协程创建时指定了 CoroutineContext ,则会合并,相同 Key 的值会被覆盖。

fun main() {
    CoroutineScope(Dispatchers.Main).launch(CoroutineName("My Coroutine")) {
        println("My Coroutine name: ${coroutineContext[CoroutineName]}")
    }
}

上面代码创建了一个在主线程的协程作用域,并创建了一个协程,该协程指定了协程元素-CoroutineName,这将和 CoroutineScope 中的 Dispatchers.Main 合并成新的 CoroutineContext

Dispatchers 与线程

Dispatchers 可以指定协程的执行的线程环境,不过它强调的是线程的类别而不是哪一个具体的线程。如:Dispatchers.IO 表示 IO 密集型线程池,Dispatchers.Default 表示 cpu 密集型线程池,特别的是,Dispatchers.Main 特指 Android 中的主线程。在协程中可以使用 withContext 进行线程池的切换:

fun main() {
    CoroutineScope(Dispatchers.Main).launch {
        withContext(Dispatchers.IO) {
            ...
        }
    }
}

如果是网络数据传输等 io 任务,一定要使用 Dispatchers.IO,其余计算类耗时任务使用 Dispatchers.Default,这是因为 Dispatchers.Default 线程池的线程数较少(和 cpu 核心数有关),而 Dispatchers.IO 线程池的线程数更多且可动态调整。io 任务往往等待时间更长,使用 Dispatchers.Default 的话很容易占满所有线程资源。

协程世界

如果已经在协程世界中了,那么创建新的协程的方式就比较多了,launch、async、coroutineScope、supervisorScope 等都可以方便地创建不同需求的协程。

launch、async

两者都是协程构建器,最大的区别是 async 有返回值而 launch 没有。有人说并行就用 async,其实它们都能并行,只不过业务场景里往往都需要拿到返回值。

完整测试用例:

fun main() = runBlocking {
    CoroutineScope(Dispatchers.IO).launch(CoroutineName("My Coroutine")) {
        println("My Coroutine name: ${coroutineContext[CoroutineName]}")

        coroutineScope {
            launch {
                println("task 1")
            }
            launch {
                println("task 2")
            }
        }

        coroutineScope {
            val task1 = async {
                delay(1000)
                1
            }
            val task2 = async {
                delay(1000)
                2
            }
            println(task1.await() + task2.await())
        }
    }.join()
}

coroutineScope、supervisorScope

coroutineScopesupervisorScope 也都协程构建器,只不过它们会等待协程执行结束才结束,有点像 runBlocking,但不同的是 runBlocking 阻塞当前线程,而它们只是挂起协程,一个是普通方法,另外两个则是挂起函数。

它们的差别类似 JobSupervisorJob 的差别,supervisorScope 中顶级子协程的发生异常不会影响其他顶级子协程。

supervisorScope {
    launch {
        throw Exception("error")
        delay(1000)
        println("task 1")
    }
    launch {
        delay(2000)
        println("task 2")
    }
}

上面代码 task 2 可以被正常打印出来,即使兄弟协程发生了异常。

结构化并发

它是一种编程范式,旨在通过结构化的方式使并发编程 更清晰明确、更高质量、更易维护。

其核心有几点:

  • 通过把多线程任务进行结构化的包装,使其具有明确的开始和结束点,并确保其孵化出的所有任务在退出前全部完成。
  • 这种包装允许结构中线程发生的异常能够传播至结构顶端的作用域,并且能够被该语言原生异常机制捕获。

kotlin 协程设计中的协程关系、执行顺序、异常传播/处理 都符合结构化并发。结构化并发明确了并发任务什么时候开始,什么时候结束,异常如何传播,通过控制顶层结构具柄就可实现整个并发结构的取消、异常处理,使复杂的并发问题简单、清晰、可控。可以说,结构化并发大大降低了并发编程的难度。

【版权声明】本文内容来自摩杜云社区用户原创、第三方投稿、转载,内容版权归原作者所有。本网站的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@moduyun.com

  1. 分享:
最后一次编辑于 2023年11月19日 0

暂无评论

推荐阅读
mkIZDEUN7jdf