前言
協程系列文章:
上篇文章分析了協程切換到主線程執行的詳細流程,本篇將分析如何切換到子線程執行。
通過本篇文章,你將了解到:
- 切換到子線程場景
- Dispatchers.Default 分發流程詳解
- Dispatchers.IO 分發流程詳解
- 與Java 線程池比對
- 協程到底在哪個線程執行?
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。
協程線程池在設計的時候,針對兩者在線程的調度策略上有所不同。
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)
}
}
結合②③分析,目前為止,出現了三個隊列:
④
主要用于判斷任務是阻塞還是非阻塞的,這在任務構造的時候就已經指定,若是使用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,在最外層的循環里會判斷該標記,若是終止了,則循環停止,整個線程執行結束。
至此,任務執行流程結束,其重點:
- 從全局隊列、本地隊列里查找任務。
- 若是沒找到,則嘗試從別的Worker 本地隊列里偷取任務。
- 1、2 能夠找到任務則執行Runnable.run()函數,該函數里最終會執行協程體里的代碼。
- 若是沒有任務,則根據策略掛起一段時間或是最終退出線程的執行。
結合任務分發與任務執行流程,有如下流程圖:
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協程池 本質上都是:"池化技術的體現”。
它們的優勢:
- 減少線程頻繁開啟/關閉的資源消耗。
- 及時響應并執行任務。
- 較好地管控/監控 應用內的線程使用。
Java 線程池原理:
- 核心線程+隊列+非核心線程。
- 首先使用核心線程執行任務,若是核心線程個數已滿,則將任務加入到隊列里,核心線程從隊列里取出任務執行,若是隊列已滿,則再開啟非核心線程執行任務。
更詳細的Java 線程池原理與使用請移步:Java 線程池之必懂應用-原理篇(上)
協程線程池原理:
- 全局隊列(阻塞+非阻塞)+ 本地隊列。
- IO 任務分發還有個緩存隊列。
- 線程從隊列里尋找任務(包括偷)并執行,若是使用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 協程系列全面解讀