掌握协程的边界与环境:CoroutineScope 与 CoroutineContext

CoroutineScope 与 CoroutineContext 的概念

CoroutineContext (协程上下文)

CoroutineContext 是协程上下文,包含了协程运行时所需的所有信息。

比如:

  • 管理协程流程(生命周期)的 Job
  • 管理线程的 ContinuationInterceptor,它的实现类 CoroutineDispatcher 决定了协程所运行的线程或线程池。

CoroutineScope (协程作用域)

CoroutineScope 是协程作用域,它通过 coroutineContext 属性持有了当前协程代码块的上下文信息。

比如,我们可以获取 JobContinuationInterceptor 对象:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(EmptyCoroutineContext) // scope 并没有持有已有协程的上下文
    val outerJob = scope.launch {
        val innerJob = coroutineContext[Job]
        val interceptor = coroutineContext[ContinuationInterceptor]
        println("job: $innerJob, interceptor: $interceptor")
    }

    outerJob.join()
}

CoroutineScope 的另一个作用就是提供了 launchasync 协程构建器,我们可以通过它来启动一个协程。

这样,新创建的协程能够自动继承 CoroutineScopecoroutineContext。比如利用 Job,可以建立起父子关系,从而实现结构化并发。

GlobalScope

GlobalScope 是一个单例的 CoroutineScope 对象,所以我们在任何地方通过它来启动协程。

它的第二个特点是,它的 coroutineContext 属性是 EmptyCoroutineContext,也就是说它没有内置的 Job

kotlin 复制代码
@DelicateCoroutinesApi
public object GlobalScope : CoroutineScope {
    /**
     * Returns [EmptyCoroutineContext].
     */
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
}

即使是我们手动创建的 CoroutineScope,其内部也是有 Job 的。

kotlin 复制代码
// 手动创建 CoroutineScope
CoroutineScope(EmptyCoroutineContext)

// CoroutineScope.kt
@Suppress("FunctionName")
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
    ContextScope(if (context[Job] != null) context else context + Job()) // 自动创建Job对象

所以我们在 GlobalScope.coroutineContext 中是获取不到 Job 的:

kotlin 复制代码
@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking<Unit> {
    val job: Job? = GlobalScope.coroutineContext[Job]
    if (job == null) {
        println("job is null")
    }
    try {
        val jobNotNull: Job = GlobalScope.coroutineContext.job
    } catch (e: IllegalStateException) {
        println("job is null, exception is: $e")
    }
}

运行结果:

less 复制代码
job is null
job is null, exception is: java.lang.IllegalStateException: Current context doesn't contain Job in it: EmptyCoroutineContext

那么,这有什么用吗?

其实,GlobalScope 所启动的协程没有父 Job

这就意味着:

  • 当前协程不和其他 Job 的生命周期绑定,比如不会随着某个界面的关闭而自动取消。
  • 它是顶级协程,生命周期默认为整个应用的生命周期。
  • 它发生异常,并不会影响到其他协程和 GlobalScope。反之,GlobalScope 本身也无法级联取消所有任务,因为它所启动的协程是完全独立的。

总结:GlobalScope 就是用来启动那些不与组件生命周期绑定,而是与整个应用生命周期保持一致的全局任务,比如一个日志上报任务。

关键在使用时,可能会有资源泄露的风险,需要正确管理好协程的生命周期。

Context 的三个实用工具

在挂起函数中获取 CoroutineContext

如果我们要在一个挂起函数中获取 CoroutineContext,我们不得不给将其作为 CoroutineScope 的扩展函数。

kotlin 复制代码
suspend fun CoroutineScope.printContinuationInterceptor() {
    delay(1000)
    val interceptor = coroutineContext[ContinuationInterceptor]
    println("interceptor: $interceptor")
}

但我们知道挂起函数的外部一定有协程存在,所以是存在 CoroutineContext 的。为此,Kotlin 协程库提供了一个顶层的 coroutineContext 属性,这个属性的 get() 函数是一个挂起函数,它能在任何挂起函数中访问到当前正在执行的协程的 CoroutineContext

kotlin 复制代码
import kotlin.coroutines.coroutineContext

suspend fun printContinuationInterceptor() {
    delay(1000)
    val interceptor = coroutineContext[ContinuationInterceptor]
    println("interceptor: $interceptor")
}

另外,还有一个 currentCoroutineContext() 函数也能获取到 CoroutineContext,它内部实现也是 coroutineContext 属性。

为什么需要这个函数?

为了解决命名冲突,比如下面这段代码。

kotlin 复制代码
private fun mySuspendFun() {
    flow<String> {
        // 顶层属性
        coroutineContext 
    }

    GlobalScope.launch {
        flow<String> {
            // this 的成员属性优先级高于顶层属性
            // 所以是外层 launch 的 CoroutineScope 的成员属性 coroutineContext
            coroutineContext
        }
    }
}

在这种情况下,如果需要明确属性的源头,就需要使用 currentCoroutineContext() 函数,它会调用到那个顶层的属性。

CoroutineName 协程命名

CoroutineName 是一个协程上下文信息,我们可以使用它来给协程设置一个名称。

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(EmptyCoroutineContext)
    val name = CoroutineName("coroutine-1")
    val job = scope.launch(name) {
        val coroutineName = coroutineContext[CoroutineName]
        println("current coroutine name: $coroutineName")
    }

    job.join()
}

运行结果:

less 复制代码
current coroutine name: CoroutineName(coroutine-1)

它主要用于测试和调试,你可以使用它来区分哪些日志是哪个协程打印的。

自定义 CoroutineContext

如果我们要给协程附加一些功能,我们可以考虑自定义 CoroutineContext

如果是简单的标记,可以优先考虑使用 CoroutineName

自定义 CoroutineContext 需要实现 CoroutineContext.Element,并且提供 Key。为此,Kotlin 协程库提供了 AbstractCoroutineContextElement 来简化这个过程。我们只需这样,即可创建一个用于协程内部记录日志的 Context

kotlin 复制代码
// 继承 AbstractCoroutineContextElement,并把 Key 传给父构造函数
class CoroutineLogger(val tag: String) : AbstractCoroutineContextElement(CoroutineLogger) {

    // 声明专属的 Key
    companion object Key : CoroutineContext.Key<CoroutineLogger>

    // 添加专属功能
    fun log(message: String) {
        println("[$tag] $message")
    }
}

使用示例:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(Dispatchers.Default)
    val job = scope.launch(CoroutineLogger("Test")) {
        val logger = coroutineContext[CoroutineLogger]
        logger?.log("Start")
        delay(5000)
        logger?.log("End")
    }
    job.join()
}

运行结果:

less 复制代码
[Test] Start
[Test] End

coroutineScope() 与 withContext()

coroutineScope 串行的异常封装器

coroutineScope 是一个挂起函数,它会挂起当前协程,直到执行完内部的所有代码(包括会等待内部启动的所有子协程执行完毕),最后一行代码的执行结果会作为函数的返回值。

coroutineScope 会创建一个新的 CoroutineScope,在这个作用域中执行 block 代码块。并且这个作用域严格继承了父上下文(coroutineContext),并会在内部创建一个新的 Job,作为父 Job 的子 Job

coroutineScope 从效果上来看,和 launch().join() 类似。

那么,它的应用场景是什么?

它的应用场景由它的特性决定,有两个核心场景:

  1. 在挂起函数中提供 CoroutineScope。(最常用)

    kotlin 复制代码
    suspend fun CoroutineScope.mySuspendFunction() {
        delay(1000)
        launch {
            println("launch")
        }
    }

    如果你要在挂起函数中启动一个新的协程,你只好将其定义为 CoroutineScope 的扩展函数。不过,你也可以使用 coroutineScope 来提供作用域。

    它能提供作用域,是因为挂起函数的外部一定存在着协程,所以一定具有 CoroutineScope

    kotlin 复制代码
    suspend fun doConcurrentWork() {
        val startTime = System.currentTimeMillis()
        coroutineScope {
            val task1 = async { // 任务1
                delay(5000)
            }
            val task2 = async { // 任务2
                delay(3000)
            }
        } // // 挂起,直到上面两个 async 都完成
        val endTime = System.currentTimeMillis()
        println("Total execution time: ${endTime - startTime}") // 5000 左右
    }
  2. 业务逻辑封装并进行异常处理。(最重要)

    我们都知道,我们无法在协程外部使用 try-catch 捕获协程内部的异常。

    但使用 coroutineScope 函数可以,当它内部的任何子协程失败了,它会将这个异常重新抛出来,这时我们可以使用 try-catch 来捕获。

    kotlin 复制代码
    fun main() = runBlocking<Unit> {
        try {
            coroutineScope {
                val data1 = async {
                    "user-1"
                }
                val data2 = async {
                    throw IllegalStateException("error")
                }
    
                awaitAll(data1, data2)
            }
        } catch (e: Exception) {
            println("exception is: $e")
        }
    }

    运行结果:

    less 复制代码
    exception is: java.lang.IllegalStateException: error

    原因也很简单,因为它是一个串行的挂起函数,外部协程会被挂起,直到它执行完毕。如果它的内部出现了异常,外部协程是能够知晓的。

    coroutineScope 可以将并发的崩溃变为可被捕获、处理的异常,常用于处理并发错误。

withContext 串行的上下文切换器

我们再来看 withContext,其实它和 coroutineScope 几乎一样。

它也是一个串行的挂起函数,也会返回代码块的结果,内部也是启动了一个新的协程。

它和 coroutineScope 的唯一的不同是,withContext 允许我们传递上下文。你也可以这么想,coroutineScope 就是一个不改变任何上下文的 withContext

kotlin 复制代码
withContext(EmptyCoroutineContext) { // 沿用旧的 CoroutineContext

}

withContext(coroutineContext) { // 使用旧的 CoroutineContext

}

withContext 的使用场景就很清楚了,我们需要切换上下文的时候会使用它,并且希望代码是串行执行的,之后还能再切回原来的线程继续往下执行。

虽然 withContextcoroutineScope 类似,但 coroutineScope 更多用于封装业务异常。

kotlin 复制代码
suspend fun getUserProfile() {
    // 当前在 Dispatchers.Main
    val profile = withContext(Dispatchers.IO) {
        // 自动切换到 IO 线程
        Thread.sleep(3000) // 耗时操作
        "the user profile"
    }

    // 自动切回 Dispatchers.Main
    println("the user profile is $profile")
}

CoroutineContext 的加、取操作

加法:合并与替换

两个 CoroutineContext 相加调用的是 plus()

kotlin 复制代码
public operator fun plus(context: CoroutineContext): CoroutineContext =
    if (context === EmptyCoroutineContext) this else // fast path -- avoid lambda creation
        context.fold(this) { acc, element ->
            val removed = acc.minusKey(element.key)
            if (removed === EmptyCoroutineContext) element else {
                // make sure interceptor is always last in the context (and thus is fast to get when present)
                val interceptor = removed[ContinuationInterceptor]
                if (interceptor == null) CombinedContext(removed, element) else {
                    val left = removed.minusKey(ContinuationInterceptor)
                    if (left === EmptyCoroutineContext) CombinedContext(element, interceptor) else
                        CombinedContext(CombinedContext(left, element), interceptor)
                }
            }
        }

其中关键在于 CombinedContext,它是 CoroutineContext 的实现类:

kotlin 复制代码
// CoroutineContextImpl.kt
@SinceKotlin("1.3")
internal class CombinedContext(
    private val left: CoroutineContext,
    private val element: Element // Element 也是 `CoroutineContext` 的实现类
) : CoroutineContext, Serializable 

它会将操作符两边的上下文使用 CombinedContext 对象包裹(合并),如果两个上下文具有相同的 Key,加号右侧的会替换左侧的。

比如 Dispatchers.IO + Job() + CoroutineName("my-name") 一共会进行三次合并,得到三个 CombinedContext 对象,不会进行替换。

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val handler = CoroutineExceptionHandler { _, throwable ->
        println("Caught $throwable")
    }
    val job =
        launch(Dispatchers.IO + Job() + CoroutineName("my-name") + handler) { 
            println(coroutineContext)
        }
    job.join()
}

运行结果:

less 复制代码
[CoroutineName(my-name), com.example.coroutinetest.TestKtKt$main$1$invokeSuspend$$inlined$CoroutineExceptionHandler$1@6667fe3b, StandaloneCoroutine{Active}@26b2b4fd, Dispatchers.IO]

如果在末尾再加上一个 CoroutineName("your_name"),会进行一次替换,运行结果是:[com.example.coroutinetest.TestKtKt$main$1$invokeSuspend$$inlined$CoroutineExceptionHandler$1@6667fe3b, CoroutineName(your_name), StandaloneCoroutine{Active}@26b2b4fd, Dispatchers.IO]

[] 取值

[] 取值其实调用的是 CoroutineContext.get() 函数,它会从上下文(CombinedContext 树)中找到我们需要的信息。

kotlin 复制代码
@SinceKotlin("1.3")
public interface CoroutineContext {
    /**
     * Returns the element with the given [key] from this context or `null`.
     */
    public operator fun <E : Element> get(key: Key<E>): E?
        
    // ...
}

我们填入的参数其实是每一个接口的伴生对象 Key,每个伴生对象都实现了 CoroutineContext.Key<T> 接口,并将泛型指定为了当前接口。

ContinuationInterceptor 为例:

kotlin 复制代码
@SinceKotlin("1.3")
public interface ContinuationInterceptor : CoroutineContext.Element {
    /**
     * The key that defines *the* context interceptor.
     */
    public companion object Key : CoroutineContext.Key<ContinuationInterceptor>
    
    // ...
}

比如我们要获取上下文中的 CoroutineDispatcher,我们可以这样做:

kotlin 复制代码
fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(Dispatchers.Default)
    val job = scope.launch {
        val dispatcher = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher // 强转
        println("CoroutineDispatcher is $dispatcher")
    }
    job.join()
}
相关推荐
robotx1 小时前
安卓线程相关
android
消失的旧时光-19432 小时前
Android 面试高频:JSON 文件、大数据存储与断电安全(从原理到工程实践)
android·面试·json
dalancon3 小时前
VSYNC 信号流程分析 (Android 14)
android
dalancon3 小时前
VSYNC 信号完整流程2
android
dalancon3 小时前
SurfaceFlinger 上帧后 releaseBuffer 完整流程分析
android
用户69371750013844 小时前
不卷AI速度,我卷自己的从容——北京程序员手记
android·前端·人工智能
程序员Android5 小时前
Android 刷新一帧流程trace拆解
android
墨狂之逸才5 小时前
解决 Android/Gradle 编译报错:Comparison method violates its general contract!
android
阿明的小蝴蝶6 小时前
记一次Gradle环境的编译问题与解决
android·前端·gradle
汪海游龙6 小时前
开源项目 Trending AI 招募 Google Play 内测人员(12 名)
android·github