來吧!接受Kotlin 協程--執行緒池的7個靈魂拷問

語言: CN / TW / HK

前言

之前有分析過協程裡的執行緒池的原理:Kotlin 協程之執行緒池探索之旅(與Java執行緒池PK),當時偏重於整體原理,對於細節之處並沒有過多的著墨,後來在實際的使用過程中遇到了些問題,也引發了一些思考,故記錄之。
通過本篇文章,你將瞭解到:

  1. 為什麼要設計Dispatchers.Default和Dispatchers.IO?
  2. Dispatchers.Default 是如何排程的?
  3. Dispatchers.IO 是如何排程的?
  4. 執行緒池是如何排程任務的?
  5. 據說Dispatchers.Default 任務會阻塞?該怎麼辦?
  6. 執行緒的生命週期是如何確定?
  7. 如何更改執行緒池的預設配置?

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("任務執行結束") } 當任務執行結束後,執行緒繼續查詢任務佇列的任務,若沒有任務可執行則進行掛起操作,線上程池裡的狀態我們認為是空閒的。

排程原理

![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7602f8a65e5a4086a5ceb6988e59297d~tplv-k3u1fbpfcp-zoom-1.image)

注:此處忽略了本地佇列的場景
由上圖可知:

  1. launch(Dispatchers.Default) 作用是建立任務加入到執行緒池裡,並嘗試通知執行緒池裡的執行緒執行任務
  2. launch(Dispatchers.Default) 執行並不耗時

3. Dispatchers.IO 是如何排程的?

直接看圖:

![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e18eb8a8533941bab76d080ef32f1623~tplv-k3u1fbpfcp-zoom-1.image)

很明顯地看出和Dispatchers.Default的排程很相似,其中標藍的流程是重點的差異之處。

結合Dispatchers.Default和Dispatchers.IO排程流程可知影響任務執行的步驟有兩個:

  1. 執行緒池是否有空閒的執行緒
  2. 建立新執行緒是否成功

我們先分析第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提交任務,看看有什麼效果。

  1. Dispatchers.Default 提交任務,此時執行緒池裡所有任務都在忙碌,於是嘗試建立新的執行緒,而又因為當前計算型的執行緒數=8,等於核心執行緒數,此時不能建立新的執行緒,因此該任務暫時無法被執行緒執行
  2. Dispatchers.IO 提交任務,此時執行緒池裡所有任務都在忙碌,於是嘗試建立新的執行緒,而當前阻塞的任務數為1,當前執行緒池所有執行緒個數為8,因此計算型的執行緒數為 8-1=7,小於核心執行緒數,最後可以建立新的執行緒用以執行任務

這也是兩者的最大差異,因為對於計算型(非阻塞)的任務,很佔CPU,即使分配再多的執行緒,CPU沒有空閒去執行這些執行緒也是白搭,而對於IO型(阻塞)的任務,不怎麼佔CPU,因此可以多開幾個執行緒充分利用CPU效能。

4. 執行緒池是如何排程任務的?

不論是launch(Dispatchers.Default) 還是launch(Dispatchers.IO) ,它們的目的是將任務加入到佇列並嘗試喚醒執行緒或是建立新的執行緒,而執行緒尋找並執行任務的功能並不是它們完成的,這就涉及到執行緒池排程任務的功能。

![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d705a1c4a2df4c4c810f00ca905647e2~tplv-k3u1fbpfcp-zoom-1.image)

執行緒池裡的每個執行緒都會經歷上圖流程,我們很容易得出結論:

  1. 只有獲得cpu許可的執行緒才能執行計算型任務,而cpu許可的個數就是核心執行緒數
  2. 如果執行緒沒有找到可執行的任務,那麼執行緒將會進入掛起狀態,此時執行緒即為空閒狀態
  3. 當執行緒再次被喚醒後,會判斷是否已經被終止,若是則退出,此時執行緒就銷燬了

處在空閒狀態的執行緒被喚醒有兩種可能:

  1. 執行緒掛起的時間到了
  2. 掛起的過程中,有新的任務加入到執行緒池裡,此時將會喚醒執行緒

5. 據說Dispatchers.Default 任務會阻塞?該怎麼辦?

在瞭解了執行緒池的任務分發與排程之後,我們對執行緒池的核心功能有了一個比較全面的認識。
接著來看看實際的應用,先看Demo:
假設我們的裝置有8核。
先開啟8個計算型任務:
kotlin binding.btnStartThreadMultiCpu.setOnClickListener { repeat(8) { GlobalScope.launch(Dispatchers.Default) { println("cpu multi...${multiCpuCount++}") Thread.sleep(36000000) } } } 每個任務裡執行緒睡眠了很長時間。

![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/707437edd53847f98a255e75dd303e32~tplv-k3u1fbpfcp-zoom-1.image)

從列印可以看出,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任務得到了執行,並且是新開的執行緒。

![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/66de821608ed4e759d34abb12f06f7ea~tplv-k3u1fbpfcp-zoom-1.image)

這是為什麼呢?

  1. 計算密集型任務能分配的最大執行緒數為核心的執行緒數(預設為CPU核心個數,比如我們的實驗裝置上是8個),若之前的核心執行緒數都處在忙碌,新開的任務將無法得到執行
  2. IO型任務能開的執行緒預設為64個,只要沒有超過64個並且沒有空閒的執行緒,那麼就一直可以開闢新執行緒執行新任務

這也給了我們一個啟示:Dispatchers.Default 不要用來執行阻塞的任務,它適用於執行快速的、計算密集型的任務,比如迴圈、又比如計算Bitmap等。

6. 執行緒的生命週期是如何確定?

是什麼決定了執行緒能夠掛起,又是什麼決定了它喚醒後的動作?
先從掛起說起,當執行緒發現沒有任務可執行後,它會經歷如下步驟:

![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7277d99b884b4cc9aff54bae0a14c545~tplv-k3u1fbpfcp-zoom-1.image)

重點在於執行緒被喚醒後確定是哪種場景下被喚醒的,判斷方式也很簡單:

執行緒掛起時設定了掛起的結束時間點,當執行緒喚醒後檢查當前時間有沒有達到結束時間點,若沒有,則說明被新加入的任務動作喚醒的

即使是沒有了任務執行,若是當前執行緒數小於核心執行緒數,那麼也無需銷燬執行緒,繼續等待任務的到來即可。

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 協程系列全面解讀