本文翻自Roman Elizarov撰写的Kotlin协程解析系列,原文:Coroutine Context and Scope

Kotlin内的每一个协程,都有一个由CoroutineContext对象代表的上下文。一个上下文其实就是一组协程元素(CoroutineContext.Element),可以通过coroutineContext属性来访问协程的上下文:

fun main() = runBlocking<Unit> {
    println("My context is: $coroutineContext")
}

// My context is: [CoroutineId(1), "coroutine#1":BlockingCoroutine{Active}@73c6c3b2, BlockingEventLoop@48533e64]

协程上下文是不可变的,但是可以使用plus()来添加协程元素到上下文中,就像为集合添加元素一样,这会生成一个新的协程上下文:

fun main() = runBlocking<Unit> {
    println("A context with name: ${coroutineContext + CoroutineName("test")}")
}

// A context with name: [CoroutineId(1), "coroutine#1":BlockingCoroutine{Active}@73c6c3b2, CoroutineName(test), BlockingEventLoop@48533e64]

一个协程的本身是由一个Job对象表示的,它负责管理一个协程的生命周期、取消以及父子关系。当前的Job可以从CoroutineContext中取得:

fun main() = runBlocking<Unit> {
    println("My job is: ${coroutineContext[Job]}")
}

// My job is: "coroutine#1":BlockingCoroutine{Active}@6aa8ceb6

还有一个接口叫做CoroutineScope(作用域),这个接口里只定义了一个属性——val coroutineContext: CoroutineContext,除了上下文之外它什么也不再包含了。所以,这个接口为何存在,它与上下文本身有何区别?其实它们之间的区别和设计初衷相关。

一个典型的协程使用launch构造器来启动:

fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    // ...
): Job

这个构造器被定义为CoroutineScope的扩展函数,并且传入一个CoroutineContext作为参数,所以它实际上需要两个CoroutineContext(尽管CoroutineScope只有一个CoroutineContext的引用)。

那么launch构造器使用这两个CoroutineContext做了什么操作呢?它使用plus()操作,合并两个CoroutineContextElement,同时会使参数context中的Element获得比调用的CoroutineScope中的Element更高的优先级。

最后,合并完毕的CoroutineContext会被用于启动一个新的协程,但是这个CoroutineContext并不是新启动协程的CoroutineContext,而是新启动协程的父级CoroutineContext

Coroutine context creation

新的协程会从调用者的Job中创建他自己的Job对象,之后,会把这个协程的Job对象和刚刚合并的CoroutineContext进行plus()操作,得到新的CoroutineContext,这个CoroutineContext才是新启动协程内得到的。

这样设计的目的是希望CoroutineScope在一个可管理的范围内创建协程。通常来说,一个CoroutineScope内的CoroutineContext包含一个Job元素,这个Job元素将会成为在这个Scope内创建的新协程的父级。(GlobalScope是一个例外,它不包含Job,应该避免用它启动协程)

另一方面,launch构造器中的context参数设计初衷,是希望能够使用传入的context来替换当前作用域内context 的一些元素,进而实现改变一些当前Scope下的行为。例如:

fun main() = runBlocking<Unit> {
    launch(CoroutineName("child")) {
        println("My context is $coroutineContext}")
    }
}

// My context is [CoroutineName(child), CoroutineId(2), "child#2":StandaloneCoroutine{Active}@5eb5c224, BlockingEventLoop@53e25b76]}

尽量不要将带有Job元素的CoroutineContext传入launch构造器,因为这样做会破坏协程的父子关系,除非我们明确表示不需要父子关系,比如使用NonCancellableJob

此外请注意,launch构造器中的block,也需要接受一个CoroutineScope参数:

fun CoroutineScope.launch(
    // ...
    block: suspend CoroutineScope.() -> Unit
): Job

通常,所有的构造器都应该遵守以下约定:CoroutineScope内的CoroutineContext应该和block内的CoroutineContext相同:

fun main() = runBlocking<Unit> {
    launch { scopeCheck(this) }
}

suspend fun scopeCheck(scope: CoroutineScope) {
    println(scope.coroutineContext === coroutineContext)
}

// true

这样,当我们在代码中看到一个无限制条件的的coroutineContext引用时,由于它们始终是相同的设计,因此对应命名的top-level propertyscope’s property之间就没有混淆。

因为CoroutineContextCoroutineScope在本质上是一样的,所以我们可以不通过GlobalScope来启动协程,而是直接通过CoroutineContext创建CoroutineScope对象:

suspend fun doNotDoThis() {
    CoroutineScope(coroutineContext).launch {
        println("I'm confused.")
    }
}

但是最好别这么干,这会使这个CoroutineScope启动的协程不透明,应该通过捕获外部的Job的方式来启动新协程,而不是声明在函数中,一个协程通常需要与其他的代码合作,所以它的启动必须明确父子关系。

如果你希望启动一个在函数返回后继续运行的协程,请将你的函数设计成CoroutineScope的扩展函数,或者将CoroutineScope作为参数传递给你的函数。通过这种设计来使你的函数签名更加清晰,同时,不要用suspend修饰这些函数:

fun CoroutineScope.doThis() {
    launch { println("I'm fine") }
}

fun doThatIn(scope: CoroutineScope) {
    scope.launch { println("I'm fine, too") }
}

另一方面,挂起函数是被设计为非阻塞的,它不应该具有启动其他并发工作的副作用。

挂起函数应该等待其所有工作完成后,再返回调用者。(换而言之,挂起函数内不要搞并发,不要调用后直接返回,然后在后台干一些奇怪的事)。