『译』揭秘协程上下文

转载请注明原文地址:https://www.jianshu.com/p/f60b009e5ac4

原文标题Demystifying CoroutineContext
原文发布于:2018-09-27

Kotlin 协程的核心就是 CoroutineContext(协程上下文) 接口。所有的协程构建者,比如:launch 和 async,第一个参数都是 context: CoroutineContext。这些构建者也都是属于协程作用域接口的扩展方法,同时协程作用域接口仅有一个只可读的抽象属性 coroutineContext: CoroutineContext

每个协程构建器是一个协程作用域接口的扩展函数,进而继承了作用域接口的 coroutineContext对象,可以自动的传递上下文 elements 和 撤销。(译者注:这句话有点难懂,不过不要紧,继续往下看)

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}
public fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
): Deferred<T> {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyDeferredCoroutine(newContext, block) else
        DeferredCoroutine<T>(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

协程上下文是 Kotlin 协程的一个基本结构单元。巧妙的运用协程上下文是至关重要的,以此来实现正确的线程行为、生命周期、异常以及调试。

数据结构

它是一个有索引的 Element 实例集合。这个有索引的集合类似于一个介于 set 和 map之间的数据结构。每个 element 在这个集合有一个唯一的 Key 。当多个 element 的 key 的引用相同,则代表属于集合里同一个 element。

协程上下文接口的源码初看之下可能会觉得晦涩难懂,但是实际上它仅仅是一个 类型安全的异构 Map 数据结构 ,从 CoroutineContext.Key 实例(Key是按传地址方式比较,而不是传值,作为每个类的文档)映射到 CoroutineContext.Element 实例。为了更好的理解:为什么不得不重新定义一个新接口,而不是简单的使用一个标准的 Map 结构 ? 下面考虑等价的声明一个上下文 :

typealias CoroutineContext = Map<CoroutineContext.Key<*>, CoroutineContext.Element>

然而 Map 结构的 get 方法是不能够推导出返回的 Element 接口的实体类型,即使这个信息可以作为 Key 的泛型表示。

fun get(key: CoroutineContext.Key<*>): CoroutineContext.Element?

如上所示,无论何时从 map 中获取一个 element ,它必须要强制转换为一个实际类型 (译者注:因为 Element 是一个接口,实际使用时绝对会定义为一个子类,当用到子类定义的方法这时就需要强转) 。但是在 CoroutineContext 类中不同,它的泛型 get 方法,通过传递来的 Key 参数的泛型类型,明确的定义了返回的 Element 类型。

fun <E : Element> get(key: Key<E>): E?

这个方法得以 elements 可以安全的被获取而无需类型强转,因为它们的类型在 Key 的泛型中被明确指定

操作符

从上面的数据结构可知,协程上下文并没有实现集合类型的接口,所以它没有一些集合特有的操作符。

译者注:Kotlin 的集合类型比 Java 要更强大,它内置支持很多操作符,比如集合类型的 plus 操作符minus 操作符 等。

不过它仍自己实现了一个非常重要的运算符 —— plus 。这个 plus 操作符可以把两个协程上下文结合在一起。它将合并两者包含的 elements,很像 Map 的行为。(译者注:像 Map 那样一个 key 对应一个 value ),它使用右侧的上下文增量覆盖左侧的上下文。

简而言之,加号操作符返回一个包含两个上下文所有 elements 的上下文集合,当两个上下文有 key 值相同的 element 时,它将丢弃掉操作符左侧上下文中的 element。

CoroutineContext.Element 接口实际上继承自 CoroutineContext 。这是非常有好处的,因为如此意味着 CoroutineContext.Element 实例可以被简单的视为一个仅含一个元素的协程上下文实例。

综上所述," + " 运算符可以很容易的用于结合上下文,但是有一个很重要的事情需要小心 —— 要注意它们结合的次序,因为这个 " + " 运算符是不对称的 (译者注:也就是不同于数学里面的 +,它不满足结合律) 。

某些情况需要一个上下文不持有任何元素,此时就可以使用 EmptyCoroutineContext 对象。可以预见,添加这个对象到另一个上下文不会对其有任何影响。

Elements

如上所述,协程上下文本质上是一个 Map 结构,并且它总是持有一个预先定义的项目集合。由于所有的 Key 必须实现 CoroutineContext.Key 接口,所以通过查找 CoroutineContext.Key 实现的源码,很容易发现一系列公有的 Elements ,进而检查相关的 Element 扩展。

下面列举所有常用到的 Element :

查看上面这些类或接口的源码可以看到,在上述 Element 中的每个 Key 被定义为一个伴随对象。这样做,可以直接把这些 element 类型名作为 Key 来使用。例如:coroutineContext [Job] 将返回一个持有 coroutineContext 的 Job 实例,或者返回 null 如果它不包含任何东西。

如果不考虑扩展性这一要素,协程上下文可以简化为下面这个类模型:

class CoroutineContext(

val continuationInterceptor: ContinuationInterceptor?,

val job: Job?,

val coroutineExceptionHandler: CoroutineExceptionHandler,

val name: CoroutineName?

)

协程作用域构建器们

当我们要启动一个协程时,通常我们要调用隶属于 CoroutineScope 实例的一个构建函数。在这些构建函数中,我们可以看到有三个上下文起作用。

  • 这个协程作用域的接收者被定义的同时它本身就提供了一个协程上下文 。这是一个“继承的上下文”。(译者注:通过查阅 CoroutineScope 源码 很容易发现,该接口有一个公有的只可读的 coroutineContext 对象,这个即为 “继承的上下文”)

  • 这个构建器函数接收一个协程上下文实例作为它的第一个参数。我们将它称为 “上下文参数”。

  • 这个构建函数还有一个挂起参数 —— “block”,它是一个协程作用域类型的扩展函数,所以其本身也提供了一个协程上下文。这个我们成为“协程上下文”。

通过查阅构建器 launchasync 的源码,我们可以看到它们两者都有同样一条语句:

val newContext = newCoroutineContext(context)

这个 newCoroutineContext 是协程作用域类的一个扩展函数,它用于把 “继承的上下文” 和 “上下文参数” 以及提供的默认值进行合并,并且做了一点额外的配置。这个合并可以等价写作 coroutineContext + context ,这里的 coroutineContext 也就是 “继承的上下文”,context 也就是 “上下文参数” 。前文中已经给出过关于 CoroutineContext.plus 操作符的解释,它是右操作数优先的,因此 “上下文参数” 的属性元素将覆盖“继承的上下文”的属性元素。这个函数的返回结果我们称之为 “父上下文”。

父上下文 = 默认值 + 继承的上下文 + 上下文参数

译者注:前方高能!总之,本人结合源码:

@ExperimentalCoroutinesApi

public actual fun CoroutineScope.newCoroutineContext(context: CoroutineContext): CoroutineContext {

val combined = coroutineContext + context

val debug = if (DEBUG) combined + >CoroutineId(COROUTINE_ID.incrementAndGet()) else combined

return if (combined !== Dispatchers.Default && combined[ContinuationInterceptor] == null)

debug + Dispatchers.Default else debug

}

还是可以较好的理解上面这一段的含义,但是下面这段,由于描述的问题真心绕,所以翻译也只好跟着绕,建议读者可以结合 lauch 的源码 以及 AbstractCoroutine的源码 来阅读,不然对于初学者真的很难懂。

把这个协程作用域实例作为一个接收者传递给这个挂起的“block”,它实际上就是协程本身,它总是继承于 AbstractCoroutine 类,实现协程作用域,同时也是一个 Job 类型。这个“协程上下文”就是这个类提供的,同时将返回前面获取的“父上下文”,然后在把“父上下文”和它本身相加,以此有效的重载 Job 类。

协程上下文 = 父上下文 + 协程 Job

译者注:高能结束!本人反正有点晕,总之,慢慢看源码,留待以后再深入理解吧

默认值

当正要使用的协程,它的上下文缺失 elements 时,它将使用一个默认值来替代缺失的 element。

  • ContinuationInterceptor 子接口的默认值是 Dispatchers.Default 。这个在 newCoroutineContext 的文档中有提到。因此,当一个 dispatcher 既没有 “继承的上下文” 也没有 “上下文参数”,那么这个 dispatcher 就会使用这个默认值。在这个情况下,协程上下文也将继承这个默认的 dispatcher。

  • 如果上下文没有 Job ,那么这个协程被创建后就不会有 “父上下文”。

  • 如果这个上下文没有 CoroutineExceptionHandler ,那么全局的异常处理会被使用(但是没有安装在这个上下文中)。它将最终调用 handleCoroutineExceptionImpl 方法,这个方法首先使用了 Java 的 ServiceLoader 类装载所有 CoroutineExceptionHandler 的实现类,然后传播这个异常给到“当前线程未捕获的异常处理器”。在 Android 系统中,一个特有的异常处理器 AndroidExceptionPreHandler 是自动安装的,它会把异常报告给在 Thread 类的隐藏属性 uncaughtExceptionPreHandler,把异常日志输出到控制台。

  • 协程的默认命名为 “coroutine”,它是直接硬编码在代码中(译者注:可直接查阅 CoroutineContext.coroutineName 的源码),可以使用 CoroutineName 来获取上下文的名称。

通过上面的描述,接下来可以把协程作用域当作一个类的模型来看,以此来阐明它的默认值。

val defaultExceptionHandler = CoroutineExceptionHandler { ctx, t ->

ServiceLoader.load(

serviceClass,

serviceClass.classLoader

).forEach{

it.handleException(ctx, t)

}

Thread.currentThread().let {

it.uncaughtExceptionHandler.uncaughtException(it, exception)

}

}

class CoroutineContext(

val continuationInterceptor: ContinuationInterceptor =

Dispatchers.Default,

val parentJob: Job? =

null,

val coroutineExceptionHandler: CoroutineExceptionHandler =

defaultExceptionHandler,

val name: CoroutineName =

CoroutineName("coroutine")

)

运用

通过一些例子,让我们看一看在一些协程表达式中的上下文的结果,最重要的是看一看它们继承的那些 dispatchers 和 parent jobs。

全局作用域的上下文

GlobalScope.launch {

/* ... */

}

如果我们检查 GlobalScope 的源码,我们可以看到它的 coroutineContext 一直返回一个 EmptyCoroutineContext 。 由于这个协程使用了这样的上下文结果,因此它将使用所有的默认值。上面的语句可以等于下面这个示例,显示的指定一个默认的 dispatcher 。

GlobalScope.launch(Dispatchers.Default) {

/* ... */

}

完全限定的上下文

与之相反的,我们可以指定一个包含所有的 elements 的上下文作为参数。

launch(

Dispatchers.Main +

Job() +

CoroutineName("HelloCoroutine") +

CoroutineExceptionHandler { _, _ -> /* ... */ }

) {

/* ... */

}

没有一个 elements 来自于继承的上下文。This statement has the same behavior no matter the CoroutineScope on which it’s called.

协程作用域上下文

在 Android 的协程 UI 编程指南,我们在 “并发行结构,生命周期以及协程父子层级结构” 这章里,可以找到下面这个例子,它展示了如何在一个 Activity 里面实现一个协程作用域。

abstract class ScopedAppActivity:

AppCompatActivity(),

CoroutineScope

{

protected lateinit var job: Job

override val coroutineContext: CoroutineContext

get() = job + Dispatchers.Main

override fun onCreate(savedInstanceState: Bundle?) {

super.onCreate(savedInstanceState)

job = Job()

}

override fun onDestroy() {

super.onDestroy()

job.cancel()

}

}

根据上面这个例子,这个作用域返回的上下文有一个 dispatcher。作出这样的设计,是为了在这个作用域中调用所有的协程构建者,都是使用 Main dispatcher 而不是 Default。在这个作用域的上下文定义的 elements 使用的是一种重载这个库的默认值。这个作用域也提供了一个 job,使得来自于这个上下文的所有协程都有相同的父类。这样的做法,可以在单点就可以取消所有的运行的协程。

重载父 Job

我们可以有一些继承的上下文 elements ,从作用域或者其他添加的“上下文参数”,以便结合两者。例如,当使用 NonCancellable job,它是典型的只作为参数传递的上下文 elements 。

withContext(NonCancellable) {

// this code will not be cancelled

}

The code executed within this block will inherit the dispatcher from its calling context, but it will override that context’s job by using NonCancellable as parent. This way, the coroutine will always be in an active state.

访问上下文 Elements

在当前的上下文中,通过使用顶层挂起的 coroutineContext 只可读属性,可以获取到所有的 elements。

println("Running in ${coroutineContext[CoroutineName]}")

上面这个语句可以作为一个示例:如何打印当前协程的名称。

如果我们确实想要重建一个和当前上下文一模一样的协程上下文,可以这样:

val inheritedContext = sequenceOf(

Job,

ContinuationInterceptor,

CoroutineExceptionHandler,

CoroutineName

)

.mapNotNull { key -> coroutineContext[key] }

.fold(EmptyCoroutineContext) { ctx: CoroutineContext, elt ->

ctx + elt

}

launch(inheritedContext) {

/* ... */

}

这个例子除了能让我们更好的理解上下文的构成,在实际编程里是毫无用处的。在实际开发中,我们通常应该是要省去 launch 函数的上下文参数,让它默认为空就好。

嵌套的上下文

这个最后的示例是非常重要的,因为它呈现了在最近发布的协程版本的一个变化,构建者函数成为了 CoroutineScope 的扩展函数。

(译者注:这篇文章写于2018年9月底,当时协程很多特性都是实验性的,迭代变更频繁,很多都是 kotlin.coroutines.experimental 的,当然现在大多已经不是 experimental 了)

GlobalScope.launch(Dispatchers.Main) {

  val deferred = async {

  /* ... */

  }

/* ... */

}

假定在一个作用域中,调用 async 函数 (作为一个顶层函数的替代),它将继承这个作用域的 dispatcher,这个 dispatcher 通过 launch 函数指定为 Dispatchers.Main,而不是使用默认值。在上一个版本的协程中,这代码里的 async 函数将运行在 Dispatchers.Default 提供的工作线程中,但是现在它将运行 Dispatchers.Main 提供的 UI 线程,这样可能引发阻塞应用导致崩溃发生。

很显然,有一个很简单的解决方案,如下:

launch(Dispatchers.Main) {

  val deferred = async(Dispatchers.Default) {

  /* ... */

  }

/* ... */

}

协程 API 设计

协程 API 的设计是灵活的且富有表现力的。通过使用一个简单的 “+” 操作符结合上下文,语言设计者确保当启动它时,尽可能的轻松的定义一个协程的属性,同时可以从运行的上下文中,继承这些属性。这样既可以让开发者完全控制他们定义的协程,同时也保证了语义的流畅。

禁止转载,如需转载请通过简信或评论联系作者。

推荐阅读更多精彩内容