來吧!接受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](http://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](http://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](http://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](http://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](http://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](http://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 協程系列全面解讀