Kotlin协程-Scheduler的优秀设计

在仔细分析协程源码后,发现kotlin中协程的很多设计都参考了golang中的实现。
比如跟线程和任务调度关系最密切的 Scheduler 类,在它的注释中看到这句话,

* The original idea with a single-slot LIFO buffer comes from "Golang" runtime scheduler by D. Vyukov.
 * It was proven to be "fair enough", performant and generally well accepted and initially was a significant inspiration
 * source for the coroutine scheduler.
 *

Scheduler的实现参考了golang运行时的单槽位LIFO结构。Kotlin在它的基础上进行了优化和提升,做到能够实现FIFO队列的多槽位机制。可以说Kotlin协程的很多实现都和Golang的协程差不多。

其中比较优秀的实现我认为有三个,

controlState
线程管理
阻塞协程的调度策略

线程管理和阻塞协程的调度策略应该和Golang中的差不多。接下来先看controlState的应用和具体实现。

在接着介绍之前,建议先看一下Linux内核中断和io,其中关于IO密集型和CPU挂起的关系是理解阻塞线程的基础。

任务调度的核心数据结构-controlState

在协程调度中涉及到最重要的东西是CPU核心的使用率。这个问题在线程池技术中也需要考虑到,比如CPU只有4核,那么线程的上线应该不能大于4个,否则会有较大的线程切换带来的性能开销。

基于这个就引申出来一个问题,怎么样合理地创建线程,同时用一种怎样的数据结构来记录这个信息?仔细考虑后面的问题,至少有三个信息需要记录,1 可运行的core数,2 创建的线程数,3 处于阻塞状态的线程数。记录阻塞状态的线程数是因为,一个处于IO阻塞状态的线程,实际上是没有占用core的。

最容易想到的直接方式是对这三个信息分别用三个变量记录。但如果Kotlin这么做那就没什么优秀可说了。

controlState是Kotlin用来记录这个信息的一个long型变量。设计者用分区位移的方式把上面的三个信息放到controlState中。从右到做,高位21个bit表示创建了的线程数量,之后是阻塞线程,最后21个bit是CPU core数。剩一个bit没用到。

controlState的创建代码如下

private val controlState = atomic(corePoolSize.toLong() shl CPU_PERMITS_SHIFT)

CPU_PERMITS_SHIFT 的值是42。假设我们的corePoolSize是4核,那么上面的操作是把4,往左移动42个bit。
之后就可以通过下面这个函数查看还有多少CPU core可用,

public inline fun availableCpuPermits(state: Long): Int = (state and CPU_PERMITS_MASK shr CPU_PERMITS_SHIFT).toInt()

它会把用掩码把高位过滤掉,然后把值右移42位,从而得到一个int值。

阻塞线程数量的获取也是一样的,

private inline fun blockingTasks(state: Long): Int = (state and BLOCKING_MASK shr BLOCKING_SHIFT).toInt()

同样是通过掩码获取中间21位的值,然后右移21bit。

至于创建的线程数量就更简单了,直接用掩码就行,不需要用位移

private inline fun createdWorkers(state: Long): Int = (state and CREATED_MASK).toInt()

所以kotlin协程允许创建的线程数是 2 的21次方,比两百万多一点,转成10进制是2097151。

这时候会想到两个问题,超出上限了怎么办?创建这么多线程不会影响性能吗?

第一个问题,在协程的代码中并没有直接回答,并且它也没有针对上述情况的拒绝服务策略或者其他策略逻辑。或者开发团队认为200万的数量已经达到了饱和设计,相比之下程序更可能出现stack overflow?

第二个问题,假设用某种blocking的方式确实创建了200万个阻塞线程,这种情况下是否会影响性能?我们在下面的部分进行分析。

线程管理

线程在Kotlin协程中的类是Worker。所有的Worker在创建完毕后就会进入运行状态,对应Thread类来说就是调用run()方法。Worker实现run的是 runWorker()方法。

private fun runWorker() {
    var rescanned = false
    while (!isTerminated && state != WorkerState.TERMINATED) { //这里是个死循环
        val task = findTask(mayHaveLocalTasks) //获取task的优先级是CPU > IO
        // Task found. Execute and repeat
        if (task != null) {
            rescanned = false
            minDelayUntilStealableTaskNs = 0L
            executeTask(task)
            continue
        } else {
            mayHaveLocalTasks = false
        }
        /*
         * No tasks were found:
         * 1) Either at least one of the workers has stealable task in its FIFO-buffer with a stealing deadline.
         *    Then its deadline is stored in [minDelayUntilStealableTask]
         *
         * Then just park for that duration (ditto re-scanning).
         * While it could potentially lead to short (up to WORK_STEALING_TIME_RESOLUTION_NS ns) starvations,
         * excess unparks and managing "one unpark per signalling" invariant become unfeasible, instead we are going to resolve
         * it with "spinning via scans" mechanism.
         * NB: this short potential parking does not interfere with `tryUnpark`
         */
        if (minDelayUntilStealableTaskNs != 0L) {
            if (!rescanned) {
                rescanned = true //在释放CPU控制权前最后再扫一遍任务队列
            } else {
                rescanned = false
                tryReleaseCpu(WorkerState.PARKING) //确实没有任务可做则释放CPU权限
                interrupted()
                LockSupport.parkNanos(minDelayUntilStealableTaskNs)
                minDelayUntilStealableTaskNs = 0L
            }
            continue
        }
        /*
         * 2) Or no tasks available, time to park and, potentially, shut down the thread.
         * Add itself to the stack of parked workers, re-scans all the queues
         * to avoid missing wake-up (requestCpuWorker) and either starts executing discovered tasks or parks itself awaiting for new tasks.
         */
        tryPark() //完全没有任务的情况下线程会挂起
    }
    tryReleaseCpu(WorkerState.TERMINATED)
}

对于一个Worker来说,它的使命从一开始就是在死循环中不停地扫描并且执行任务。一个Worker的状态有五个,
· CPU_ACTUIRED
· BLOCKING
· PARKING
· DORMANT
· TERMINATED

比较值得留意的是BLOCKING,PARKING。BLOCKING指的是Worker正在执行一个IO密集型的coroutine,此时它已经释放了CPU控制权,此时CPU的core是可以给其他计算密集型的coroutine使用的,具体在Linux内核中断和io有说其中的原理。

PARKING状态,指的是线程已经完成了所有任务,但暂时还没有销毁的必要。所以先挂起。park的挂起,和常说的 Thread.sleep() 和 wait() 的区别在于,park挂起之后CPU可以去干别的事情,而另外两个是在空转。

findTask()函数是协程的线程任务抢占核心逻辑,

fun findTask(scanLocalQueue: Boolean): Task? {
    if (tryAcquireCpuPermit()) return findAnyTask(scanLocalQueue)
    // If we can't acquire a CPU permit -- attempt to find blocking task
    val task = if (scanLocalQueue) {
        localQueue.poll() ?: globalBlockingQueue.removeFirstOrNull()
    } else {
        globalBlockingQueue.removeFirstOrNull()
    }
    return task ?: trySteal(blockingOnly = true)
}

结合 findAnyTask() 一起看的话会更清楚。

private fun findAnyTask(scanLocalQueue: Boolean): Task? {
    /*
     * Anti-starvation mechanism: probabilistically poll either local
     * or global queue to ensure progress for both external and internal tasks.
     */
    if (scanLocalQueue) {
        val globalFirst = nextInt(2 * corePoolSize) == 0
        if (globalFirst) pollGlobalQueues()?.let { return it }
        localQueue.poll()?.let { return it }
        if (!globalFirst) pollGlobalQueues()?.let { return it }
    } else {
        pollGlobalQueues()?.let { return it }
    }
    return trySteal(blockingOnly = false)
}

首先Worker会判断是否占有CPU,有可能此时CPU所有的core都被CPU密集型coroutine占用,那worker在获得当前这个很短的时间片的时机里,就要尽可能快地调度一下IO型coroutine,然后释放CPU时间片。所以在 tryAcquireCpuPermit() 返回false的情况下会走下面的逻辑,注释中说的是没有CPU权限的话,就尝试找阻塞型任务,也就是IO任务。

这时它在localQueue和globalQueue都找不到任务的情况下会用 trySteal 去抢别的线程的 localQueue,因为抢的是IO型任务,所以参数是 true,

trySteal(blockingOnly = true)

反过来看在有CPU权限下的逻辑。它会调用 findAnyTask() 去找任务。在找不到任务的情况下也一样会去 trySteal,不过这时候的参数就是 false了,

trySteal(blockingOnly = false)

意思是这次偷的就不是非得要blocking任务了。但需要注意它的实现并不是一定只偷cpu密集型,它拿回来的任务其实有可能是IO型的。

那如果拿到IO型的怎么办呢?其实很简单,交出CPU权限就行了,在runWorker的下次循环的时候,看看能不能拿回来CPU控制权,拿得到再继续跑CPU密集型任务。

拿到IO密集型的也不慌,下面说说这种情况的处理逻辑。

阻塞协程的调度策略

不管拿到的是IO还是CPU密集型,最后都会走 Worker的 executeTask()函数,

private fun executeTask(task: Task) {
    val taskMode = task.mode
    idleReset(taskMode)
    beforeTask(taskMode)
    runSafely(task)
    afterTask(taskMode)
}

idleReset的逻辑比较有趣,

private fun idleReset(mode: Int) {
    terminationDeadline = 0L // reset deadline for termination
    if (state == WorkerState.PARKING) {
        assert { mode == TASK_PROBABLY_BLOCKING }
        state = WorkerState.BLOCKING
    }
}

从代码可以看出来,一个park状态的Worker能被唤醒,并且走到这里,说明唤醒它的任务一定是个IO型任务,此时可以直接把worker状态切到blocking。这个逻辑比较隐晦,需要慢慢体会。

beforeTask()则是判断这个任务是不是个CPU任务,是的话就不管了。

private fun beforeTask(taskMode: Int) {
    if (taskMode == TASK_NON_BLOCKING) return
    // Always notify about new work when releasing CPU-permit to execute some blocking task
    if (tryReleaseCpu(WorkerState.BLOCKING)) {
        signalCpuWork()
    }
}

如果不是的话,就让当前worker把CPU权限交出去,当然前提是worker持有CPU权限。

internal fun tryReleaseCpu(newState: WorkerState): Boolean {
    val previousState = state
    val hadCpu = previousState == WorkerState.CPU_ACQUIRED
    if (hadCpu) releaseCpuPermit() //如果有CPU权限的话再交
    if (previousState != newState) state = newState
    return hadCpu
}

之后就会调用 signalCpuWork,跟其他线程说现在CPU又有core空出来了,可以去占。

internal fun signalCpuWork() {
    if (tryUnpark()) return
    if (tryCreateWorker()) return
    tryUnpark()
}

这里第一行就是用unPark唤醒Worker,如果唤醒成功就直接返回了。否则看看是不是线程不够,需要创建一个新的线程。

Kotlin协程创建线程的逻辑跟第一部分介绍的 controlState 有关,总的说它用创建线程数-阻塞线程数,得到当前cpu core占用的数量。如果这个数量小于最大核数,那么再创建新线程,否则就返回false。对应上面的signalCpuWork,如果创建线程返回false的话,说明没有core可用了,那就直接看看是不是有park的线程,把它们唤醒就行。

private fun tryCreateWorker(state: Long = controlState.value): Boolean {
    val created = createdWorkers(state)
    val blocking = blockingTasks(state)
    val cpuWorkers = (created - blocking).coerceAtLeast(0)
    /*
     * We check how many threads are there to handle non-blocking work,
     * and create one more if we have not enough of them.
     */
    if (cpuWorkers < corePoolSize) {
        val newCpuWorkers = createNewWorker()
        // If we've created the first cpu worker and corePoolSize > 1 then create
        // one more (second) cpu worker, so that stealing between them is operational
        if (newCpuWorkers == 1 && corePoolSize > 1) createNewWorker()
        if (newCpuWorkers > 0) return true
    }
    return false
}

总结

Kotlin的协程Scheduler里面有非常多精妙的设计,有些逻辑比较隐晦不能够一下子就明白。而且需要大量的知识储备做基础,否则它的设计的核心依赖没法理解。

比如为什么 (created - blocking) < corePoolSize 就可以创建新线程?如果我已经创建了10个线程,8个在执行IO操作,最大核数有4个,为什么还有两个核可以用?如果能体会到这一层才能算真正明白协程吧。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章