Kotlin 協程之線程池探索之旅(與Java線程池PK)

前言

協程系列文章:

上篇文章分析了協程切換到主線程執行的詳細流程,本篇將分析如何切換到子線程執行。
通過本篇文章,你將了解到:

  1. 切換到子線程場景
  2. Dispatchers.Default 分發流程詳解
  3. Dispatchers.IO 分發流程詳解
  4. 與Java 線程池比對
  5. 協程到底在哪個線程執行?

1. 切換到子線程場景

Demo 展示

先看一個最常見的網絡請求Demo:

    fun showStuName() {
        GlobalScope.launch(Dispatchers.Main) {
            var stuInfo = withContext(Dispatchers.IO) {
                //模擬網絡請求
                Thread.sleep(3000)
                "我是小魚人"
            }
            //展示
            Toast.makeText(context, stuInfo, Toast.LENGTH_SHORT).show()
        }
    }

因為是耗時操作,因此需要切換到子線程處理,又因為是網絡請求,屬于I/O操作,因此使用Dispatchers.IO 分發器。

若任務偏向于計算型,比較耗費CPU,可以改寫如下:

    fun dealCpuTask() {
        GlobalScope.launch(Dispatchers.Main) {
            //切換到子線程    
            withContext(Dispatchers.Default) {
                var i = 0
                val count = 100000
                while(i < count) {
                    Thread.sleep(1)
                }
            }
        }
    }

Dispatchers.IO/Dispatchers.Default 異同

兩者都是協程分發器,Dispatchers.IO 側重于任務本身是阻塞型的,比如文件、數據庫、網絡等操作,此時是不怎么占用CPU的。而Dispatchers.Default 側重于計算型的任務,可能會長時間占用CPU。
協程線程池在設計的時候,針對兩者在線程的調度策略上有所不同。


image.png

2. Dispatchers.Default 分發流程詳解

任務分發

以上面的Demo 為例,從源碼角度分析分發流程。
從前面的文章很容易了解到:withContext()函數里構造了DispatchedContinuation,它本身也是個Runnable,通過:

//this 指DispatchedContinuation 本身
dispatcher.dispatch(context, this)

進行分發。
而dispatcher 就是分發器,我們這里用的是Dispatchers.Default,因此先來看看它的實現。

#Dispatchers.kt
actual object Dispatchers {
    @JvmStatic
    actual val Default: CoroutineDispatcher = createDefaultDispatcher()
    @JvmStatic
    public val IO: CoroutineDispatcher = DefaultScheduler.IO
    @JvmStatic
    public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher
}

可以看出Dispatchers 是個單例。

#CoroutineContext.kt
//useCoroutinesScheduler 默認為true
//使用DefaultScheduler
internal actual fun createDefaultDispatcher(): CoroutineDispatcher =
    if (useCoroutinesScheduler) DefaultScheduler else CommonPool

#Dispatcher.kt
internal object DefaultScheduler : ExperimentalCoroutineDispatcher() {
    //定義IO 分發器
    //...
}

DefaultScheduler 也是個單例,內容不多,其功能實現還得繼續往上看。
ExperimentalCoroutineDispatcher 定義如下:

#Dispatcher.kt
open class ExperimentalCoroutineDispatcher(
    //核心線程數
    private val corePoolSize: Int,
    //最大線程個數
    private val maxPoolSize: Int,
    //空閑線程的存活時間
    private val idleWorkerKeepAliveNs: Long,
    //線程名前綴
    private val schedulerName: String = "CoroutineScheduler"
) : ExecutorCoroutineDispatcher() {
    constructor(
        //初始化參數的值
        corePoolSize: Int = CORE_POOL_SIZE,
        maxPoolSize: Int = MAX_POOL_SIZE,
        schedulerName: String = DEFAULT_SCHEDULER_NAME
    ) : this(corePoolSize, maxPoolSize, IDLE_WORKER_KEEP_ALIVE_NS, schedulerName)
    
    //真正的線程池實現
    private var coroutineScheduler = createScheduler()
    //分發
    override fun dispatch(context: CoroutineContext, block: Runnable): Unit =
        try {
            //分發實現
            coroutineScheduler.dispatch(block)
        } catch (e: RejectedExecutionException) {
            //...
        }
}

//真正的線程池實現為:CoroutineScheduler
private fun createScheduler() = CoroutineScheduler(corePoolSize, maxPoolSize, idleWorkerKeepAliveNs, schedulerName)

查看CoroutineScheduler.dispatch()函數:

//block 為DispatchedContinuation
fun dispatch(block: Runnable, taskContext: TaskContext = NonBlockingContext, tailDispatch: Boolean = false) {
    //構建Task對象,block 本身就是Task類型
    val task = createTask(block, taskContext)
    //當前線程是否是Worker類型,也就是說當前線程是否是線程池內的線程
    val currentWorker = currentWorker()//①
    //如果是,則嘗試提交任務到本地隊列,否則返回任務本身
    val notAdded = currentWorker.submitToLocalQueue(task, tailDispatch)//②
    if (notAdded != null) {
        //如果沒有提交到本地隊列,則提交到全局隊列 
        if (!addToGlobalQueue(notAdded)) {//③
            //添加隊列失敗則拋出異常
            throw RejectedExecutionException("$schedulerName was terminated")
        }
    }
    //是否需要跳過喚醒線程,主要用在IO分發器
    val skipUnpark = tailDispatch && currentWorker != null
    if (task.mode == TASK_NON_BLOCKING) {//④
        if (skipUnpark) return
        //非阻塞任務,喚醒cpu 線程
        signalCpuWork()//⑤
    } else {
        //阻塞任務,喚醒blocking 線程
        signalBlockingWork(skipUnpark = skipUnpark)//⑥
    }
}

這函數是分發核心,注釋里標明了6個點,現在一一闡述:

    private fun currentWorker(): Worker? = (Thread.currentThread() as? Worker)?.takeIf { it.scheduler == this }

Worker 本身是繼承自Thread 的,因此Worker 是線程類,代表線程池里的線程。通過判斷是否是Worker類型來確認當前線程是否屬于線程池內的線程。

private fun CoroutineScheduler.Worker?.submitToLocalQueue(task: Task, tailDispatch: Boolean): Task? {
    //Worker 為空,直接返回任務本身
    if (this == null) return task
    //非阻塞的任務,則直接返回
    if (task.mode == TASK_NON_BLOCKING && state === CoroutineScheduler.WorkerState.BLOCKING) {
        return task
    }
    //表示本地隊列里存有任務了
    mayHaveLocalTasks = true
    //加入到本地隊列里
    //localQueue 為Worker的成員變量
    return localQueue.add(task, fair = tailDispatch)
}


若是②沒有成功加入到本地隊列里,則嘗試加入到全局隊列里:

private fun addToGlobalQueue(task: Task): Boolean {
    return if (task.isBlocking) {
        //加入到阻塞隊列
        globalBlockingQueue.addLast(task)
    } else {
        //加入到cpu隊列
        globalCpuQueue.addLast(task)
    }
}

結合②③分析,目前為止,出現了三個隊列:


image.png


主要用于判斷任務是阻塞還是非阻塞的,這在任務構造的時候就已經指定,若是使用Dispatchers.Default 分發器,那么構造的任務是非阻塞的,而使用Dispatchers.IO,則構造的任務是阻塞的。


⑤⑥ 是針對阻塞與否進行不同的處理。

fun signalCpuWork() {
    //嘗試去喚醒正在掛起的線程,若是有線程可以被喚醒,則無需創建新線程
    if (tryUnpark()) return
    //若喚醒不成功,則需要嘗試創建線程
    if (tryCreateWorker()) return
    //再試一次,邊界條件
    tryUnpark()
}

tryUnpark()函數主要作用是從棧里取出掛起的線程(Worker),入棧的的時機是當線程沒有任務可以處理時進行掛起,此時會記錄在棧里。
重點是tryCreateWorker()函數:

private fun tryCreateWorker(state: Long = controlState.value): Boolean {
    //獲取當前已經創建的線程數
    val created = createdWorkers(state)
    //獲取當前阻塞的任務數
    val blocking = blockingTasks(state)
    //已創建的線程數-阻塞的任務數=非阻塞的線程數
    //coerceAtLeast(0) 表示結果至少是0
    val cpuWorkers = (created - blocking).coerceAtLeast(0)
    //如果非阻塞數小于核心線程數
    if (cpuWorkers < corePoolSize) {
        //創建線程
        val newCpuWorkers = createNewWorker()
        //如果當前只有一個非阻塞線程并且核心線程數>1,那么再創建一個線程
        //目的是為了方便"偷"任務...
        if (newCpuWorkers == 1 && corePoolSize > 1) createNewWorker()
        //創建成功
        if (newCpuWorkers > 0) return true
    }
    return false
}

創建新線程為什么與阻塞任務的多少關聯呢?
簡單舉個例子:

  • 現在若是已經創建了5個線程,而這幾個線程都在執行IO任務,此時就需要再創建新的線程來執行任務,因為此時CPU是空閑的。
  • 只要非阻塞任務的個數小于核心線程數,那么就需要創建新的線程,目的是為了充分利用CPU。

再看createNewWorker() 是如何創建新的線程(Worker)的。

private fun createNewWorker(): Int {
    //workers 為Worker 數組,因為需要對數組進行add 操作,因此需要同步訪問
    synchronized(workers) {
        if (isTerminated) return -1
        val state = controlState.value
        //獲取已創建的線程數
        val created = createdWorkers(state)
        //阻塞的任務數
        val blocking = blockingTasks(state)
        //非阻塞的線程數
        val cpuWorkers = (created - blocking).coerceAtLeast(0)
        //非阻塞的線程數不能超過核心線程數
        if (cpuWorkers >= corePoolSize) return 0
        //已創建的線程數不能大于最大線程數
        if (created >= maxPoolSize) return 0
        val newIndex = createdWorkers + 1
        require(newIndex > 0 && workers[newIndex] == null)
        //構造線程
        val worker = Worker(newIndex)
        //記錄到數組里
        workers[newIndex] = worker
        //記錄創建的線程數
        require(newIndex == incrementCreatedWorkers())
        //開啟線程
        worker.start()
        //當前非阻塞線程數
        return cpuWorkers + 1
    }
}


signalBlockingWork()函數調用時會記錄阻塞的任務數,其它與signalCpuWork 一致。

至此,Dispatchers.Default 任務分發流程已經結束,其重點:

  • 構造任務,添加到隊列里(三個隊列中選一個)。
  • 喚醒掛起的線程或是創建新的線程。

任務執行

既然任務都提交到隊列了,該線程出場執行任務了。

internal inner class Worker private constructor() : Thread() {}

Worker 創建并啟動后,將會執行run()函數:

override fun run() = runWorker()
private fun runWorker() {
    var rescanned = false
    //一直查找,除非worker終止了
    while (!isTerminated && state != CoroutineScheduler.WorkerState.TERMINATED) {
        //從隊列里尋找任務
        //mayHaveLocalTasks:本地隊列里是否有任務
        val task = findTask(mayHaveLocalTasks) //①
        if (task != null) {
            rescanned = false
            minDelayUntilStealableTaskNs = 0L
            //任務獲取到后,執行任務
            executeTask(task)//②
            //任務執行完畢,繼續循環查找任務
            continue
        } else {
            mayHaveLocalTasks = false
        }
        if (minDelayUntilStealableTaskNs != 0L) {
            //延遲一會兒,再去偷
            if (!rescanned) {
                rescanned = true
            } else {
                //掛起一段時間
            }
            continue
        }
        //嘗試掛起
        tryPark()//③
    }
    //釋放token
    tryReleaseCpu(CoroutineScheduler.WorkerState.TERMINATED)
}

同樣的,標注了3個重點,一一分析之。


findTask()顧名思義:找任務。
傳入的參數表示是否掃描本地隊列,若是之前有提交任務到本地隊列,則此處mayHaveLocalTasks = true。

fun findTask(scanLocalQueue: Boolean): Task? {
    //嘗試獲取cpu 許可
    //若是拿到cpu 許可,則可以執行任何任務
    if (tryAcquireCpuPermit()) return findAnyTask(scanLocalQueue)
    //拿不到,若是本地隊列有任務,則從本地取,否則從全局阻塞隊列取
    val task = if (scanLocalQueue) {
        localQueue.poll() ?: globalBlockingQueue.removeFirstOrNull()
    } else {
        globalBlockingQueue.removeFirstOrNull()
    }
    //都拿不到,則偷別人的
    return task ?: trySteal(blockingOnly = true)
}

private fun findAnyTask(scanLocalQueue: Boolean): Task? {
    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)
}

此處解釋一下獲取cpu 許可的含義:

它和核心線程數相關,假設我們是8核CPU,那么同一時間最多只能有8個線程在CPU上執行。因此,若是其它線程想要執行非阻塞任務(占用CPU),需要申請許可(token),申請成功說明有CPU空閑,此時該線程可以執行非阻塞任務。否則,只能執行阻塞任務。

當從本地隊列、全局隊列里都沒找出任務時,當前的Worker打起了別個Woker的主意。我們知道全局隊列是所有Worker共享,而本地隊列是每個Worker私有的。因此,當前Worker發現自己沒任務可以執行的時候會去看看其它Worker的本地隊列里是否有可以執行的任務,若是有就可以偷過來用。
看看如何偷的:

private fun trySteal(blockingOnly: Boolean): Task? {
    //自己本地沒有才能偷
    kotlinx.coroutines.assert { localQueue.size == 0 }
    //所有的已創建的workers個數
    val created = createdWorkers
    //遍歷workers數組
    repeat(created) {
        ++currentIndex
        if (currentIndex > created) currentIndex = 1
        val worker = workers[currentIndex]
        if (worker !== null && worker !== this) {
            //從別的worker里的本地隊列偷到自己的本地隊列
            val stealResult = if (blockingOnly) {
                localQueue.tryStealBlockingFrom(victim = worker.localQueue)
            } else {
                localQueue.tryStealFrom(victim = worker.localQueue)
            }
            //偷成功,則取出任務
            if (stealResult == TASK_STOLEN) {
                return localQueue.poll()
            } else if (stealResult > 0) {
                minDelay = min(minDelay, stealResult)
            }
        }
    }
    //...沒偷到
    return null
}

實際上偷的本質是:

從別人的本隊隊列里取出任務放到自己的本地隊列,最后取出任務返回。


拿到任務后,就開始執行任務。

private fun executeTask(task: Task) {
    //模式:阻塞/非阻塞
    val taskMode = task.mode
    idleReset(taskMode)
    //當前任務是非阻塞任務,則嘗試釋放cpu token,并執行signalCpuWork
    beforeTask(taskMode)
    //真正執行任務
    runSafely(task)
    //修改狀態
    afterTask(taskMode)
}
fun runSafely(task: Task) {
    try {
        //task 其實就是DispatchedContinuation
        task.run()
    } catch (e: Throwable) {
        //..
    } finally {
        unTrackTask()
    }
}

此時線程正式執行任務了。


若是線程沒有找到任何任務執行,則嘗試掛起。

private fun tryPark() {
    //沒有在掛起棧里
    if (!inStack()) {
        //將worker放入掛起棧里
        parkedWorkersStackPush(this)
        return
    }
    //...
    while (inStack() && workerCtl.value == CoroutineScheduler.PARKED) { // Prevent spurious wakeups
        if (isTerminated || state == CoroutineScheduler.WorkerState.TERMINATED) break
        //...
        //真正掛起,并標記worker state 狀態
        park()
    }
}

最后一步的park()里會修改state = WorkerState.TERMINATED,在最外層的循環里會判斷該標記,若是終止了,則循環停止,整個線程執行結束。

至此,任務執行流程結束,其重點:

  1. 從全局隊列、本地隊列里查找任務。
  2. 若是沒找到,則嘗試從別的Worker 本地隊列里偷取任務。
  3. 1、2 能夠找到任務則執行Runnable.run()函數,該函數里最終會執行協程體里的代碼。
  4. 若是沒有任務,則根據策略掛起一段時間或是最終退出線程的執行。

結合任務分發與任務執行流程,有如下流程圖:


image.png

3. Dispatchers.IO 分發流程詳解

Dispatchers.IO 定義

internal object DefaultScheduler : ExperimentalCoroutineDispatcher() {
    //創建LimitingDispatcher
    val IO: CoroutineDispatcher = LimitingDispatcher(
        this,
        systemProp(IO_PARALLELISM_PROPERTY_NAME, 64.coerceAtLeast(AVAILABLE_PROCESSORS)),
        "Dispatchers.IO",
        TASK_PROBABLY_BLOCKING
    )
    //...
}

Dispatchers.IO 作為DefaultScheduler 里的成員變量,并且它的分發器使用的是DefaultScheduler 本身。
構造函數里指明了并行的數量限制,以及它屬于TASK_PROBABLY_BLOCKING(阻塞任務)。

任務分發

private fun dispatch(block: Runnable, tailDispatch: Boolean) {
    var taskToSchedule = block
    while (true) {
        //記錄在等待執行的任務
        val inFlight = inFlightTasks.incrementAndGet()
        
        //如果小于并行數
        if (inFlight <= parallelism) {
            //直接分發 dispatcher= DefaultScheduler
            dispatcher.dispatchWithContext(taskToSchedule, this, tailDispatch)
            return
        }
        //等待執行的任務超過并行數,則加入到隊列里
        queue.add(taskToSchedule)
        
        //碰運氣,看是否有任務釋放了
        if (inFlightTasks.decrementAndGet() >= parallelism) {
            return
        }
        //若釋放了,則取出隊列里的任務執行
        taskToSchedule = queue.poll() ?: return
    }
}

可以看出Dispatchers.IO 任務分發是借助于DefaultScheduler,也就是Dispatchers.Default的能力,因此兩者是共用一個線程池。
只是Dispatchers.IO 比較特殊,它有個隊列,該隊列作用:

當IO 任務分派個數超過設定的并行數時,不會直接進行分發,而是先存放在隊列里。

那它什么時候取出來呢?
當任務執行完畢,也就是DispatchedTask.run()函數執行完畢后會調用:
taskContext.afterTask(),來看它的實現:

override fun afterTask() {
    //從隊列里取出
    var next = queue.poll()
    if (next != null) {
        //繼續分發
        dispatcher.dispatchWithContext(next, this, true)
        return
    }

    inFlightTasks.decrementAndGet()
    //...
}

舉個簡單例子:

假設現在最大的并行數是64,線程池分配了64個線程執行IO任務,當第65個任務到來之時,因為超出了64,因此會放入隊列里。當64個任務有某個任務執行完畢后,會從隊列里取出第65個任務進行分發。

這樣做的目的是什么呢?

為了限制突然間創建了許多IO線程,浪費資源,因此在線程池之外再加了一層防護,多出的任務先進入緩沖隊列。

4. 與Java 線程池比對

使用過Java 線程池的小伙伴可能會知道,Java 線程池與Kotlin協程池 本質上都是:"池化技術的體現”。
它們的優勢:

  1. 減少線程頻繁開啟/關閉的資源消耗。
  2. 及時響應并執行任務。
  3. 較好地管控/監控 應用內的線程使用。

Java 線程池原理:

  1. 核心線程+隊列+非核心線程。
  2. 首先使用核心線程執行任務,若是核心線程個數已滿,則將任務加入到隊列里,核心線程從隊列里取出任務執行,若是隊列已滿,則再開啟非核心線程執行任務。

更詳細的Java 線程池原理與使用請移步:Java 線程池之必懂應用-原理篇(上)

協程線程池原理:

  1. 全局隊列(阻塞+非阻塞)+ 本地隊列。
  2. IO 任務分發還有個緩存隊列。
  3. 線程從隊列里尋找任務(包括偷)并執行,若是使用IO 分發器,則超出限制的任務將會放到緩存隊列里。

兩者區別:

  • Java 線程池開放API,比較靈活,調用者可以根據不同的需求組合不同形式的線程池,沒有區分任務的特點(阻塞/非阻塞)。
  • 協程線程池專供協程使用,區分任務特點,進而進行更加合理的調度。

5. 協程到底在哪個線程執行?

回到我們上篇末尾的問題:

    fun launch3() {
        GlobalScope.launch(Dispatchers.IO) {
            println("1>>>${Thread.currentThread()}")
            withContext(Dispatchers.Default) {
                println("2>>>${Thread.currentThread()}")
                delay(2000)
                println("3>>>${Thread.currentThread()}")
            }
            println("4>>>${Thread.currentThread()}")
        }
    }

理解了線程池原理,答案就呼之欲出了。
1、4 可能不在同一線程。
2、3 可能不在同一線程。
1、2 可能在同一線程。

看到這結果,你可能會覺得:廢話!
容我解釋:因為線程池本身的調度側重于執行任務,而非使用哪個特定的線程執行,因此具體分派到哪個線程執行需要看哪個線程剛好拿到了任務。

下篇將分析協程的取消與異常處理,敬請關注。

本文基于Kotlin 1.5.3,文中完整Demo請點擊

您若喜歡,請點贊、關注、收藏,您的鼓勵是我前進的動力

持續更新中,和我一起步步為營系統、深入學習Android/Kotlin

1、Android各種Context的前世今生
2、Android DecorView 必知必會
3、Window/WindowManager 不可不知之事
4、View Measure/Layout/Draw 真明白了
5、Android事件分發全套服務
6、Android invalidate/postInvalidate/requestLayout 徹底厘清
7、Android Window 如何確定大小/onMeasure()多次執行原因
8、Android事件驅動Handler-Message-Looper解析
9、Android 鍵盤一招搞定
10、Android 各種坐標徹底明了
11、Android Activity/Window/View 的background
12、Android Activity創建到View的顯示過
13、Android IPC 系列
14、Android 存儲系列
15、Java 并發系列不再疑惑
16、Java 線程池系列
17、Android Jetpack 前置基礎系列
18、Android Jetpack 易懂易學系列
19、Kotlin 輕松入門系列
20、Kotlin 協程系列全面解讀

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容