來吧!接受Kotlin 協程--線程池的7個靈魂拷問
前言
之前有分析過協程裏的線程池的原理:Kotlin 協程之線程池探索之旅(與Java線程池PK),當時偏重於整體原理,對於細節之處並沒有過多的着墨,後來在實際的使用過程中遇到了些問題,也引發了一些思考,故記錄之。
通過本篇文章,你將瞭解到:
- 為什麼要設計Dispatchers.Default和Dispatchers.IO?
- Dispatchers.Default 是如何調度的?
- Dispatchers.IO 是如何調度的?
- 線程池是如何調度任務的?
- 據説Dispatchers.Default 任務會阻塞?該怎麼辦?
- 線程的生命週期是如何確定?
- 如何更改線程池的默認配置?
1. 為什麼要設計Dispatchers.Default和Dispatchers.IO?
一則小故事
書接上篇:一個小故事講明白進程、線程、Kotlin 協程到底啥關係?
出場人物:
操作系統,簡稱OS
Java
Kotlin
在Java的世界裏支持多線程編程,開啟一個線程的方式很簡單:
java
private void startNewThread() {
new Thread(()->{
//線程體
//我在子線程執行...
}).start();
}
而Java也是按照此種方式創建線程執行任務。
某天,OS找到Java説到:"你最近的線程創建、銷燬有點頻繁,我這邊切換線程的上下文是要做準備和善後工作的,有一定的代價,你看怎麼優化一下?"
Java無辜地答到:"我也沒辦法啊,業務就是那麼多,需要隨時開啟線程做支撐。"
OS不悦:"你最近態度有點消極啊,説到問題你都逃避,我理解你業務複雜,需要開線程,但沒必要頻繁開啟關閉,甚至有些線程就執行了一會就關閉,而後又立馬開啟,這不是玩我嗎?。這問題必須解決,不然你的KPI我沒法打,你回去儘快想想給個方案出來。"
Java悻悻然:"好的,老大,我儘量。"
Java果然不愧是編程界的老手,很快就想到了方案,他興沖沖地找到OS彙報:"我想到了一個絕佳的方案:建立一個線程池,固定開啟幾個線程,有任務的時候往線程池裏的任務隊列扔就完事了,線程池會找到已提交的任務進行執行。當執行完單個任務之後,線程繼續查找任務隊列,如果沒有任務執行的話就睡眠等待,等有任務過來的時候通知線程起來繼續幹活,這樣一來就不用頻繁創建與銷燬線程了,perfect!"
OS撫掌誇讚:"池化技術,這才是我認識的Java嘛,不過線程也無需一直存活吧?"
Java:"這塊我早有應對之策,線程池可以提供給外部接口用來控制線程空閒的時間,如果超過這時間沒有任務執行,那就辭退它(銷燬),我們不養閒人!"
OS滿意點點頭:"該方案,我準了,細節之處你再完善一下。"
經過一段時間的優化,Java線程池框架已經比較穩定了,大家相安無事。
某天,OS又把Java叫到辦公室:"你最近提交的任務都是很吃CPU,我就只有8個CPU,你核心線程數設置為20個,剩餘的12個根本沒機會執行,白白創建了它們。"
Java沉吟片刻道:"這個簡單,針對計算密集型的任務,我把核心線程數設置為8就好了。"
OS略微思索:"也不失為一個辦法,先試試吧,看看效果再説。"
過了幾天,OS又召喚了Java,面帶失望地道:"這次又是另一個問題了,最近提交的任務都不怎麼吃CPU,基本都是IO操作,其它計算型任務又得不到機會執行,CPU天天在摸魚。"
Java理所當然道:"是呀,因為設置的核心線程數是8,被IO操作的任務佔用了,同樣的方式對於這種類型任務把核心線程數提高一些,比如為CPU核數的2倍,變為16,這樣即使其中一些任務佔用了線程,還剩下其它線程可以執行任務,一舉兩得。"
OS來回踱步,思考片刻後大聲道:"不對,你這麼設置萬一提交的任務都是計算密集型的咋辦?又回到原點了,不妥不妥。"
Java似乎早料到OS有此疑問,無奈道:”沒辦法啊,我只有一個參數設置核心線程,線程池裏本身不區分是計算密集型還是IO阻塞任務,魚和熊掌不可兼得。"
OS怒火中燒,整準備拍桌子,在這關鍵時刻,辦公室的門打開了,翩翩然進來的是Kotlin。
Kotlin看了Java一眼,對OS説到:"我已經知道兩位大佬的擔憂,食君俸祿,與君分憂,我這裏剛好有一計策,解君燃眉之急。"
OS欣喜道:"小K,你有何妙計,速速道來。“
Kotlin平息了一下激動的內心:"我計策説起來很簡單,在提交任務的時候指定其是屬於哪種類型的任務,比如是計算型任務,則選擇Dispatchers.Default,若是IO型任務則選擇Dispatchers.IO,這樣調用者就不用關注其它的細節了。"
Java説到:"這策略我不是沒有想到,只是擔憂越靈活可能越不穩定。"
OS打斷他説:"先讓小K完整説一下實現過程,下來你倆仔細對一下方案,揚長避短,吃一塹長一智,這次務必要充分考慮到各種邊界情況。"
Java&Kotlin:"好的,我們下來排期。"
故事講完,言歸正傳。
2. Dispatchers.Default 是如何調度的?
Dispatchers.Default 使用
kotlin
GlobalScope.launch(Dispatchers.Default) {
println("我是計算密集型任務")
}
開啟協程,指定其運行的任務類型為:Dispatchers.Default。
此時launch函數閉包裏的代碼將在線程池裏執行。
Dispatchers.Default 用在計算密集型的任務場景裏,此種任務比較吃CPU。
Dispatchers.Default 原理
概念約定
在解析原理之前先約定一個概念,如下代碼:
kotlin
GlobalScope.launch(Dispatchers.Default) {
println("我是計算密集型任務")
Thread.sleep(20000000)
}
在任務裏執行線程的睡眠操作,此時雖然線程處於掛起狀態,但它還沒執行完任務,在線程池裏的狀態我們認為是忙碌的。
再看如下代碼:
kotlin
GlobalScope.launch(Dispatchers.Default) {
println("我是計算密集型任務")
Thread.sleep(2000)
println("任務執行結束")
}
當任務執行結束後,線程繼續查找任務隊列的任務,若沒有任務可執行則進行掛起操作,在線程池裏的狀態我們認為是空閒的。
調度原理
注:此處忽略了本地隊列的場景
由上圖可知:
- launch(Dispatchers.Default) 作用是創建任務加入到線程池裏,並嘗試通知線程池裏的線程執行任務
- launch(Dispatchers.Default) 執行並不耗時
3. Dispatchers.IO 是如何調度的?
直接看圖:
很明顯地看出和Dispatchers.Default的調度很相似,其中標藍的流程是重點的差異之處。
結合Dispatchers.Default和Dispatchers.IO調度流程可知影響任務執行的步驟有兩個:
- 線程池是否有空閒的線程
- 創建新線程是否成功
我們先分析第2點,從源碼裏尋找答案: ```kotlin #CoroutineScheduler private fun tryCreateWorker(state: Long = controlState.value): Boolean { //線程池已經創建並且還在存活的線程總數 val created = createdWorkers(state) //當前IO類型的任務數 val blocking = blockingTasks(state) //剩下的就是計算型的線程個數 val cpuWorkers = (created - blocking).coerceAtLeast(0)
//如果計算型的線程個數小於核心線程數,説明還可以再繼續創建
if (cpuWorkers < corePoolSize) {
//創建線程,並返回新的計算型線程個數
val newCpuWorkers = createNewWorker()
//滿足條件,再創建一個線程,方便偷任務
if (newCpuWorkers == 1 && corePoolSize > 1) createNewWorker()
//創建成功
if (newCpuWorkers > 0) return true
}
//創建失敗
return false
}
```
怎麼去理解以上代碼的邏輯呢?舉個例子:
假設核心線程數為8,初始時創建了8個Default線程,並一直保持忙碌。
此時分別使用Dispatchers.Default 和 Dispatchers.IO提交任務,看看有什麼效果。
- Dispatchers.Default 提交任務,此時線程池裏所有任務都在忙碌,於是嘗試創建新的線程,而又因為當前計算型的線程數=8,等於核心線程數,此時不能創建新的線程,因此該任務暫時無法被線程執行
- Dispatchers.IO 提交任務,此時線程池裏所有任務都在忙碌,於是嘗試創建新的線程,而當前阻塞的任務數為1,當前線程池所有線程個數為8,因此計算型的線程數為 8-1=7,小於核心線程數,最後可以創建新的線程用以執行任務
這也是兩者的最大差異,因為對於計算型(非阻塞)的任務,很佔CPU,即使分配再多的線程,CPU沒有空閒去執行這些線程也是白搭,而對於IO型(阻塞)的任務,不怎麼佔CPU,因此可以多開幾個線程充分利用CPU性能。
4. 線程池是如何調度任務的?
不論是launch(Dispatchers.Default) 還是launch(Dispatchers.IO) ,它們的目的是將任務加入到隊列並嘗試喚醒線程或是創建新的線程,而線程尋找並執行任務的功能並不是它們完成的,這就涉及到線程池調度任務的功能。
線程池裏的每個線程都會經歷上圖流程,我們很容易得出結論:
- 只有獲得cpu許可的線程才能執行計算型任務,而cpu許可的個數就是核心線程數
- 如果線程沒有找到可執行的任務,那麼線程將會進入掛起狀態,此時線程即為空閒狀態
- 當線程再次被喚醒後,會判斷是否已經被終止,若是則退出,此時線程就銷燬了
處在空閒狀態的線程被喚醒有兩種可能:
- 線程掛起的時間到了
- 掛起的過程中,有新的任務加入到線程池裏,此時將會喚醒線程
5. 據説Dispatchers.Default 任務會阻塞?該怎麼辦?
在瞭解了線程池的任務分發與調度之後,我們對線程池的核心功能有了一個比較全面的認識。
接着來看看實際的應用,先看Demo:
假設我們的設備有8核。
先開啟8個計算型任務:
kotlin
binding.btnStartThreadMultiCpu.setOnClickListener {
repeat(8) {
GlobalScope.launch(Dispatchers.Default) {
println("cpu multi...${multiCpuCount++}")
Thread.sleep(36000000)
}
}
}
每個任務裏線程睡眠了很長時間。
從打印可以看出,8個任務都得到了執行,且都在不同的線程裏執行。
此時再次開啟一個計算型任務:
kotlin
var singleCpuCount = 1
binding.btnStartThreadSingleCpu.setOnClickListener {
repeat(1) {
GlobalScope.launch(Dispatchers.Default) {
println("cpu single...${singleCpuCount++}")
Thread.sleep(36000000)
}
}
}
先猜測一下結果?
答案是沒有任何打印,新加入的任務沒有得到執行。
既然計算型任務無法得到執行,那我們嘗試換為IO任務:
kotlin
var singleIoCount = 1
binding.btnStartThreadSingleIo.setOnClickListener {
repeat(1) {
GlobalScope.launch(Dispatchers.IO) {
println("io single...${singleIoCount++}")
Thread.sleep(10000)
}
}
}
這次有打印了,説明IO任務得到了執行,並且是新開的線程。
這是為什麼呢?
- 計算密集型任務能分配的最大線程數為核心的線程數(默認為CPU核心個數,比如我們的實驗設備上是8個),若之前的核心線程數都處在忙碌,新開的任務將無法得到執行
- IO型任務能開的線程默認為64個,只要沒有超過64個並且沒有空閒的線程,那麼就一直可以開闢新線程執行新任務
這也給了我們一個啟示:Dispatchers.Default 不要用來執行阻塞的任務,它適用於執行快速的、計算密集型的任務,比如循環、又比如計算Bitmap等。
6. 線程的生命週期是如何確定?
是什麼決定了線程能夠掛起,又是什麼決定了它喚醒後的動作?
先從掛起説起,當線程發現沒有任務可執行後,它會經歷如下步驟:
重點在於線程被喚醒後確定是哪種場景下被喚醒的,判斷方式也很簡單:
線程掛起時設定了掛起的結束時間點,當線程喚醒後檢查當前時間有沒有達到結束時間點,若沒有,則説明被新加入的任務動作喚醒的
即使是沒有了任務執行,若是當前線程數小於核心線程數,那麼也無需銷燬線程,繼續等待任務的到來即可。
7. 如何更改線程池的默認配置?
上面幾個小結涉及到核心線程數,線程掛起時間,最大線程數等,這些參數在Java提供的線程池裏都可以動態配置,靈活度很高,而Kotlin裏的線程池比較封閉,沒有提供額外的接口進行配置。
不過好在我們可以通過設置系統參數來解決這問題。
比如你可能覺得核心線程數為cpu的個數配置太少了,想增加這數量,這想法完全是可以實現的。
先看核心線程數從哪獲取的。
kotlin
internal val CORE_POOL_SIZE = systemProp(
//從這個屬性裏取值
"kotlinx.coroutines.scheduler.core.pool.size",
AVAILABLE_PROCESSORS.coerceAtLeast(2),//默認為cpu的個數
minValue = CoroutineScheduler.MIN_SUPPORTED_POOL_SIZE//最小值為1
)
若是我們沒有設置"kotlinx.coroutines.scheduler.core.pool.size"屬性,那麼將取到默認值,比如現在大部分是8核cpu,那麼CORE_POOL_SIZE=8。
若要修改,則在線程池啟動之前,設置屬性值:
kotlin
System.setProperty("kotlinx.coroutines.scheduler.core.pool.size", "20")
設置為20,此時我們再按照第5小結的Demo進行測試,就會發現Dispatchers.Default 任務不會阻塞。
當然,你覺得IO任務配置的線程數太多了(默認64),想要降低,則修改屬性如下:
kotlin
System.setProperty("kotlinx.coroutines.io.parallelism", "40")
其它參數也可依此定製,不過若沒有強烈的意願,建議遵守默認配置。
通過以上的7個問題的分析與解釋,相比大家都比較瞭解線程池的原理以及使用了,那麼趕緊使用Kotlin線程池來規範線程的使用吧,使用得當可以提升程序運行效率,減少OOM發生。
本文基於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 協程系列全面解讀
- 來吧!接受Kotlin 協程--線程池的7個靈魂拷問
- Kotlin Flow啊,你將流向何方?
- Kotlin 協程 Select:看我如何多路複用
- Kotlin 協程調度切換線程是時候解開真相了
- 講真,Kotlin 協程的掛起沒那麼神祕(原理篇)
- 講真,Kotlin 協程的掛起沒那麼神祕(故事篇)
- 少年,你可知 Kotlin 協程最初的樣子?
- 一個小故事講明白進程、線程、Kotlin 協程到底啥關係?
- Kotlin 高階函數從未如此清晰(中)
- Kotlin 高階函數從未如此清晰(上)
- Android 容易遺漏的刷新小細節
- Jetpack ViewModel 抽絲剝繭
- Jetpack LiveData 是時候瞭解一下了
- Jetpack Lifecycle 該怎麼看?還肝否?
- Android Activity 與View 的互動思考
- 數字簽名/數字證書/對稱/非對稱加密/CA 等概念明晰
- Android IPC 之AIDL應用(下)
- Android IPC 之Messenger 原理及應用
- Android clipToPadding 使用與疑難點解析
- Android invalidate/postInvalidate/requestLayout 徹底釐清