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個,爲什麼還有兩個核可以用?如果能體會到這一層才能算真正明白協程吧。

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