第一個 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 ms
val 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