第一個 Coroutine(協程)程式跑起來之後,下一個合理的問題是:它跟執行緒(Thread)到底差在哪?官方文件常用「輕量級的執行緒」來介紹 Coroutine,但這個比喻只描述了結果,沒講清楚為什麼。要回答「為什麼 Coroutine 比執行緒輕」,得從多工的調度方式講起。
一個直覺的對照實驗#
Kotlin 官方文件有個經典實驗:同時建立 10 萬個 Thread 與 10 萬個 Coroutine,比較執行時間。
val threadExecuteTime = measureTimeMillis {
repeat(100_000) {
thread {
// do nothing
}.start()
}
}
println("Threads completed, duration: $threadExecuteTime ms")
// Threads completed, duration: 17187 msval coroutinesExecuteTime = measureTimeMillis {
runBlocking {
repeat(100_000) {
launch {
// do nothing
}
}
}
}
println("Coroutines completed, duration: $coroutinesExecuteTime ms")
// Coroutines completed, duration: 493 ms在同樣什麼事都不做的情況下,10 萬個 Coroutine 跑完只要幾百毫秒,10 萬個 Thread 卻要十多秒。為什麼?
搶佔式多工(Preemptive Multitasking)#
執行緒由作業系統直接排程,採用的是搶佔式多工(Preemptive Multitasking):
- 每條執行緒帶有優先權,OS 排程器根據優先權與時間片分配 CPU。
- OS 隨時可以中斷一條執行緒、儲存其上下文,再把 CPU 交給另一條。
- 程式本身對「我現在要不要讓出 CPU」沒有發言權,全由 OS 決定。
優點是公平且不容易餓死,缺點是上下文切換的成本完全在 OS 端:暫存器、堆疊指標、頁表狀態都要儲存與還原。
協同式多工(Cooperative Multitasking)#
Coroutine 採用的是協同式多工(Cooperative Multitasking):
- 每個 Coroutine 自己決定何時讓出執行權,例如呼叫
delay()、yield()、或在 suspend 函式(suspend function)中等待 I/O。 - 排程不需要 OS 介入,而是由 Coroutine 函式庫內部處理。
- OS 看到的還是執行緒,但執行緒上頭可以承載大量 Coroutine。
換句話說,Coroutine 是在語言層級實作的多工機制。OS 根本不知道有 Coroutine 的存在,一切調度都發生在使用者空間。
協同式多工的前提是「每個任務都會主動讓出」。如果一段 Coroutine 從頭到尾不呼叫任何 suspend 函式,就會把所在執行緒霸佔到結束。寫 CPU 密集任務時要記得
yield()或拆段。
為什麼 Coroutine 比較輕#
把上面兩種模型攤開比較:
| 比較項 | 執行緒(Preemptive) | Coroutine(Cooperative) |
|---|---|---|
| 排程主體 | 作業系統 | Coroutine 執行時期 |
| 切換時機 | OS 隨時可中斷 | 任務主動讓出(suspend) |
| 上下文成本 | 暫存器、堆疊、頁表全部儲存 | 只需要保存 Coroutine 自己的狀態 |
| 同一執行緒承載 | 一執行緒對應一條工作 | 一執行緒可承載多個 Coroutine |
| 建立成本 | 高(與 OS 配置量級相關) | 低(純 JVM 物件) |
每個執行緒底下都可以同時跑很多個 Coroutine,所以 Coroutine 把執行緒切成更細的單位來重複利用。這就是「Coroutine 是輕量的執行緒」這句話真正的意思。
切換執行緒由 Dispatcher 決定#
雖然 Coroutine 自己負責調度,但實際上 Coroutine 還是要跑在某條執行緒上。Kotlin 的做法是用 Dispatcher(調度器)把 Coroutine 對應到執行緒池。Android 上常用三個:
Dispatchers.Main:主執行緒,負責畫面更新。Dispatchers.IO:I/O 任務專用,由共享執行緒池支援。Dispatchers.Default:CPU 密集任務的預設池。
切換 Dispatcher 不需要自己管理執行緒的建立與結束,這也是 Coroutine 寫起來簡潔的原因之一。
取消比執行緒乾淨#
協同式調度也讓「取消」變得乾淨。執行緒的 stop() 已被棄用,因為強制中斷會留下半開的資源,常見的做法是用 flag 自己檢查:
class MyThread(var isRunning: Boolean) {
fun run() {
thread {
var i = 0
while (isRunning) {
println(".")
Thread.sleep(100)
i++
if (i == 10) stop()
}
}
}
fun stop() { isRunning = false }
}Coroutine 的 launch 回傳 Job,要取消只要呼叫 cancel():
class MyCoroutine {
lateinit var job: Job
suspend fun run() = coroutineScope {
job = launch {
repeat(100) { i ->
println("job: wait $i ...")
delay(500L)
if (i == 10) job.cancel()
}
}
}
}cancel() 會在下一個 suspend 點生效,不會強制中斷工作。這同樣是協同式多工的好處之一。
小結#
- 執行緒採搶佔式多工(Preemptive Multitasking),由 OS 強制切換,上下文成本高。
- Coroutine 採協同式多工(Cooperative Multitasking),由語言層級調度,切換成本低、可大量並行。
- 同一執行緒可以承載多個 Coroutine,搭配 Dispatcher(調度器)切換執行緒。
- 取消邏輯透過
Job配合 suspend 點完成,比執行緒的 flag 模式乾淨很多。
原文出處#
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10261501
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10262296