iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Software Development

異步程式設計修煉:Kotlin Coroutines 完全指南系列 第 13 篇

Day 12:Dispatchers,協程實際跑在哪條執行緒上

  • 分享至 

  • xImage
  •  

Day 11:小結:Scope、Structured Concurrency、Job、取消如何協同運作 結尾留下一個還沒碰過的問題:這幾天建立的框架,回答的都是協程何時暫停、何時恢復、被誰持有、被誰取消,但完全沒有回答協程實際上是在哪一條執行緒上運行的。今天要正式回答這個問題。

協程活在哪個 Scope,但跑在哪條執行緒

階段二打下的地基,讓你清楚知道一個協程住在哪個 Day 06:Coroutine Scope,協程住在哪裡、活多久 界定的邊界裡,跟其他協程之間是什麼樣的父子關係,這一切由哪個 Day 09:Job,協程生命週期的控制把手 記錄下來,取消發生時訊號又是怎麼傳遞的。這些問題全部圍繞著「協程的生命週期」打轉,卻沒有一個問題碰到「協程實際執行的那一刻,CPU 正在跑哪一條執行緒」這件事。

決定一個協程實際在哪一種執行緒資源上運行的角色,叫做 Dispatcher。

Dispatcher 是協程的排程器,負責把協程實際排到某條執行緒,或某一組執行緒池上面去執行。

協程不等於執行緒,這是兩個不同層次的東西

如果你之前寫過傳統的多執行緒程式,很容易把「啟動一個協程」直覺地當成「啟動一個新的執行緒」。這兩件事其實完全不是同一個層次的操作。

啟動一個作業系統執行緒,是相對昂貴的動作,一台機器能同時存在的執行緒數量有限。超過某個數量之後,系統反而會因為頻繁切換執行緒而拖慢整體表現。

在這一點上,協程和執行緒不一樣,它本身相對輕量,一支程式裡同時存在數萬個協程,並不是什麼誇張的事,這在傳統執行緒模型底下幾乎不可能發生。真正決定這些協程要被排到哪一條實體執行緒上執行的,正是它們各自使用的 Dispatcher。多個協程可以共用同一小群執行緒,由 Dispatcher 負責在背後排隊調度,誰現在該被排上去執行,誰該先等一下。

這裡還有一個更細微、也更容易被忽略的地方。同一個協程,在它暫停又恢復的過程中,有可能被換到不同的執行緒上繼續執行。它不會像傳統執行緒的任務那樣,從頭到尾釘死在同一條執行緒上跑完,而是可能在第一段程式碼跑在執行緒 A,暫停一段時間之後,恢復執行時被排到執行緒 B 繼續,對寫程式的人來說完全無感,也不需要手動處理。

這一點剛好可以回頭補上 Day 02:第一個 suspend function,暫停到底暫停了什麼 建立的「暫停不等於阻塞」這個心智模型裡缺的一角。

當時只講到協程暫停時會讓出執行緒,讓這條執行緒可以先去做別的事,但沒有細講「讓出去之後去哪了」。現在可以補上這一層:協程暫停時讓出的底層執行緒資源,可能被 Dispatcher 派去處理其他協程的工作,等原本這個協程準備好要恢復執行時,Dispatcher 再重新決定把它排到哪一條執行緒上繼續跑,不保證一定是原本那一條。

認識幾個內建 Dispatcher,各自為了什麼情境設計

Kotlin Coroutines 內建了幾個現成的 Dispatcher,各自針對不同性質的工作設計,選用時得依照工作本質對照,而不是隨便挑一個能動就好。可以想像成把不同性質的工作分派給不同專長的工作團隊,需要大量排隊等候的工作交給擅長等待調度的團隊,需要高速運算的工作交給擅長吃滿運算資源的團隊,這只是一個輔助理解的比喻,實際判斷還是要看每個 Dispatcher 具體的設計用途。

Default :設計給 CPU 密集型的運算工作使用,例如複雜的資料處理、排序、加密運算這類會實際佔用大量運算資源的任務。它背後對應的是一組數量與處理器核心數相關的執行緒池,數量刻意有所限制,避免這類運算型任務一次把系統所有可用的執行緒資源都佔滿,導致其他工作完全排不上。

IO :設計給會產生大量等待、但本身不特別消耗運算資源的操作,最典型的例子就是網路請求或檔案讀寫。這類操作大多數時間都在等,等伺服器回應,等磁碟讀寫完成,真正吃 CPU 的時間反而很少。IO 背後對應的執行緒池可以視需要擴張,讓大量同時發生的等待型操作不會互相卡住。拿多來源圖片下載與聚合工具這個一路延續下來的情境具體對照,發送網路請求去下載一張圖片,正是 IO 這個 Dispatcher 設計要服務的典型情境,協程在等待伺服器回傳圖片資料的這段期間,執行緒被釋放出來去處理別的等待型任務,等資料真正回來才需要真正動用運算資源。

Main :設計給需要在特定主執行緒上執行的操作使用,最常見的例子是更新畫面。這裡只需要知道它的存在與設計目的,具體到某個應用框架底下主執行緒有哪些限制、違反規則會發生什麼事,這些細節留給讀者依照自己實際使用的框架另行查閱,這系列維持跨平台中立,不綁定任何特定框架的主執行緒規則。

Unconfined :這個 Dispatcher 的行為跟前面三個不太一樣,它不會強制把協程綁定到某個特定的執行緒池。這裡只需要記住它的存在,由於它這種不受限的特殊行為,通常不是一般業務邏輯下會主動選用的預設項目,底層實際的排程細節今天先不展開。

該替下載任務選哪一個 Dispatcher

回到多來源圖片下載與聚合工具這個情境,具體演練一次選擇 Dispatcher 的判斷邏輯。

負責發送網路請求下載圖片的那個子協程,適合指定使用 IO,因為這個操作的本質是在等待網路回應,而不是在消耗運算資源,把它排到 IO 對應的執行緒池,符合這個操作真正的性質。

如果聚合完所有下載結果之後,還需要對圖片資料做一些格式轉換之類真正會吃運算資源的處理,這類子協程改成適合指定使用 Default,因為此刻的工作性質已經從等待轉變成運算。

啟動協程時指定 Dispatcher 的語法很精簡,把 Dispatcher 當成參數傳給 launch 或 async 這類 Coroutine Builder 即可:

suspend fun aggregateDownload(urls: List<String>): List<ByteArray> = coroutineScope {
    val deferredResults = urls.map { url ->
        async(Dispatchers.IO) { downloadImage(url) }
    }
    deferredResults.awaitAll()
}

如果聚合完成後還有一段運算密集的後製處理,指定方式是同樣的道理,只是換一個 Dispatcher:

async(Dispatchers.Default) {
    processImages(results)
}

這裡只示範啟動時指定 Dispatcher 這個動作本身,協程執行到一半,能不能中途換一個 Dispatcher 繼續跑,這是另一個問題,留到後面再處理。

選擇 Dispatcher 是一個需要依照操作本質判斷的設計決策,不是隨手指定一個能跑的選項就結束。如果把大量網路請求類的協程都排到 Default,等待型的任務會跟真正需要運算資源的任務搶占同一組數量有限的執行緒池,反而讓原本該用來處理運算的資源被大量閒置等待的協程佔住,造成資源使用不當。

Dispatcher 是怎麼跟著協程走的

今天釐清了協程跟執行緒是兩個不同層次的概念,也認識了 Default、IO、Main、Unconfined 四個內建 Dispatcher 各自的設計用途,還練習了啟動協程時指定 Dispatcher 的最小語法。這個新維度跟階段二建立的 Scope、Structured Concurrency、Job、取消機制並不衝突,一個協程依然活在某個 Scope 界定的父子關係裡,被同一套生命週期規則管理著,只是現在多了一層資訊:它實際被排到哪條執行緒上執行。

但這裡留下一個還沒解決的疑問。今天示範的做法,是在啟動協程的當下,把 Dispatcher 當成一個參數傳進去,這個設定具體是用什麼方式跟著協程一起被攜帶著走的?協程身上,是不是還帶著其他類似的設定,而不只有 Dispatcher 這一項?

下一篇會正式定案這個問題的答案,協程隨身攜帶的那包設定究竟是什麼。


上一篇
Day 11:Scope、Structured Concurrency、Job、取消如何協同運作
下一篇
Day 13:Coroutine Context,協程隨身攜帶的那包設定
系列文
異步程式設計修煉:Kotlin Coroutines 完全指南 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言