Kotlin Coroutines: 入門概念 Coroutine vs Thread

Wang, Sheng-Yuan
Gogolook Tech
Published in
16 min readJun 29, 2020

Kotlin Coroutines 讓你使用直觀簡潔的方式實現非同步程式同時擺脫 Callback Hell

前言

Android 開發者目前可能使用 Thread、Handler、AsyncTask 或 RxJava 等函式庫來實作非同步程式設計模型 (Asynchronous Programming Model),如果你使用過 Kotlin 來開發 Android 可能會聽過 Coroutines (通常稱協程)。Kotlin Coroutines 是 Kotlin 的一個官方函式庫讓開發者以便利方式撰寫非同步程式設計模型、非封鎖 (Non-blocking) 及並行 (Concurrency) 的程式。Coroutines 其實不是新技術或名詞,目前已有很多的程式語言都提供 Coroutines 函式庫或框架,例如 C++、C#、Golang 和 Python 等。

我們都知道透過非同步程式設計模型可避免等待耗時操作完成所產生的效能瓶頸問題及提升程式的回應性,尤其對 Android、iOS 、與使用者高度互動或是 IO 密集的程式非常重要。我們不會希望使用者在應用程式中執行耗時操作時,發生整個應用程式卡住的狀況,透過非同步程式設計可以實現應用程式在處理耗時操作的同時能繼續處理其它工作。

本篇主要對 Kotlin Coroutines 及並行 (Concurrency) 概念進行說明 ,如果想直接看 Kotlin Coroutines 實際使用方式可透過以下傳送門

Coroutine & Thread

Kotlin 官網中提到 Coroutine 是輕量化的 Thread (Light-weight Thread),從官方附帶的 Wikipedia 中看到 Coroutine 屬於協同式 (Cooperative) 多工,而 Thread 通常屬於搶佔式 (Preemptive) 多工

協同式多工,程式會定時放棄已佔有的執行資源讓其它程式可以執行。由程式自己讓出執行資源,作業系統不會干涉。

搶佔式多工,程式有各自的優先權,作業系統會根據程式的優先權安排當下哪個程式能擁有執行資源去執行,另外作業系統有權中斷任何正在執行中的程式,變更執行資源的擁有者。

Thread

Android 透過 Java 層建立的 Thread 實際上是對應到底層作業系統的 Thread,因此在 Android 中直接使用多個 Thread 實現程式並行運作,這些 Thread 都會由作業系統來排程 (搶佔式多工),當 Thread 數量過多時就容易增加作業系統切換 Thread (上下文切換 Context Switch) 的負擔,影響整體效能。

Threads

上圖為多 Thread 的運作概念,假設你在 Android 程式中建立 N 個 Thread 去並行多段程式,實際上這些 Thread 能使用 CPU 的時間是作業系統決定的。

Coroutine

為什麽說 Coroutine 是輕量化的 Thread?首先建立一個 Coroutine 不會綁定到作業系統的 Thread,此外 Coroutine 使用協同式多工來排程,Coroutine 之間的切換由當前正在執行的 Coroutine 主動讓出執行權給其它 Coroutine 執行,藉此達到並行運作,因為 Coroutine 的切換是在上層,不需要由底層的作業系統來處理,所以 Coroutine 交替時所產生的上下文切換負擔比 Thread 小。

當然 Coroutine 執行時還是會被指派到一個 Thread 上,因為只有 Thread 才能真正從作業系統獲取到實際 CPU 執行時間。所以在 Coroutine 框架下 Thread 就變成一個擁有容器的 Thread ,容器用來暫存接下來要被執行的 Coroutine。

Coroutines

Coroutine 的運作概念就像上面的圖,你建立的 Coroutine 會透過函式庫或框架跟底層的 Thread 去互動。所以 Coroutines 是程式上的邏輯並行,實際在底層運作不一定是並行,還是需要看作業系統跟硬體的環境。

接下來透過兩個例子來比較 Coroutine 是否真的比 Thread 輕量,分別將 Thread 和 Coroutine 建立和執行一萬次。

fun main() {
val time = measureTimeMillis {
repeat(10000) {
Thread {
// Do nothing
}.start()
}
}
println("Completed, duration: $time ms")
}
// Output
Completed, duration: 982 ms.

試跑上方 Thread 範例程式可透過傳送門

fun main() {
runBlocking {
val startTime = System.currentTimeMillis()
val job = launch {
repeat(10000) {
launch {
// Do nothing
}
}
}
job.invokeOnCompletion {
println("Completed, duration: ${System.currentTimeMillis() - startTime} ms")
}
}
}
// Output
Completed, duration: 112 ms.

試跑上方 Coroutine 範例程式可透過傳送門

Note: 上方 Coroutine 程式碼的 runBlockinglaunch 是用來建立 Coroutine。

建立和執行一萬次 Thread 大約需要 982 毫秒,而 Coroutine 大約需要 112 毫秒,可以明顯看出 Coroutine 輕量化的優勢。

Coroutine 暫停 (Suspend) & 恢復 (Resume)

Coroutine 還有一個特色是每個 Coroutine 可以被暫停執行之後再恢復執行 ,直接透過範例說明。

先介紹範例中主要的三個函式 doTaskA()doTaskB()doTaskC() 作為 Coroutine 實際要執行操作,每個函式在結束前會印出下列資訊:

[on which coroutine][on which thread] done task XX

這個資訊用來呈現函式在哪個 Coroutine 及哪個 Thread 被執行。另外比較特別的是 doTaskB() 前面多加入了 suspend 關鍵字,是讓 Coroutine 知道執行到這個函式時先可以暫停執行讓別的 Coroutnie 去做。

fun doTaskA(id: Int) {
println("[$id][${Thread.currentThread().name}] done task A")
}
suspend fun doTaskB(id: Int) {
delay(10) // Simulation do something.
println("[$id][${Thread.currentThread().name}] done task B")
}
fun doTaskC(id: Int) {
println("[$id][${Thread.currentThread().name}] done task C")
}

下方範例程式建立兩個 Coroutine 分別都執行 doTaskA()doTaskB()doTaskC() 三個函式。

fun main() {
runBlocking {
launch {
val id = 0
doTaskA(id)
doTaskB(id)
doTaskC(id)
}
launch {
val id = 1
doTaskA(id)
doTaskB(id)
doTaskC(id)
}
}
}
// Output
[0][main] done task A
[1][main] done task A
[0][main] done task B
[0][main] done task C
[1][main] done task B
[1][main] done task C

試跑上方範例程式可透過傳送門

從上面的結果可以看到,第一個 Coroutine 完成 taskA() 準備要執行 taskB() 看到有 suspend 標註知道自己要先暫停讓出目前所在 Thread 的執行權給其他 Coroutine 執行。此時第二個 Coroutine 拿到執行權開始運作在完成 taskA() 後看到 taskB()suspend 標註就讓出執行權,當第一個 Coroutine 拿到執行權繼續後面的動作,當所以操作結束釋放執行權讓第二個 Coroutine 去執行。

透過上方的基本概念跟範例知道 Coroutine 有以下幾種特色:

  • 建立 Coroutine 及 Coroutine 之間切換的成本比 Thread 低
  • 開發者不需要管理 Thread,由 Coroutine 函式庫處理開發者建立的 Coroutine 實際運行的 Thread
  • 透過 Coroutine 的暫停/恢復的機制知道 Coroutine 是非阻斷的

並行 (Concurrency)

上面說到 Coroutine 和 Thread 都能做到並行,並行跟平行是一樣的?讓我們從生活上的情境開始了解,想像現在有一間飲料店專賣現打蔬果的飲料,製作一杯飲料需要的步驟如下:

  1. 蔬果前置處理 (S1)
  2. 蔬果放進攪拌機,攪拌 (S2)
  3. 封裝飲料 (S3)

目前只有一位員工在製作飲料,蔬果前置處理的區域和攪拌機都只有一個, 這名員工只會依序製作每杯飲料,流程如下:

Note: A 和 B 表示飲料,S表示製作飲料的步驟,例如 S1 為蔬果前置處理步驟,後面步驟依編號類推。

將飲料店看成一個程式,製作飲料的每個步驟可以看成程式中的函式。上面的情境套到程式上,就像程式在只有一個 CPU 的機器上運作,且只能使用一個 Thread 。

回到飲料店,這名員工熟悉工作後,他調整製作飲料流程,現在他可以同時做兩杯飲料,流程如下:

從程式的運作角度來看,雖然還是只有一個 Thread 可用,但經過排程後讓 Thread 去交替執行函式達到並行效果。

飲料店漸漸有名來客量增加,只有一員工製作飲料已經效率不佳,因此飲料店多雇用一名員工,目前飲料店有兩個員工,但蔬果前置處理的區域和攪拌機都還只有一個,在這樣的環境製作飲料的流程如下:

從流程可以看出兩個員工互相合作提升並行製作飲料的效率。就像程式可使用兩個 Thread 彼此相互合作執行函式,比起使用單一個 Thread 有效率 (這邊就不討論共用資源如何分配的問題)。

飲料店為了再提升產能,將蔬果前置處理的區域擴大 (兩個人可以同時處理) 和增加一台攪拌機,因此兩個員工可在同一個時間點同時製作兩杯飲料,流程如下:

就像程式運行有兩個 CPU 的機器上,兩個 Thread 各自使用一個 CPU 去執行,讓程式達到平行 (Parallelism),整體效率大幅提升。

並行在單一執行資源的環境下也可以實現,平行需要有硬體的支持 (多執行資源) 才有辦法做到。如果程式可以結構化的做到並行,那在有多執行資源環境下或許可以實現平行運作。

並行和平行真的很容易混淆,這邊節錄幾段 Rob Pike 大神在 Concurrency is not parallelism 演講中對於並行和平行的描述,推薦大家可以看這個演講。

“Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.

Concurrency is about structure. Parallelism is about execution.

They are not the same, but related.” — Rob Pike

下面的範例使用 Kotlin Coroutines 寫出簡單易懂的並行程式,假設現在要執行兩個耗時的函式並將函式的回傳結果相加,一般來說這種情況會使用額外的 Thread 去完成這件事。

suspend fun calculate1(): Int {
println("[${Thread.currentThread().name}] run calculate1")
delay(1000) // Simulation some calculation
return 1
}
suspend fun calculate2(): Int {
println("[${Thread.currentThread().name}] run calculate2")
delay(2000) // Simulation some calculation
return 2
}

上方是用來模擬耗時運算的函式,基本上使用 delay() 來模擬耗時計算的部分,另外執行函式時會顯示以下資訊,知道函式在哪個 Thread 的執行。

[on which thread] run calculateX

使用一個 Thread 來執行耗時運算:

fun main() {
runBlocking {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
val a = calculate1()
val b = calculate2()
println("Sum = ${a + b}")
}
job.invokeOnCompletion {
println("Completed, duration: ${System.currentTimeMillis() - startTime} ms.")
}
}
}

試跑上方範例程式可透過傳送門

Note: launch(Dispatchers.Default) 的作用是指定建立的 Coroutine 要跑哪種 Thread 上, Dispatchers.Default是用來處理 CPU 密集的工作,這邊先不深入解釋。

[DefaultDispatcher-worker-2] run calculate1
[DefaultDispatcher-worker-2] run calculate2
Sum = 3
Completed, duration: 3033 ms.

第一個範例是將兩個函式都交給另外的 Thread 來執行,沒意外看到整體的執行時間大約是兩個函式需要的運算時間,這樣的確不會影響主要 Thread (Main/UI Thread) 的運作造成程式卡頓,如果想減少計算時間,這時可能會想說將兩個函式分別用兩個 launch(Dispatchers.Default) 來執行,這樣的確可以使用兩個 Thread 分別執行兩個函式,但 launch沒辦法直接回傳內部函式的結果,這樣就需要用要額外的方式 (可能傳個回呼函式進去或使用共用 Queue 等方式) 去確認兩個 Coroutine 結束才能執行後面加總的動作,這樣的做法跟一般的 Thread 沒有差別。下面就來看看簡單又好懂的實現方式:

fun main() {
runBlocking {
val startTime = System.currentTimeMillis()
val job = launch {
val a = async { calculate1() }
val b = async { calculate2() }
println("Sum = ${a.await() + b.await()}")
}
job.invokeOnCompletion {
println("Completed, duration: ${System.currentTimeMillis() - startTime} ms.")
}
}
}
// Output
[DefaultDispatcher-worker-1] run calculate1
[DefaultDispatcher-worker-2] run calculate2
Sum = 3
Completed, duration: 2035 ms.

試跑上方範例程式可透過傳送門

Note: asynclaunch 相同都是可以建立出 Coroutine,透過 async 建立的 Coroutine 可以方便跟其他 Coroutine 組合並實現並行。

可以看到使用 async 可以實作簡單好懂的並行,且對於有回傳值需求的函式也很方便,整體看起來就跟寫一般序列式程式一樣,不需要使用回呼函式處理,這也是 Kotlin Coroutines 的一個優點讓你在寫非同步程式時不會陷入回呼函示地獄 (Callback Hell)。

結論

這篇簡單介紹了 Kotlin Coroutines 和 Thread 的差別以及並行的觀念,透過一些範例可以發覺使用 Kotlin Coroutines 來完成非同步程式非常簡單。但不是說有了這麼好用的工具就馬上把以前的非同步程式都替換成 Kotlin Coroutines,如果深入去看 Kotlin Coroutines 的原始碼會看到還有些地方是標註實驗性,所以還會有不少改動,建議可以從新功能嘗試導入,舊功能的轉移還是要經過整體考量後慢慢更新。

如果你喜歡這篇文章,歡迎分享給其他想嘗試 Kotlin Coroutines 的人,
小弟初次寫這種文章,希望可以拍手給小弟一些鼓勵!

參考來源

程式範例

--

--