学习笔记-协程调度器实现原理解析

881 阅读10分钟

上一篇文章Retrofit源码解析中,提到对于协程的一些个人理解,它并不是什么黑魔法,它只是将异步所需要的线程调度和传递回调封装或者自动生成。

将回调封装成Continuation ,调用它的 resume 或者 resumeWithException 来返回结果或者抛出异常,跟我们普通的回调并没有区别。

将调度封装成调度器,调度器的本质是一个协程拦截器,它拦截的对象就是Continuation,进而在其中实现回调的调度。

本文重点就是分析协程中的调度器是怎样工作的。首先从它的基类协程上下文、协程拦截器开始看。

1. 协程上下文

协程上下文CoroutineContext是一个接口,定义了一种数据结构,它的直接子类有三个ElementEmptyCoroutineContextCombinedContext

public interface CoroutineContext {
    public operator fun <E : Element> get(key: Key<E>): E?

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

    public operator fun plus(context: CoroutineContext): CoroutineContext =  ...

    public fun minusKey(key: Key<*>): CoroutineContext
}

CoroutineContext定义了四个方法:

CoroutineContext作用
get(key: Key): E?通过Key查找Element
fold( R, (R, Element) -> R): R遍历所有Element
plus(CoroutineContext): CoroutineContext两个CoroutineContext相加组合
minusKey(key: Key<*>): CoroutineContext移除Key对应的Element

看到这几个熟悉的方法,可以说CoroutineContext是一个以Key为索引的Element集合。

1.1. Element

Element本身也继承自CoroutineContextElement也有这些集合的接口,不过它实现的是只有一个元素的集合。 image-20220112223215816.png

1.2. EmptyCoroutineContext

EmptyCoroutineContext是一个静态类,从名字就可以看出,它是一个空实现。

image-20220112223021203.png

1.3. CombinedContext

CombinedContext是一个单向链表实现,与平常的链表存储下一个元素不同,CombinedContext存储的是上一个元素。

get()方法查找从当前元素向上查找。

fold()方法遍历的时候是最后再遍历当前元素,先递归调用处理前面的元素,最后再处理自身。

image-20220112223105218.png

1.4. ContinuationInterceptor

CoroutineContext.Element有一个主要的实现--拦截器ContinuationInterceptor

新增的interceptContinuation接口,返回包装原始回调continuation的一个新的continuation,拦截所有的回调操作。

image-20220112223348893.png

再回头看plus的实现。

plus的对两个CoroutineContext进行了合并,合并的过程是将被合并的CoroutineContext中一个个单独的element,添加到当前CoroutineContext中。

添加的过程中,移除掉同一个相同Key的值,并且将拦截器放到了最后一项。

image-20220112222615828.png 从这里可以看出CoroutineContext的两个特点,同一种类型的CoroutineContext在列表中只能存在一个,拦截器在列表的最后一项。又因为CombinedContext结构的特殊性,他存储的是上一个元素的指针,所以最后一项也就是第一项。

2. 调度器

回到CoroutineScope.launch()方法,CoroutineContext的默认值是EmptyCoroutineContext,也就是之前说的空实现。在函数体中,调用CoroutineScope.newCoroutineContext()对传入的context进行了封装。

封装的过程中,当未指定其他调度程序或ContinuationInterceptor时,添加Dispatchers.Default

image-20220112221517170.png

image-20220112221428067.png

这里的Dispatchers.Default就是一个调度器的默认实现,调度器的作用就是对进程进行调度处理。

首先看一下调度器的定义接口:CoroutineDispatcher

添加了两个方法isDispatchNeeded()dispatch()isDispatchNeeded()用于分别当前是否需要调度,dispatch()是调度的执行代码。

CoroutineDispatcher本身是context的子类,同时实现了ContinuationInterceptor接口,在拦截器接口中将回调封装成了DispatchedContinuation

public abstract class CoroutineDispatcher :
    AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {
    ...
    public open fun isDispatchNeeded(context: CoroutineContext): Boolean = true
    
    public abstract fun dispatch(context: CoroutineContext, block: Runnable)
    
    public final override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> =
        DispatchedContinuation(this, continuation)
}

DispatchedContinuationContinuation的子类,重写了其中的resumeWith()方法,在其中就调用的CoroutineDispatcher中新增的两个方法。

如果isDispatchNeeded()返回True,标识需要调度,调用dispatcher.dispatch()实现线程的调度。否则的话,无约束的直接调用回调方法continuation.resumeWith()

image-20220112221757998.png

如果需要控制协程的调度,继承实现CoroutineDispatcher接口即可。但在实际的使用过程中并不需要我们自己实现,KotlinDispatchers内置了四个调度器MainDefaultIOUnconfined

Main让协程体在主线程运行,DefalutIO在一个线程池中运行,Unconfined则是不限制运行线程。接下来看他们是如何实现的。

2.1. Main

Main通过MainDispatcherLoader获取。

image-20220112142538904.png 进入到MainDispatcherLoader,可以发现它的主要作用是创建MainDispatcherFactory,而MainDispatcherFactory的作用就是创建MainCoroutineDispatcher。其次之所以需要factory创建,是因为Kotlin有多个平台实现,每个平台实现方法不同。

image-20220112104558104.png

Android中的实现是AndroidDispatcherFactory,创建的调度器实现是handlerContext,并且看到了熟悉的Looper,就可以大概猜出是怎么实现的啦。

image-20220112112331966.png

查看HandlerContext源码,在isDispatchNeeded()中根据是否立即调用和是否已经在主线程决定是否需要调度,而在dispatch()中的调度的实现方法就是调用handler.post()方法,而handler就是前面通过Looper.getMainLooper().asHandler()获取到的MainHandler,也就是Android中常说的主线程和UI线程。至于Runnablepost之后怎么执行,就是另一个问题,这里不再累赘。

image-20220112135613350.png

Android中,Main的调度方法就是将所有的任务通过handler.post()抛到了主线程执行。

2.2. Default

Default的创建调用了一个叫createDefaultDispatcher()的方法。createDefaultDispatcher()直接使用的是DefaultScheduler

image-20220112142837741.png

image-20220112142956113.png

DefaultScheduler的实现主要在它的父类ExperimentalCoroutineDispatcher之中,直接来看它关于调度的两个方法。

首先isDispatchNeeded()是在基类接口CoroutineDispatcher之中,返回true即所有情况下都需要进行调度。

image-20220112143814800.png

第二个方法dispatch()实现在ExperimentalCoroutineDispatcher之中,但是其中所做的事情就是全部代理给了coroutineScheduler去处理。

image-20220112143944524.png

进入到coroutineScheduler.dispatch(),这个方法比较复杂,接下来对这个方法逐行进行分析。

image-20220112160751247.png

第一个方法createTask(),是对Runnable进行了封装,主要是添加了taskContext,并且在run()执行完之后调用了taskContext.afterTask()。在Default中基本没有使用到这个功能,后续讲到IO时会用到这玩意。

image-20220112161805815.png

第二个方法currentWorker(),调用Thread.currentThread()获取当前线程,并且强制转换为Worker返回,这里的Worker是什么呢?

WorkercoroutineScheduler内部对于Thread的实现,也可以说是对Thread进行的封装,也即通过Default调度的协程都是在这些Worker上面运行的。

再回到这个这个方法,Thread.currentThread() as? Worker会在什么情况下返回Worker,又在什么情况下返回了NULL呢?直接的解释就是当currentThread是一个Worker对象的时候会返回Worker,再深究一下,就是在Default调度后的协程体中,再次触发了Default的调度,这个时候返回值才不是空的。举个反例,如果在主线程中触发了Default的调度,这个方法则会返回NULL

image-20220112162454276.png

第三个方法currentWorker.submitToLocalQueue(),这是一个扩展方法,字面意思就是往WorkerlocalQueue中塞Task,这个队列就是线程需要执行的任务队列,但这个方法不一定成功,如果不成功就返回task,如果成功了就返回了空。

比如刚才获得的currentWorker是一个空对象,或者线程已经进入终止态,亦或者线程当前阻塞而Task不阻塞等,都不会塞成功。

image-20220112164340540.png

接下来如果submitToLocalQueue()返回值不是空,就会调用addToGlobalQueue()Task添加到coroutineScheduler自身维护的任务队列之中,这里存放着所有不知道应该交给那个Worker执行的任务,等待着被拉出来执行。

image-20220112180657431.png

到了最后一步如果任务是非阻塞的,会调用signalCpuWork(),在其中调用两个方法尝试获取Worker

trtUnpark()尝试从堆栈中取出一个Worker,如果成功则调用LockSupport.unpark()唤醒对应线程。LockSupport是一个线程阻塞工具类,有一个对应的方法unpark()park(),用来控制线程的唤醒和阻塞,不需要某个对象的锁也不会有先后执行顺序的影响。这里的操作就是将堆栈中阻塞的Worker唤醒。

如果没有成功从堆栈中拿到Worker,就会调用tryCreateWorker()尝试创建一个Worker。这里只要运行中的线程没有超过设置的上限就会创建新的Worker,并且如果是首次创建,会同时创建两个,为了实现抢占机制(抢占另一个Worker后续的任务自己去执行)。这里的corePoolSize由虚拟机控制。

image-20220112200807070.png

创建的过程校验了一下上限然后就是创建Worker并且调用start()方法启动线程。

image-20220112203000365.png

前面不论是唤醒或者是start(),都是走到Worker.run()方法,在其中通过findTask()拿到Task,然后调用executeTask()执行它。

image-20220112204528633.png executeTask()执行的方法就是调用Runnable.run()方法,简单。

findTask()大多数情况下会走到findAnyTask()方法,根据标志位和随机数从自己的任务队列或者全局的任务队列中取任务,如果取不到,还会通过trySteal()遍历其他的Worker的任务队列,去抢占他们的任务,这些种种都是为了更快的执行任务。

image-20220112212656350.png 到这里Default基本上没有什么秘密了,本质还是创建新线程、启动线程或者唤醒线程。

2.3. IO

IO的调度是基于Default的,它的实现是LimitingDispatcher,但是DefaultScheduler本身却是作为参数传入其中,可以想到,IO是在Default的基础上做了一些额外的处理。

image-20220112213642314.png

image-20220112213658181.png

进到LimitingDispatcher,还是直接找dispatch()方法。

每一次调度的时候,都将计数inFlight加一,并且和参数parallelism比较。inFlight就可以理解为当前正在执行的任务数,而parallelism就是允许并行的上限,它的值还是由JVM配置决定的。

如果当前并行的任务数并没有达到上限,就调用传入的Default调度器的方法,否则就把任务放到自身维护的任务队列中去等待执行。

image-20220112214554260.png

细心的可能已经发现,这里调用Default的时候,不是调用的dispatch()方法,而是dispatchWithContext()并且把自己传了进去。这里传进去的是TaskContext接口,前面介绍Task时候说到,TaskRunnable进行了封装,在run()执行完之后调用了taskContext.afterTask()

afterTask()中,尝试从任务队列中取新任务,如果没有了就将计数器减一。

image-20220112215945020.png 这也就是IO新增的逻辑了,增加了并行任务上限的限制,如果没有达到上限,IODefault是一样的效果。

2.4. Unconfined

Unconfined实现十分简单,isDispatchNeeded()返回值改成false

image-20220112220751355.png

3. 总结

通过四种调度器的源码分析,这里不再是一个黑魔法。归根溯源之后,其实就是最熟悉的handler.post()亦或者是Thread.start(),与我们自己创建一个异步回调没有什么不同。再回归到开头对于协程的解释,它只是将异步所需要的线程调度和传递回调封装或者自动生成,本质上还是异步回调本身,但它将这些繁琐的工作封装起来,用起来还是很舒服的,这次知道了原理之后,可以更舒畅的使用这个工具了。

如果文章中有任何问题,烦请积极指出。

你的点赞和评论是我最大的动力!

4. 参考文章

  1. Kotlin Coroutines Dispatchers 那一兩件事
  2. 破解 Kotlin 协程(3) - 协程调度篇
  3. 挂起(suspend)与线程阻塞工具类LockSupport