CoroutineContext 与 CoroutineScope 的定位

481 阅读13分钟

CoroutineContext

一、CoroutineContext(协程上下文)

  • 概念:承载协程运行所需的所有元信息,底层是一个键值对集合(Element)。

  • 常见元素

    • Job:管理协程的生命周期与父子关系;
    • ContinuationInterceptor(通常是各种 Dispatcher):决定协程恢复在哪个线程或线程池。
  • 作用:在协程挂起/恢复时,携带和查找执行所需的信息。


二、CoroutineScope

1. 存储并提供上下文

  • 每个 CoroutineScope 都持有一个 coroutineContext: CoroutineContext,并将其用于新协程的启动。

  • coroutineContext 中通常包含:

    • 一个 Job(或 SupervisorJob),用以组织该 Scope 下所有协程的父子关系;
    • 至少一个 ContinuationInterceptor,决定默认的调度器;
    • 其他可选元素,如 CoroutineNameCoroutineExceptionHandler 等。
val scope = CoroutineScope(Dispatchers.IO)
// 等同于:
// 如果传入的 context 中没有 Job,就自动加一个新的 Job
// public fun CoroutineScope(context: CoroutineContext) =
//     ContextScope(if (context[Job] != null) context else context + Job())

2. 启动协程

  • launch { … }async { … }actor { … } 等都是 CoroutineScope 的扩展函数。
  • 启动新协程时,这些函数会“取用”当前 Scope 的 coroutineContext,并在其基础上创建子 Job 与(可选)调度器(取决于是否自己写了新的不同的调度器)覆盖。
scope.launch {
  // this.coroutineContext[Job]  → 子 Job
  // coroutineContext[ContinuationInterceptor]  → Dispatcher
}

三、两者的关系与职责

方面CoroutineContextCoroutineScope
核心含义一组协程运行时元素(Job、Dispatcher…)持有并管理一个 CoroutineContext
主要职责保存协程执行所需的信息提供上下文;启动协程;取消所属协程群
生命周期管理Job 元素负责通过内部的根 Job 级联管理所有子协程
启动协程时的作用决定父子关系与线程分发launch/async 提供上下文来源

四、总结

  • CoroutineContext:是协程运行时的“配置”,包含了线程调度、异常处理、父子关系等元素。
  • CoroutineScope:是“持有”这份配置的容器,并以此来启动、管理和取消一组协程。

这种分离设计,使得协程能够在保持层次结构和生命周期可控的同时,灵活指定执行线程与异常策略。

GlobalScope

一、什么是 GlobalScope

  • GlobalScope 是一个 单例对象,它本身实现了 CoroutineScope

    public object GlobalScope : CoroutineScope {
        override val coroutineContext: CoroutineContext
            get() = EmptyCoroutineContext
    }
    
  • 与普通 CoroutineScope(如 CoroutineScope(Dispatchers.IO))不同,它的 coroutineContext 为空,即没有内置的 Job


二、GlobalScope.launch/async 的上下文构建

  1. 调用 GlobalScope.launch {…} 时,实际用到的上下文是:

    // 伪代码,简化自源码
    fun CoroutineScope.launch(
      context: CoroutineContext = EmptyCoroutineContext, 
      block: suspend ()->Unit
    ): Job {
      // 合并三个部分:Scope 的 context + 默认 Dispatcher + 新 Job
      val newContext = this.coroutineContext + Dispatchers.Default + Job()
      // ……启动协程……
    }
    
  2. 因为 GlobalScope.coroutineContextEmptyCoroutineContext,所以合并后:

    • 没有父 Job,新协程拿到的 Job() 就是“根”
    • 使用了默认的调度器(Dispatchers.Default
  3. 结果

    val job = GlobalScope.launch { /*…*/ }
    println(job.parent)           // null
    println(job is Job)           // true
    println(GlobalScope.coroutineContext[Job]) // null
    

    启动的协程既没有父协程,也不会被 GlobalScope 的“Job”所管理。

三、与普通 CoroutineScope 的区别

特性GlobalScopeCoroutineScope(…)
coroutineContextEmptyCoroutineContext传入的 CoroutineContext(含 Job
启动协程时的父 Job(新协程独立)会创建并附加到该 Scope 的根 Job
Scope 取消时对协程的影响无,从不取消已启动的协程取消 Scope 时会级联取消所有子协程
典型用途“真正全局”、与任何生命周期无关的顶层任务

为什么要这样设计?

  • 解耦父子关系GlobalScope 启动的协程独立存在,不会因任何上层作用域取消而被取消。
  • 适合“常驻”任务:例如后台日志、全局单例服务,或者应用级别的定时器,不应被局部生命周期所影响。
// 1. GlobalScope 本身没有 Job
println(GlobalScope.coroutineContext[Job]) // null

// 2. 但 launch/async 会给协程创建一个独立的 Job
val job = GlobalScope.launch {
    // 这里的 coroutineContext[Job] 是调用 launch 时新创建的 Job
    println(coroutineContext[Job] != null) // true
}
println(job.parent) // null

// 3. 普通 Scope 会自动添加根 Job
val scope = CoroutineScope(Dispatchers.IO)
val job2 = scope.launch {
    println(coroutineContext[Job] != null) // true
}
println(job2.parent) // 不为 null,属于 scope 的根 Job

val job0 = GlobalScope.launch {
    coroutineContext[Job] // 该值不为空,因为用到的是CoroutineScope,而不是GlobalScope
}

val job = GlobalScope.async {
    delay(1000)
    coroutineContext[Job] // 该值不为空,因为用到的是CoroutineScope,而不是GlobalScope
}

GlobalScope.coroutineContext.job  // 该值为空
GlobalScope.coroutineContext[Job] // 该值为空

// 自己创建的CoroutineScope也会有Job对象,源码自动添加的。
CoroutineScope(EmptyCoroutineContext).launch {
    coroutineContext[Job]
}

// 通过GlobalScope的launch函数启动的协程的CoroutineScope是没有Job的,源码里边直接返回了空的context.
override val coroutineContext: CoroutineContext
    get() = EmptyCoroutineContext

/**
 * An empty coroutine context.
 */
@SinceKotlin("1.3")
public object EmptyCoroutineContext : CoroutineContext, Serializable {
    private const val serialVersionUID: Long = 0
    private fun readResolve(): Any = EmptyCoroutineContext

    public override fun <E : Element> get(key: Key<E>): E? = null
    public override fun <R> fold(initial: R, operation: (R, Element) -> R): R = initial
    public override fun plus(context: CoroutineContext): CoroutineContext = context
    public override fun minusKey(key: Key<*>): CoroutineContext = this
    public override fun hashCode(): Int = 0
    public override fun toString(): String = "EmptyCoroutineContext"
}

这样会导致 GlobalScope 有什么特点呢?一个CoroutineScope没有Job,那么它创建的协程就没有父协程了。它创建的协程的Job就没有父Job了。例如下边这个Job的parent就是空的:

val job = GlobalScope.async {
  delay(1000)
}

GlobalScope 的这个特点有什么用处呢?他需要这个效果才能正常工作。如果我们需要某个协程一直存在,可以用下边这种方式:

CoroutineScope(EmptyCoroutineContext).launch {

}

使用GlobalScope需要注意

  • 避免滥用GlobalScope 启动的协程不会自动取消,容易造成内存泄漏或过期任务未停止。
  • 推荐做法:绝大多数情况下应使用与生命周期或业务逻辑绑定的 CoroutineScope(如 viewModelScopelifecycleScope、手动创建并持有的 CoroutineScope)。
  • 仅在真正需要“脱离任何生命周期”的全局任务时,再考虑使用 GlobalScope

从挂起函数里获取CoroutineContext

直接引入以下包,就可以在挂起函数中访问 coroutineContext

import kotlin.coroutines.coroutineContext
fun main() = runBlocking<Unit> {
    val scope = CoroutineScope(EmptyCoroutineContext)
    scope.launch(Dispatchers.Main) {
        showDispatcher()
    }
    delay(10000)
}

private suspend fun showDispatcher() {
    delay(1000)
    println("Dispatcher: ${coroutineContext[ContinuationInterceptor]}")
}

/**
 * 返回当前协程的上下文。
 */
@SinceKotlin("1.3")
@Suppress("WRONG_MODIFIER_TARGET")
@InlineOnly
public suspend inline val coroutineContext: CoroutineContext
    get() {
        throw NotImplementedError("Implemented as intrinsic")
    }

为了在挂起函数里拿到当前协程作用域CoroutineScope)所提供的上下文,Kotlin 还提供了一个函数 currentCoroutineContext()
在某些场景下,直接使用顶层属性 coroutineContext 会与作用域里的同名属性冲突。例如:

GlobalScope.launch {
    flow<String> {
        // 这里的 coroutineContext,会解析为 launch 提供的上下文
        coroutineContext 
        // 如果想取 flow 本身的大括号里挂起函数的上下文,就要用:
        currentCoroutineContext()
    }
}

——原因在于,成员属性的解析优先级高于顶层属性,所以在双重环境(launch 作用域 + flow 挂起函数)中,coroutineContext 总是指向 launch 的上下文。


下面用一个完整示例演示在 flow { … } 中,coroutineContextcurrentCoroutineContext() 在不同调度器下的差异:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking {
    GlobalScope.launch {
        flow<String> {
            println("Before flowOn:")
            println("  coroutineContext            = $coroutineContext")
            println("  currentCoroutineContext()   = ${currentCoroutineContext()}")
            emit("Hello")
        }
        .flowOn(Dispatchers.IO)  // 将发射数据的上下文切换到 IO 调度器
        .collect { value ->
            println("In collect:")
            println("  value                       = $value")
            println("  coroutineContext            = $coroutineContext")
            println("  currentCoroutineContext()   = ${currentCoroutineContext()}")
        }
    }
    // 等待足够时间让示例跑完
    delay(3000)
}
  • flowOn(Dispatchers.IO) 之前,coroutineContextcurrentCoroutineContext() 都是运行在 runBlocking 的上下文(默认是主线程)。
  • flowOn(…) 会把上游的发射操作移动到指定调度器(这里是 IO),此时在 flow { … } 里打印的两者仍然一致(都等于 IO 调度器)。
  • collect { … } 阶段,两者又都回到原始的 launchrunBlocking 上下文(例如主线程)。

因此,简单场景下两者行为相同;但当你需要区分挂起函数内部外部作用域的上下文时,就应使用 currentCoroutineContext()

coroutineScope()与SupervisorScope()

coroutineScope vs. launch 的区别

coroutineScope()launch 在表面上都能“启动”子协程,但它们有两点本质区别:

  1. 上下文可定制性

    • launch 可以接收一个 CoroutineContext(比如不同的调度器或自定义的 Job);
    • coroutineScope() 无任何参数,始终沿用外部上下文,相当于无参的 launch
  2. 挂起 vs. 立刻返回

    • launchasync 启动协程后会立即返回一个 JobDeferred,并不会等待内部逻辑执行完毕;
    • coroutineScope() 是一个 挂起函数,它会挂起调用它的协程,直到其代码块中所有操作(包括任何通过 launchasync 启动的子协程)都执行完毕后才返回。

可将它理解为:

// 等同于:launch { … }.join()
coroutineScope {
  launch { /* … */ }
  // 只有当上面这行 launch 完全执行结束后,coroutineScope 才会返回
}

正是因为这个“挂起等待”的特性,coroutineScope() 常被用来在挂起函数内部:

  • 启用子协程 —— 给挂起函数一个 CoroutineScope,才能启动 launchasync 等;
  • 封装并发逻辑并集中异常处理 —— 内部子协程失败会向外抛出,外层可以一处 try–catch,防止整个协程树无差别崩溃。
scope.launch {
  // 沿用外部的coroutineContext,但不用外部的Job,而是创建一个新的Job来用
  coroutineScope {
      launch{ // coroutineScope还会等着它执行结束
      }
  }
  launch {

  }
}

那么什么情况下我们会想到使用coroutineScope呢?

使用场景一:在挂起函数内部启动子协程

  • 问题:普通的 suspend fun 并没有显式的 CoroutineScope,无法直接调用 launchasync 等协程构建器。

  • 传统做法:将挂起函数声明为 suspend fun CoroutineScope.someFun(),这样就能拿到 this 作用域:

    private suspend fun CoroutineScope.someFun() {
        println("scope(1) 的 context = $coroutineContext")        // (1)
        launch(Dispatchers.IO) {
            println("launch(2) 的 context = $coroutineContext")  // (2)
        }
    }
    

    此时 (1) 是外层作用域的上下文,(2) 则是 IO 调度器。

  • 更优方案:直接在普通挂起函数中调用 coroutineScope { … },让 Kotlin 知道“这是一个新作用域”,即可在其中启动子协程:

    private suspend fun someFun() = coroutineScope {
        // 此处就拥有一个 CoroutineScope,可以直接 launch/async
        launch(Dispatchers.IO) {
            // ...
        }
    }
    

使用场景二:封装并发逻辑并统一返回值与异常

  • 特点coroutineScope { … } 会返回其代码块最后一行的结果,而 launch 本身不返回值。

  • 示例:并行启动两个 async,组合它们的返回值:

    val combined: String = coroutineScope {
        val d1 = async { "rengwuxian" }
        val d2 = async { "AAAAAAAAA" }
        // 代码块的最后一行即为 coroutineScope 的返回值:
        "${d1.await()} ${d2.await()}"
    }
    println(combined)  // 输出 "rengwuxian AAAAAAAAA"
    
  • 异常隔离:如果任一 async 抛出非 CancellationException,整个 coroutineScope 会向外抛异常,外层可以通过 try–catch 统一处理,避免各并发分支互相影响,也避免整个协程树无差别崩溃。

val result = try {
    coroutineScope {
        val d1 = async { fetchA() }
        val d2 = async { fetchB() }
        "${d1.await()} + ${d2.await()}"
    }
} catch (e: Exception) {
    "Error: ${e.message}"
}
// 即便内部某个请求失败,外层依然可以继续执行
println(result)

通过这两种典型用法,coroutineScope() 在挂起函数内部既能“造”一个作用域,又能将并发结果和异常收敛到同一个挂起点,极大地方便了结构化并发和异常管理。

为什么 coroutineScope 能隔离异常

  • 串行挂起
    coroutineScope 是一个挂起函数,调用它的外层协程会被挂起,直到内部所有代码(包括通过 launch/async 启动的子协程)执行完毕后才恢复。这就像把并发逻辑“包裹”在一个串行块里,外层可以在它上面统一 try–catch
  • 父子/兄弟协程并行时无法互相捕获
    普通的 launch(或默认的父子关系)和 async 并行运行时,相互之间没有 try/catch 绑定。一旦某个分支抛出非 CancellationException,就会按“全面崩溃”规则取消整个协程树,外层也无法局部捕获。

示例一:在 coroutineScope 中捕获并处理异常

scope.launch {
    // 一个并行的子协程,仅用于测时
    val startTime = System.currentTimeMillis()
    launch {
        delay(2000)
        println("Duration of coroutineScope: ${System.currentTimeMillis() - startTime} ms")
    }
    
    // 下面用 coroutineScope 包裹并发逻辑,外层能捕获内部异常
    val name = try {
        coroutineScope {
            val d1 = async { "rengwuxian" }
            val d2 = async<String> { throw RuntimeException("Error!") }
            // 仅当两者都成功时才返回拼接结果
            "${d1.await()} ${d2.await()}"
        }
    } catch (e: Exception) {
        // 捕获到 "Error!",而不会导致 scope.launch 崩溃
        e.message
    }

    println("Result name = $name")
}
  • 这里即使 d2 抛异常,也只是让 coroutineScope 向外抛,并被外层的 try–catch 捕获,scope.launch 可以继续执行后续逻辑。

示例二:在 Android lifecycleScope 中结构化并发

lifecycleScope.launch {
    try {
        // 为两次网络请求提供一个独立挂起块,方便统一异常处理
        coroutineScope {// 添加这个代码的原因是为异常的结构化管理。
            val c1 = async { gitHub.contributors("square", "retrofit") }
            val c2 = async { gitHub.contributors("square", "okhttp") }
            // 并行请求完毕后合并结果并展示
            showContributors(c1.await() + c2.await())
        }
    } catch (e: Exception) {
        // 请求任一失败,就在这里集中处理
        showError(e)
    }
    // launch 仍可继续执行其他逻辑
}

不要在这两个 async 前加 lifecycleScope.,否则它们就不再是当前 coroutineScope { … } 挂起块内部的子协程,coroutineScope 无法管理它们。
加上 lifecycleScope 前缀的 async 会脱离这层局部作用域,变成外层 lifecycleScope 的子协程,从而失去并发封装和异常隔离的好处。

当我们在 coroutineScope { … } 里这样写:

coroutineScope {
  val c1 = async { /*…*/ }
  val c2 = async { /*…*/ }
  // …
}

其实是这样执行的:

  1. coroutineScope { … } 会创建一个 新的 CoroutineScope,其 coroutineContext 中包含一个 专属的 Job(记作 jobInner)。

  2. 在这个作用域里调用 扩展函数 async { … },它等同于:

    this.async { … }  // this 就是 coroutineScope 提供的那个 CoroutineScope
    

    所以生成的两个 Deferred,它们的父 Job 都是 jobInner,属于同一个作用域,coroutineScope 能够:

    • 等待它们全部完成(因为它会挂起到所有子协程结束),
    • 在任一失败时取消它们并把异常抛到外层。

但是如果我们写成:

coroutineScope {
  val c1 = lifecycleScope.async { /*…*/ }
  val c2 = lifecycleScope.async { /*…*/ }
  // …
}

async调用者 就变成了 lifecycleScope

  • lifecycleScope 自己持有一棵独立的协程树,其根 Job 我们记作 jobLife
  • lifecycleScope.async { … } 创建出来的 Deferred 都归到 jobLife 下——而不是 jobInner
  • 因此,coroutineScope 的那个 jobInner不管理 c1c2,它也不会等待它们完成,更不会在你捕获到异常时自动取消它们。

简而言之:

  • 不加前缀async { … } ⇒ 生成的协程属于当前的 coroutineScope,受它管理。
  • 加上 lifecycleScope.async { … } ⇒ 生成的协程属于外层的 lifecycleScope,脱离了内层 coroutineScope 的控制。

这就是为什么一旦用 lifecycleScope.async,就失去了“在同一挂起块内并行启动 + 挂起等待 + 统一异常处理”的结构化并发能力。


supervisorScope vs. coroutineScope

特性coroutineScope (普通)supervisorScope
Job 语义普通 JobSupervisorJob 机制
子协程失败行为任一失败 → 取消所有同级及子协程 → 向上抛异常子协程失败 → 仅取消自身;兄弟协程继续执行;最后向上抛异常
适用场景希望一处捕获内部所有并发异常希望兄弟之间互不影响,单个失败不影响其他并发任务
scope.launch {
    supervisorScope {
        launch { /* A */ }
        launch { throw RuntimeException("B failed") }
        launch { /* C */ }  // 即使 B 异常,C 仍会执行
    }
    // supervisorScope 本身会在内部所有子协程结束后再返回,并向外抛最后的异常
}

核心思路是:

  1. coroutineScope:串行挂起 + 并发包裹 + 集中异常;
  2. 普通父子/兄弟协程:并行运行,异常会级联导致全面崩溃;
  3. supervisorScope:为并行任务提供“兄弟互不影响”的失败隔离。

再谈 withContext

1. 基本定义对比

suspend fun <R> coroutineScope(
    block: suspend CoroutineScope.() -> R
): R { … }
  • 无参数,沿用当前 coroutineContext
  • 挂起函数,会创建一个新的 ScopeCoroutine,并在其内部执行 block,直到所有子协程完成才返回。
suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
): T { … }
  • 带参数,可以传入新的 CoroutineContext(如不同的调度器或 Job)。

  • 同样是挂起函数,会根据新旧上下文是否相同选择不同的执行路径:

    1. 完全相同:直接在当前线程、当前上下文中“无调度”执行(ScopeCoroutine)。
    2. 同一 Dispatcher、仅其他属性不同:切换上下文元素但不切换线程(UndispatchedCoroutine)。
    3. Dispatcher 也变化:切换到新调度器/线程(DispatchedCoroutine)。

因此,综上,我们可以得到以下的结论:

  1. withContext 确实是挂起函数,跟 coroutineScope 一样,会等它内部的 block 完全执行完才返回;

  2. 它必须带一个 CoroutineContext 参数——这是它和 coroutineScope 最大的区别。

  3. 如果你传入的上下文跟当前协程上下文完全“对等”——

    • EmptyCoroutineContextnewContext = oldContext.newCoroutineContext(Empty) 会得到原来的 oldContext
    • coroutineContext:显式传入当前上下文,本质上也没变;
      在这两种情况下,withContext 会走“快速路径”,在同一线程、同一 Job 下执行,行为就跟 coroutineScope { … } 一模一样。

小结

  • 想切换调度器或引入新 Job 就用 withContext(Dispatchers.IO)withContext(SupervisorJob()) 等;
  • 只是想在挂起函数中开启一个子作用域但不换上下文,可写成 withContext(EmptyCoroutineContext)withContext(coroutineContext),这时它和 coroutineScope { … } 没任何差别。
public suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
): T {
    // Contract: block is called exactly once
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    // Suspend without interception and return result via continuation uCont
    return suspendCoroutineUninterceptedOrReturn sc@ { uCont ->
        // 1. 原协程上下文
        val oldContext = uCont.context
        // 2. 基于传入的 context,创建一个新的上下文(可能包含原有元素 + 新元素)
        val newContext = oldContext.newCoroutineContext(context)
        // 3. 检查新上下文的取消状态
        newContext.ensureActive()

        // —— 快速路径 #1 ——
        // 如果 newContext 与 oldContext 引用相同,说明没有任何上下文切换
        if (newContext === oldContext) {
            // 直接用 ScopeCoroutine,在同一线程、同一 Dispatcher 下执行
            val coroutine = ScopeCoroutine(newContext, uCont)
            return@sc coroutine.startUndispatchedOrReturn(coroutine, block)
        }

        // —— 快速路径 #2 ——
        // 如果 Dispatcher 相同,但其他上下文元素(如 Job)变化
        if (newContext[ContinuationInterceptor] == oldContext[ContinuationInterceptor]) {
            // 用 UndispatchedCoroutine:切换 context,但不换线程
            val coroutine = UndispatchedCoroutine(newContext, uCont)
            // 临时安装新的上下文元素到当前线程,然后执行 block
            withCoroutineContext(coroutine.context, null) {
                return@sc coroutine.startUndispatchedOrReturn(coroutine, block)
            }
        }

        // —— 慢速路径 ——
        // Dispatcher 也变化,需要切换到 newContext 对应的线程/线程池
        val coroutine = DispatchedCoroutine(newContext, uCont)
        // 启动可取消的协程,开始执行 block
        block.startCoroutineCancellable(coroutine, coroutine)
        // 阻塞等待结果(挂起返回)
        coroutine.getResult()
    }
}

public suspend fun <R> coroutineScope(
    block: suspend CoroutineScope.() -> R
): R {
    // Contract: block is called exactly once
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    // Suspend without interception and return result via continuation uCont
    return suspendCoroutineUninterceptedOrReturn { uCont ->
        // 1. 创建一个 ScopeCoroutine,继承原有 uCont.context
        val coroutine = ScopeCoroutine(uCont.context, uCont)
        // 2. 立即启动并“无调度”(undispatched),执行 block
        coroutine.startUndispatchedOrReturn(coroutine, block)
    }
}

如果你想在不启动额外并行子协程的情况下切换上下文,就不要用 launch+async,而用 withContext

  • withContext 是一个挂起函数,支持传入新的 CoroutineContext(如不同的调度器、不同的 Job 等),内部会“串行”地执行你的逻辑,不会创建与外层并行的子协程。
  • 从使用体验看,它就像一个支持参数化上下文的 coroutineScope
  • 也可以把它理解为一个“可定制上下文的串行化 launch+join”。
// 切换到 IO 调度器,并在该上下文中串行执行 block
withContext(Dispatchers.IO) {
    // 这里的逻辑跑在 IO 线程池,执行完成后才回到外层上下文
    doBlockingIOWork()
}

简而言之

withContext = “可定制 coroutineContextcoroutineScope 挂起函数”,
从效果上相当于 “串行版的 launch + async(或 launch+join)”。

CoroutineName

主要是用于调试和测试的,也是一个coroutineContext,如下边的代码,所有通过scope启动的协程都有这个name。

val scope = CoroutineScope(Dispatchers.IO + name)
scope.launch { 
  
}

CoroutineContext 的加减和 get()

一、CoroutineContext 的合并(+)与剔除(-

1.1 + 的基本行为

val scope = CoroutineScope(
    Dispatchers.IO 
    + Job() 
    + CoroutineName("MyCoroutine")
    + job2
)
  • 每次用 + 都会将左侧已有的 CoroutineContext 与右侧元素合并,返回一个新的 CombinedContext

  • 合并是“层层套上去”的结构,内部可能表现为:

    • CombinedContext(CombinedContext(Dispatchers.IO, Job()), CoroutineName("MyCoroutine"))
    • 或者等价的另一种嵌套顺序,但对外效果一致。

1.2 同类型元素的替换

  • 如果右侧元素与左侧上下文中已有同类型(同 key)的元素,就 替换 左侧旧的:

    CoroutineScope(Dispatchers.IO + Job() + Job())
    // 结果中只会保留第二个 Job,第一个被剔除
    
  • 这是因为 Job 自身定义了:

    @Deprecated(..., level = DeprecationLevel.ERROR)
    public operator fun plus(other: Job): Job = other
    

    即对两个 Job 做 “+” 时,直接取右边那个。

另外还有一些细节需要说明下:

/**
 * Returns a context containing elements from this context and elements from  other [context].
 * The elements from this context with the same key as in the other one are dropped.
 */
public operator fun plus(context: CoroutineContext): CoroutineContext =
    // 空的EmptyCoroutineContext不加入到ConbineContext里边
    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)
                // 让ContinuationInterceptor总是排在最外层,这样查找的时候就可以省时间了。因为只需要拨开一层conbinedContext就行了
                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)
                }
            }
        }
  1. 跳过空上下文EmptyCoroutineContext + X 会直接返回 X

  2. 按元素折叠:逐个把 context 里的元素 fold 进累计器 acc

  3. 剔除同 Key:合并时遇到同 Key 的元素,旧的被剔除,只保留新的。

  4. 优化拦截器位置ContinuationInterceptor(即 CoroutineDispatcher)总是放在最外层,查询时只需“拨开”一层。

总而言之

  • + 操作做了两件事

    1. 合并:将左侧 CoroutineContext 和右侧元素/上下文折叠成一个新的 CombinedContext,相当于把它们“套”在一起。
    2. 替换同类型元素:合并时,如果右侧有跟左侧相同 Key(类型)的元素,旧的(左侧的)会被剔除,只保留新的(右侧的)。

  • 为什么可以写 coroutineContext[Job] 而不是 Job.Key
    在 Kotlin 的协程 API 里,Job 接口声明了一个 companion object Key,它本身就是 CoroutineContext.Key<Job>

    public interface Job : CoroutineContext.Element {
        companion object Key : CoroutineContext.Key<Job>
    }
    

    因此,写作 coroutineContext[Job],Kotlin 就会把 Job 解析为它的伴生对象 Key,这是语言层面的简写支持。

    等价的完整写法是:

    // 简写
    val job1: Job? = coroutineContext[Job]
    
    // 全写
    val job2: Job? = coroutineContext[Job.Key]
    

二、从 CoroutineContext 中获取元素

scope.launch {
    // 1. 通过简写的 Key(Job.Companion)获取当前 Job
    val job1: Job? = coroutineContext[Job]
    // 2. 等价的全写形式
    val job2: Job? = coroutineContext[Job.Key]
    
    // 以下方式均不可用(它们不是 CoroutineContext.Key<T>)
    // val job3: Job? = coroutineContext[Job::class]
    // val job4: Job? = coroutineContext[Job::class.java]
}
  • Job 接口中定义了它自己的伴生对象 Key

    public interface Job : CoroutineContext.Element {
        companion object Key : CoroutineContext.Key<Job>
    }
    
  • Kotlin 支持简写,因此 coroutineContext[Job] 会被编译器视作 coroutineContext[Job.Key]


三、调用 CoroutineDispatcher 的特有 API

// 1. 以 ContinuationInterceptor(CoroutineDispatcher 的别名)获取调度器
val dispatcher1: CoroutineDispatcher? =
    coroutineContext[ContinuationInterceptor] as? CoroutineDispatcher

// 2. 也可以直接用接口类型的 Key
val dispatcher2: CoroutineDispatcher? =
    coroutineContext[CoroutineDispatcher]

// 3. 或者更显式地调用 get()
val dispatcher3: CoroutineDispatcher? =
    coroutineContext.get(CoroutineDispatcher)

扩展:生成一个限并行度(parallelism)更小的 Dispatcher

// 假设 dispatcher 是上面任一方式获取到的 CoroutineDispatcher
val limited: CoroutineDispatcher? = dispatcher?.limitedParallelism(3, name = "limited-3")
  • limitedParallelism(parallelism: Int, name: String? = null) 会基于原有 Dispatcher 创建一个新的 LimitedDispatcher

    • parallelism:允许同时并发的最大协程数;
    • name:可选的线程名后缀,便于调试。
public open fun CoroutineDispatcher.limitedParallelism(
    parallelism: Int,
    name: String? = null
): CoroutineDispatcher {
    parallelism.checkParallelism()  // 校验参数合法
    return LimitedDispatcher(this, parallelism, name)
}

这样,你可以:

  1. 简洁地coroutineContext[Job]coroutineContext[CoroutineDispatcher] 拿到当前协程的 Job 和调度器;
  2. 灵活地limitedParallelism(...) 派生出一个并发度受限的新调度器。

四、从 CoroutineContext 中获取元素(get()

可以通过元素的 Key 对象 来检索:

// 获取当前 Job
val job: Job? = coroutineContext[Job]

// 等价于
val job2: Job? = coroutineContext[Job.Key]

// 也可以写成
val dispatcher: CoroutineDispatcher? =
    coroutineContext[ContinuationInterceptor] as? CoroutineDispatcher
  • 这里使用 Job 而非 Job.Key 是因为 Job 接口里有:

    public interface Job : CoroutineContext.Element {
        companion object Key : CoroutineContext.Key<Job>
    }
    

    companion object Key 允许简写 coroutineContext[Job]

  • 想调用调度器特有方法时:

    val ioDispatcher = (coroutineContext[ContinuationInterceptor] 
        as? CoroutineDispatcher)?.limitedParallelism(3)
    

五、删除某个上下文元素

val noJobContext = coroutineContext.minusKey(Job)

自定义 CoroutineContext 元素

  • 继承自 AbstractCoroutineContextElement 并提供唯一的 Key
class MyCoroutineContext : AbstractCoroutineContextElement(MyCoroutineContext) {
    companion object Key : CoroutineContext.Key<MyCoroutineContext>

    suspend fun log() {
        println("Current coroutine context = $coroutineContext")
    }
}

// 使用时:
val ctx = MyCoroutineContext()
val result = withContext(ctx) {
    // 这里可以通过 coroutineContext[MyCoroutineContext] 拿到 ctx
    coroutineContext[MyCoroutineContext]?.log()
    // …
}

这样,你就可以在任意挂起函数或协程构建器里,通过 coroutineContext[MyCoroutineContext] 访问并使用你的自定义上下文元素。

学后测验

一、单项选择(每题 1 分,共 5 分)

  1. CoroutineScope 构造函数在你未传入 Job 时,会自动为其 coroutineContext 添加什么?
    A. EmptyCoroutineContext B. SupervisorJob C. 新建的普通 Job D. Dispatchers.Default
    答案:C
    解析: 源码 CoroutineScope(context) 会在 context 无 Job 时自动 + Job()

  2. GlobalScope.launch { … } 内部默认使用的调度器是:
    A. Dispatchers.Main B. 调用处的 coroutineContext C. Dispatchers.Default D. 不确定,取决于平台
    答案:C

  3. 下面哪个写法会替换原协程中的 Dispatcher?
    A. withContext(EmptyCoroutineContext) { … }
    B. withContext(coroutineContext) { … }
    C. withContext(Dispatchers.IO) { … }
    D. coroutineScope { … }
    答案:C
    解析: 只有 withContext 且传入不同 Dispatcher 才会切换;其余沿用旧上下文。

  4. 对同一协程,若依次执行

    coroutineContext + Dispatchers.IO + CoroutineName("A")
    

    最外层(最容易被 get() 取得)的元素一定是:
    A. Job B. CoroutineName C. ContinuationInterceptor D. 插入顺序决定
    答案:C
    解析: plus 内部保证 ContinuationInterceptor(即 Dispatcher)放最外,检索更快。

  5. 关于 CoroutineName,以下说法正确的是:
    A. 只在调试时可见,对运行时行为无影响 B. 会改变协程调度器名称 C. 同类型元素可叠加多个 D. CoroutineName("X") + CoroutineName("Y") 会保留 “X”
    答案:A
    解析: B 错;C 错,同 Key 替换;D 错,保留最后一个 “Y”。


二、多项选择(每题 2 分,共 10 分)

  1. 下列哪些写法能在挂起函数里拿到当前协程的 Job
    A. coroutineContext[Job] B. currentCoroutineContext()[Job] C. coroutineContext.job D. Job()
    答案:A B C
    解析: D 创建的是新 Job,不是当前的。
  2. 以下哪几种情况会导致 withContext 不发生线程切换?
    A. 传入 EmptyCoroutineContext
    B. 传入与当前上下文相同的 Dispatcher 但多了 CoroutineName
    C. 传入 Dispatchers.IO 而当前在 Dispatchers.Default
    D. 传入 coroutineContext 本身
    答案:A B D
  3. 关于 supervisorScope,以下说法正确的有:
    A. 内部使用的是 SupervisorJob B. 子协程抛异常不会取消兄弟协程 C. 父协程取消会继续保活兄弟协程 D. 自己执行完仍会向外层抛最后的异常
    答案:A B D
    **解析:**父协程取消仍会取消所有子协程(包括兄弟)。
  4. CoroutineContext+ 运算符做了哪些事?
    A. 若右侧元素 Key 已存在,则替换左侧旧值
    B. 始终把 ContinuationInterceptor 放在链表最外层
    C. 遇到 EmptyCoroutineContext 直接返回另一侧
    D. 把所有 Dispatcher 合并为一个复合 Dispatcher
    答案:A B C
  5. 以下关于自定义 CoroutineContext 元素的必备条件有:
    A. 必须实现 CoroutineContext.Element B. 必须声明伴生 Key C. 必须继承 AbstractCoroutineContextElement D. 必须实现 Job 接口
    答案:A B (或 C)
    **解析:**A B 为必要;C 为推荐做法;D 非必需。

三、判断题(每题 1 分,共 5 分)

  1. GlobalScope.coroutineContext[Job] 返回 null 对
  2. coroutineScope { … }withContext(EmptyCoroutineContext) { … } 行为等价。 对
  3. limitedParallelism() 可以对任何 CoroutineDispatcher 调用。 对
  4. coroutineScope 中写 lifecycleScope.async { … } 生成的子协程仍受当前 coroutineScope 取消控制。 错
  5. ContinuationInterceptorCoroutineDispatcher 是同一接口别名。 对

四、简答题(每题 5 分,共 20 分)

16. 简述 CoroutineContextCoroutineScope 的职责区别,并说明为什么要分离设计。
答:

  • CoroutineContext:协程运行所需“元数据容器”,用键值形式保存 JobDispatcherCoroutineNameCoroutineExceptionHandler 等元素。
  • CoroutineScope:持有一份 CoroutineContext,把它当“配置”去启动 (launch / async) 和取消 (cancel) 一组协程。

分离原因:

  1. 解耦复用——同一 CoroutineContext 可被多个 CoroutineScope 共享或叠加组合,灵活切换调度器/Job。
  2. 单一职责——Scope 只关心生命周期管理,Context 专注存放元信息;代码层次清晰。
  3. 结构化并发——取消 Scope ⇒ 级联取消其 Context 中的根 Job 及所有子协程,易于统一回收。

17. 说明 GlobalScope.launch 生成的协程在父子关系、取消传播上的特点,并列举一个合理使用场景。
答:

  • 父子关系GlobalScope 自身没有 Job,所以 GlobalScope.launch 创建的协程 Job 的 parentnull,它们各自是“根协程”。

  • 取消传播

    • 不会跟随任何屏幕、页面或 ViewModel 的生命周期自动取消;
    • 调用某个 Global 协程的 cancel() 仅影响它自己,不会波及其他 Global 协程;
    • 进程被杀时才随之终结。
  • 默认调度器:若未显式指定,会使用 Dispatchers.Default

  • 合理场景:真正进程级且“做完就好”的后台任务——例如应用生命周期内始终记录日志、定时上报指标、预生成数据库索引等。


18. 比较 coroutineScopewithContextsupervisorScope 三者在“挂起等待”“能否切换上下文”“异常传播”方面的差异。

比较点coroutineScope {}withContext(ctx) {}supervisorScope {}
是否挂起调用方✔ 挂起,直到内部协程全部结束✔ 挂起,直到内部逻辑结束✔ 挂起,直到内部协程全部结束
能否切换上下文✘ 沿用外部 CoroutineContext✔ 由参数 ctx 决定(可换 Dispatcher / Job 等)✘ 沿用外部,但内部根 Job 被替换为 SupervisorJob
异常传播规则任一子协程抛非 CancellationException ⇒ 取消所有兄弟 + 向外抛异常与左侧相同(若切换线程不影响规则)某个子协程抛异常 ⇒ 仅取消自身,其余兄弟继续;完成后把最后一个异常向外抛

19. CoroutineContext 合并时为什么总把 ContinuationInterceptor (即 CoroutineDispatcher) 放在最外层?
答:

  1. 查询频率最高——协程恢复时几乎每次都要取 Dispatcher;放最外层可 O(1) 直接获取,省去逐层解包。
  2. 独立性强——Dispatcher 不依赖其他元素,置顶不改变其它键值的可见性与逻辑。
  3. 实现简洁——把开销最大的元素放最外,内部合并/替换逻辑更简单,也减少链式嵌套深度。

五、编程题(每题 5 分,共 10 分)

  1. limitedDispatcher
    编写函数

    suspend fun currentLimitedDispatcher(n: Int): CoroutineDispatcher
    

    返回 以当前协程调度器为基础、并发度限制为 n 的新 Dispatcher。若当前协程没有 Dispatcher,要抛出 IllegalStateException给出核心代码

    参考实现:

    suspend fun currentLimitedDispatcher(n: Int): CoroutineDispatcher {
        val dispatcher = coroutineContext[ContinuationInterceptor] as? CoroutineDispatcher
            ?: throw IllegalStateException("No dispatcher in context")
        return dispatcher.limitedParallelism(n)
    }
    
  2. contextDebug
    写一个扩展函数

    suspend fun CoroutineScope.printContextInfo(prefix: String = "")
    

    要求打印当前协程的 Job 层级深度(父链长度)与 Dispatcher 类型名称,例如

    >> depth=2 , dispatcher=DefaultDispatcher
    

    参考思路:

    suspend fun CoroutineScope.printContextInfo(prefix: String = "") {
        val job = coroutineContext[Job]
        var depth = 0
        var p = job?.parent
        while (p != null) { depth++; p = p.parent }
        val dispatcher = coroutineContext[ContinuationInterceptor] as? CoroutineDispatcher
        println("$prefix depth=$depth , dispatcher=${dispatcher?.javaClass?.simpleName}")
    }