协程(7) | CoroutineContext-灵析社区

德州安卓

前言

前面文章介绍了协程的非阻塞特性和结构化并发特性,在启动协程的函数定义中我们都见到了一个非常熟悉的类:CoroutineContext,这个类可以说是贯穿了整个协程框架,理解CoroutineContext的使用以及常用的类和CoroutineContext的关系对后面理解协程原理非常重要。

正文

CoroutineContext直接翻译就是协程上下文,这里我们先来看个简单的问题:什么是Context

其实这是一个很有意思的问题,在我们平时编程中很常见各种Context,而翻译为上下文我一直觉得有点问题。假如你在读一篇小说,我突然给你中间一章内容,问你说为什么主角要这样干,你肯定不理解,因为前后章节我都没有看过,即不知道上下文,我也就无法解答这个问题。

这是文章中上下文的意思我们很容易理解,但是程序中的上下文该如何理解呢 我觉得把Context翻译为环境更为合适,即代码执行到代码段A中,这个A需要哪些信息才能正确执行下去,这些信息就保存在Context中,因为我们通常只需要上文就可以了,下文一般不需要。

所以通俗来理解,Context就可以看成是一个容器,程序需要的一些信息没地方保存就可以保存在Context中。或者更为直接的是,你在编码过程中发现你这个代码需要一大堆配置信息,你大可把这些信息都保存在Context中。比如整个Android应用都要用到某个变量,我就把它定义在ApplicationContext中,这个变量只在协程框架中用到,我就定义在CoroutineContext中。

是不是这样理解完就通透了,context就是一堆变量的集合。

指定协程运行的线程池

这里先不说CoroutineContext的设计,先来看一个ConroutineContext子类的使用,即Dispatchers来指定协程运行的线程。

比如下面代码,我想指定协程运行的线程池:

fun main() = runBlocking {
    val user = getUserInfo()
    logX(user)
}

suspend fun getUserInfo(): String {
    logX("Before IO Context.")
    withContext(Dispatchers.IO) {
        logX("In IO Context.")
        delay(1000L)
    }
    logX("After IO Context.")
    return "BoyCoder"
}

比如上面的代码,在main()函数中调用挂起函数,但是在挂起函数的执行过程中,可以使用withContext方法来指定运行的线程,withContext函数如下:

public suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
): T {
}

可以发现这里Dispatchers.IO就是一个CoroutineContext,好我们来运行一下代码:

可以发现在切换IO线程前,协程是运行在main线程上,当切换了IO线程后,协程运行在worker-1线程上,然后在执行完后,又切换了回来。

不止这个withContext,包括runBlockinglaunch的第一个参数都是CoroutineContext,我们可以用来指定运行的线程。比如下面代码:

fun main() = runBlocking(Dispatchers.IO) {
    val user = getUserInfo()
    logX(user)
}

suspend fun getUserInfo(): String {
    logX("Before IO Context.")
    withContext(Dispatchers.Default) {
        logX("In IO Context.")
        delay(1000L)
    }
    logX("After IO Context.")
    return "BoyCoder"
}

runBlocking中指定运行的线程,这里我们指定的IO线程,下面指定的是Default线程,我们来看一下代码运行结果:

会发现有的在worker-1中,有的在worker-3中,但是都是DefaultDispatcher的线程池,按理说应该是IO的工作线程才对啊,这就要明白Dispatchers几个内置的分别是啥意思

内置的Dispatcher

Kotlin官方提供了几种内置的Dispatcher,分别如下:

  1. Dispatchers.Main:它只在UI编程平台才有意义,比如Android平台,这种平台只有Main线程才能用于UI绘制。
  2. Dispatchers.Unconfined:代表无所谓,当前协程可以运行在任意线程上。
  3. Dispatchers.Default:用于CPU密集型任务的线程池,一般来说它内部的线程个数与机器CPU核心数量保持一致。
  4. Dispatchers.IO:用于IO密集型任务的线程池,内部线程数量较多,一般为64个。

需要特别注意的是,Dispatchers.IO底层是可能复用Dispatchers.Default中的线程,比如上面截图中的结果,就都是复用Default线程池中的线程。

自定义Dispatcher

这里除了Kotlin官方内置的几个Dispathcer可以选择外,还可以自定义Dispatcher,比如下面代码:

val mySingleDispatcher = Executors.newSingleThreadExecutor {
    Thread(it,"MySingleThread").apply { isDaemon = true }
}.asCoroutineDispatcher()

我们创建了一个单线程的线程池,然后通过asCoroutineDispathcer方法转换为Dispatcher,然后我们来进行使用如下:

fun main() = runBlocking(mySingleDispatcher) {
    val user = getUserInfo()
    logX(user)
}

suspend fun getUserInfo(): String {
    logX("Before IO Context.")
    withContext(Dispatchers.IO) {
        logX("In IO Context.")
        delay(1000L)
    }
    logX("After IO Context.")
    return "BoyCoder"
}

这里的runBlocking就传入我们自定义的Dispather,然后运行结果如下:

由于只有1个线程的线程池,所以切换IO线程时,会复用Default中的线程池。

这其实也就印证了协程是运行在线程上的Task这种说法

万物皆为Context

前面我们说了CoroutineDispatcher就是一个CoroutineContext,其实在协程中我们见到的几乎所有重要概念它都是CoroutineContext,比如JobCoroutineScope等,是不是觉得有点不可思议,我们先来看看这些类和CoroutineContext的关系,后面再说协程为什么这样设计。

CoroutineScope

协程作用域,我们前面文章说了launchasync都是协程作用域CoroutineScope的扩展函数,我们来看一下这个CoroutineScope:

public interface CoroutineScope {

    public val coroutineContext: CoroutineContext
}

会发现它就是一个简单的接口,而这个接口的唯一成员就是CoroutineContext,所以CoroutineScope只是对CoroutineContext做了一层简单封装而已,其核心能力还是CoroutineContext

(涉及后面文章更新感悟,可以暂不理解:这里的Context其实就是协程运行的必要环境信息,而Scope把这个Context封装了一层,然后在launch启动时,就会获取父协程的环境变量信息,从而让子协程和父协程产生关系,这也是结构化并发的原因,在后面CoroutineScope原理解析时会详细说明。)

而协程作用域的最大作用就是可以方便批量控制协程,说道批量控制协程,不由得想起来上篇文章所说的结构化并发,看下面代码:

fun main() = runBlocking {
    // 仅用于测试,生成环境不要使用这么简易的CoroutineScope
    val scope = CoroutineScope(Job())

    scope.launch {
        logX("First start!")
        delay(1000L)
        logX("First end!") // 不会执行
    }

    scope.launch {
        logX("Second start!")
        delay(1000L)
        logX("Second end!") // 不会执行
    }

    scope.launch {
        logX("Third start!")
        delay(1000L)
        logX("Third end!") // 不会执行
    }

    delay(500L)
    scope.cancel()
    delay(1000L)
}

可以发现这里创建了一个scope,然后用这个scope启动了3个协程,当协程没有执行完成时,通过调用scopecancel方法便可以取消这3个协程,上述代码打印如下:

这同样体现了结构化并发的理念。

Job

前面说了CoroutineScope还是封装的CoroutineContext的话,那Job就是一个真正的CoroutineContext了,我们来看一下源码:

public interface Job : CoroutineContext.Element {}
public interface Element : CoroutineContext {}

可以发现这里通过2层继承,Job就是CoroutineContext的子类。

Dispatcher

前面使用案例中说了内置的Dispatcher也是一个CoroutineContext,我们来看一下是如何关联的,下面是Dispatchers的源码:

public actual object Dispatchers {

    @JvmStatic
    public actual val Default: CoroutineDispatcher = DefaultScheduler

    @JvmStatic
    public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher

    @JvmStatic
    public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined

    @JvmStatic
    public val IO: CoroutineDispatcher = DefaultIoScheduler
}

会发现这是一个单例,这样很符合我们的使用,而这里提供了几个线程池,比如Default,它的类型是CoroutineDispathcer,我们来看一下:

public abstract class CoroutineDispatcher :
    AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor
public interface ContinuationInterceptor : CoroutineContext.Element

根据这个CoroutineDispatcher的继承关系,最终发现还是继承至CoroutineContext

CoroutineNmae

从名字就可以看出这个是用来给协程命名的,比如下面代码:

fun main() = runBlocking {
    val scope = CoroutineScope(Job() + mySingleDispatcher)
    scope.launch(CoroutineName("MyCoroutine")) {
        logX(coroutineContext[CoroutineDispatcher] == mySingleDispatcher)
        delay(1000L)
        logX("MyCoroutine end!")
    }
    delay(500L)
    scope.cancel()
    delay(1000L)
}

这里我们在启动协程时传入一个名字,那打印如下:

会发现这里在线程名后@的协程名,就是我们命名的,而#2是一个自增的ID。

CoroutineExceptionHandler

这个也是从名字就可以看出其作用,即协程异常处理,我们看一下使用,代码如下:

fun main() = runBlocking {
    val scope = CoroutineScope(Job() + mySingleDispatcher)
    val myExceptionHandler = CoroutineExceptionHandler{_,thorwable ->
        println("Catch exception : $thorwable")
    }
    scope.launch(CoroutineName("MyCoroutine") + myExceptionHandler) {
        logX(coroutineContext[CoroutineDispatcher] == mySingleDispatcher)
        val s: String? = null
        s!!.length
        delay(1000L)
        logX("MyCoroutine end!")
    }
    delay(500L)
    scope.cancel()
    delay(1000L)
}

这里在scope启动协程时,传递了协程名Context和异常处理Context,通过加号进行连接,然后这个协程的异常就可以被这个异常处理器捕获,打印如下:

CoroutineContext接口设计

看了上面我们不免发现协程中居然这么多重要的概念都是CoroutineContext的子类,我们来看看该类的源码:

public interface CoroutineContext {
    //get方法通过operator可以简写为[Key]
    public operator fun <E : Element> get(key: Key<E>): E?

    public fun <R> fold(initial: R, operation: (R, Element) -> R): R
    //plus方法通过operator可以简写为 + 
    public operator fun plus(context: CoroutineContext): CoroutineContext

    public fun minusKey(key: Key<*>): CoroutineContext
    
    //仅仅是接口Key,没有任何属性和方法
    public interface Key<E : Element>

    //继承至CoroutineContext的接口
    public interface Element : CoroutineContext {
     
        //接口属性,Kotlin的接口属性相当于Java的抽象方法 
        public val key: Key<*>

        //根据唯一Key,返回Element
        public override operator fun <E : Element> get(key: Key<E>): E? =
            @Suppress("UNCHECKED_CAST")
            if (this.key == key) this as E else null

        public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
            operation(initial, this)

        public override fun minusKey(key: Key<*>): CoroutineContext =
            if (this.key == key) EmptyCoroutineContext else this
    }
}

这个接口设计的非常巧妙,首先是充分利用了Kotlin接口可以带属性和默认实现的特性,其次这种无属性和无抽象方法的Key接口也是很精巧的设计。

这里就需要发挥我们对泛型的理解,首先类是对事务的抽象,而泛型可以看成对代码的抽象。就比如本例中,其实就是实现类似Map的效果,通过key来获取对应的Element对象。

第一种做法是key使用字符串,然后存和取都是利用这个字符串key,但是这种效果必须要我们手动实现一个Map来保存,来遍历。

第二种做法就是协程框架这种,Key接口对象自己就是一个key,这时子类在实现Element时,就必须会有一个Key类型的对象,把这个对象作为key。(这里Key表示接口类型,key表示键值对的键)

然后多种类型的Key,直接抽象为Key<T>即可,关于这种用法,我们后面仔细分析,先看一下接口设计。

这里的接口设计看着非常像是集合,确切的说很像集合中的Map结构,对比如下图:

所以我们完全可以把CoroutineContext当作Map来使用,都是可以通过get(简写为[])、plus(简写为+)来表示,为什么这么设计呢?

1、类似Map结构,可以方便运算符重载,快速构造CoroutineContext,比如下面代码

fun main() = runBlocking {
    //手动创建一个CoroutineScope对象
    val scope = CoroutineScope(Job() + mySingleDispatcher)
    scope.launch {
        //在协程内部,获取coroutineContext上下文对象
        logX(coroutineContext[CoroutineDispatcher] == mySingleDispatcher)
        delay(1000L)
        logX("First end!")  
    }

    delay(500L)
    scope.cancel()
    delay(1000L)
}

这里创建了一个CoroutineScope,使用Job() + mySingleDispatcher这种方式,这里之所以能使用加号,是因为运算符重载

具体原因我们前面分析过,Job就是一个Element对象,而mySingleDispatcher也是一个Element对象,根据接口种定义,所以可以这样操作。

Job() + mySingleDispatcher这种方式创建的CoroutineScope其实也就制定了JobDispatcher,这种+号就很像集合操作符。

由于launchblockCoroutineScope接收者的高阶函数类型,所以在里面我们可以获取该协程的上下文coroutineContext对象,通过[CoroutineDispatcher]获取Dispatcher

这里又有一个非常有意思的点,这里coroutineContext[CoroutineDispatcher]为什么get()方法传入的是CoroutineDispatcher就可以获取其调度器呢?

我们来看一下CoroutineDispatcher源码:

public abstract class CoroutineDispatcher :
    AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {

    //这里伴生对象类Key
    public companion object Key : AbstractCoroutineContextKey<ContinuationInterceptor, CoroutineDispatcher>(
        ContinuationInterceptor,
        { it as? CoroutineDispatcher })

   ...
}
//实际该抽象类还是实现Key接口
public abstract class AbstractCoroutineContextKey<B : Element, E : B>(
    baseKey: Key<B>,
    private val safeCast: (element: Element) -> E?
) : Key<E> 

根据前面我们对接口中Key的理解,已经companion object的本质:声明类的同时,并且创建对象,这里我们应该使用coroutineContext[CoroutineDispatcher.Key]才是合理的,因为根据伴生对象的调用规则:这里的CoroutineContext[CoroutineDispatcher.Key就等于CoroutineContext[CoroutineDispatcher.Key.INSTANCE,毕竟get方法的参数需要一个Key的对象。

那为什么这里可以直接传递CoroutineDispatcher作为参数呢?这也是一种伴生对象的简写,因为一个类只能定义一个伴生对象,就比如本类的Key单例,所以可以这样简写。

捋清楚这一套操作后,会发现这样设计背后的逻辑也非常精妙。

2、从Context本质出发,在文章开始说了,它其实就是一大堆环境变量的集合,而这些比如JobCoroutineDispatcher等都可以看成是协程运行的辅助环境变量。

同时,在前面接口设计中,Element虽然也是继承至CoroutineContext,但是从各种子类的继承规律来看,设计还是精巧的:CoroutineScope是一个范围,它包含一个环境变量的集合,所以它是封装了CoroutineContext。而其他的,比如DispatcherExceptionHandler等等更像是每一个环境变量,所以他们都是继承至Element,可以互相通过算数运算符操作,更符合逻辑。

理解这个可以借鉴后面的有篇说Continuation的文章,里面有个协程框架架构,其实CoroutineContext是最底层的概念,而launchDispatcher等则是中间层的概念,毫不夸张的说不要这些中间层API,协程一样可以运行,但是就没有这么多特性。所以说这些中层概念之所以为Context,其实都可以看成是协程运行的辅助环境变量。

总结

本篇文章没有太多深入,只是介绍了一些我们常见的类和CoroutineContext的关系,其实这里涉及到了协程框架的设计,等后面说原理时再讨论。下面做个简单总结:

  1. CoroutineContext本身是一个接口,而它的接口设计和Map的API极为相似,在使用过程中,可以当成Map来使用。这种设计的优点也就是方便我们使用运算符重载来创建我们希望的各种协程组件。
  2. 协程中非常多的类,本身就是CoroutineContext,它们通过继承Element,比如JobDeferredDispatcherContinuationInterceptorCoroutineNmaeCoroutineExceptionHandler,也正是由于这些是一个接口的子类,所以可以使用操作符重载来写出灵活的代码。
  3. 协程的CoroutineScopeCoroutineContext的一层简单封装,这个作用域可以访问这些Context,从而可以构建父子协程关系。
  4. 挂起函数也和CoroutineContext有关系,后面再说。

所以可以用下面这个图做个总结:



阅读量:88

点赞量:0

收藏量:0