瞭解關於執行緒(threads)的資訊,以及如何使用稱為協程(coroutines)的 Kotlin 功能撰寫乾淨(clear)、非阻塞(non-blocking)的並行(concurrent)程式碼。
學習目標
- 何謂並行(concurrency),以及其重要性
- 如何使用協程(coroutines)和執行緒(threads),撰寫非阻塞(non-blocking)的並行(concurrent)程式碼
- 如何在 background 工作(performing tasks)時存取 main thread,安全地更新UI
- 不同並行模式(concurrency pattern)「 範圍(Scope) / 調度工具(Dispatchers) / 延遲(Deferred) 」的使用方式與使用時機
- 如何撰寫與網路資源(network resources)互動(interacts)的程式碼
Responsive UI 是 great app 的關鍵要素。您過去建構 app 時可能都採用此做法並視為標準程序,但隨著您開始新增更多進階功能 (例如網路(networking)或資料庫(database)功能),可能會越來越難撰寫可正常運作又兼具效能(performant)的程式碼。
以下範例說明若在長時間執行 tasks (例如從 Internet 下載圖片) 時未正確處理,可能會發生什麼情況。當圖片功能運作時,scrolling 操作會變得不穩定而導致 UI 沒有回應。

為避免上述 app 發生問題,您必須對執行緒(threads)有一些瞭解。Thread 是一種略為抽象(abstract)的概念,但您可以將其視為 app 中程式碼的單一執行路徑(single path of execution)。您撰寫的每一行程式碼皆是要在同一 thread 上依序執行的指令(instruction)。
您已在 Android 中使用 thread。每個 Android app 都有預設的 main thread。這通常是 UI thread。您到目前為止撰寫的所有程式碼皆位於 main thread。每個指令 (亦即一行程式碼) 都會接續先前的指令,直到執行下一行為止。
但在執行中的 app 中,除了 main thread 之外還有更多 thread。就技術原理而言,處理者實際上不會操作個別(separate) thread,而是在各個不同序列(series)的指令(instructions)之間來回切換,以呈現多工(multitasking)處理樣貌。
★Thread 是一種抽象概念,可在編寫程式碼時用來判斷每個指令的執行路徑(path of execution)。使用 main thread 以外的 thread,可讓 app thread 如在背景(background)運作下載圖片等複雜的工作,並讓 app 的 UI 保持回應(responsive)。這就是所謂的並行(concurrent/concurrency)程式碼。
簡介(Intoduction)
多執行緒(multithreading)與並行(concurrency)
截至目前為止,我們已將 Android app 視為具備單一執行路徑(single path of execution)的程式。只要透過單一執行路徑(single path of execution)即可完成大量工作,但隨著 app 增長,您必須思考的是並行(concurrency)問題。
並行(Concurrency)可讓多個程式碼單元執行時跳脫順序(order)或看似平行(parallel)執行,提高資源(resources)使用效率。作業系統(operating system)可運用系統特性、

為什麼需要使用並行(concurrency)?隨著 app 越發複雜,程式碼也必須處於非阻塞(non-blocking)狀態。也就是說,thread 如 network request 等長時間執行的 task,並不會讓 app 停止執行其他工作。若未正確實作 concurrency,可能會導致 app 無法回應使用者。
以下為您提供幾個範例,在 Kotlin 中演示 concurrent 程式設計。所有範例皆可在 Kotlin Playground 中執行。
Thread 是程式碼的最小單位,可在程式的限定範圍(confines)內執行。以下是可讓我們執行 concurrent 程式碼的小範例。
您可透過提供 lambda 來建立簡易 thread。在 Playground 中嘗試下列做法。
1 | fun main() { |
- 系統不會在呼叫
start()
function 之前執行 thread。
Output 應該看起來像這樣。
1 | Thread[Thread-0,5,main] has run. |
- 請注意,
currentThread()
會 returns 一個 Thread instance,其會轉換為字串(string)格式,並傳回 thread 的名稱(name)、優先順序(priority)和執行緒群組(thread group)。以上的 output 可能會略有不同。
建立與執行多執行緒(multiple threads)
為了示範簡易的 concurrency,我們可以建立兩個 thread 來執行。程式碼會建立 3 個 thread,並 printing 上一個範例的 information line。
1 | fun main() { |
Playground 中的 output:
1 | Thread[Thread-2,5,main] has started Thread[Thread-2,5,main] - Starting Thread[Thread-0,5,main] - Doing Task 1 Thread[Thread-1,5,main] - Doing Task 1 Thread[Thread-2,5,main] - Doing Task 1 Thread[Thread-0,5,main] - Doing Task 2 Thread[Thread-1,5,main] - Doing Task 2 Thread[Thread-2,5,main] - Doing Task 2 Thread[Thread-0,5,main] - Ending Thread[Thread-2,5,main] - Ending Thread[Thread-1,5,main] - Ending Thread[Thread-0,5,main] has started |
AS(console) 中的 output:
1 | Thread[Thread-0,5,main] has started |
- 執行程式碼數次。畫面上會顯示不同的 output。Thread 有時會以連續順序(sequence)執行,有時內容會散置(interspersed)。
Threads 相關挑戰
使用 threads 可讓您輕鬆開始處理多項 tasks 和 concurrency,但並非完美無缺。當您直接在程式碼中使用 thread 時,可能會發生一些問題。
Threads 需要大量資源(resources)
建立、切換和管理 threads 會佔用系統資源(system resources),而可同時管理的原始 threads 數量會受時間所限。建立成本著實可能會激增。
執行中的 app 會有多個 threads,而每個 app 皆有一個專屬 thread,專供 app 的 UI 來使用。此 thread 通常稱為 main thread 或 UI thread。
這個 thread 負責執行 app 的 UI,因此 main thread 必須維持高效能,確保 app 能順暢運作。所有長時間執行的 tasks 在完成之前都會 block,導致 app 無法回應。
作業系統(operating system)會盡力維持使用者的響應式(responsive)體驗。目前的手機嘗試將 UI 更新至每秒 60 至 120 次 (至少 60 次)。準備及繪製 UI 需要一小段時間 (每秒 60 個影格(frames),每個畫面更新不超過 16 毫秒(ms))。Android 會捨棄影格(frames)或取消嘗試完成單一更新週期,以嘗試掌握運作情況。有些影格(frames)發生流失和波動是正常現象,但影格(frames)過多會導致 app 無法回應。
競爭狀況與無法預測的行為
如上所述,thread 是一種關於處理者如何一次處理多項工作(multiple tasks)的抽象概念。由於處理者會針對不同 threads 切換指令組合,因此 thread 的確切執行時間與暫停時間不在控制範圍內。當您直接處理 thread 時,系統可能會產生非預期的 output。
舉例來說,下列程式碼使用簡易迴圈來計算 1 至 50 的次數,但每計數一次,系統就會建立新 thread。請設想您所希望的 output,然後執行數次程式碼。
1 | fun main() { |
- Output 是否符合您的預期?是否每次皆相同?
以下是我們收到的 output 範例。
1 | Thread: 50 count: 49 Thread: 43 count: 50 Thread: 1 count: 1 |
有別於此程式碼的呈現結果,我們似乎優先執行最後一個 thread,其他部分 ,threads 的執行順序也不正確。若查看某些迭代的 count
,會發現多個 threads 仍維持不變。更弔詭的是,即使 ouput 表明這只是第二個執行的 thread,thread 43 的計數仍達到 50。單就 output 判斷,無法得知 count
的最終值。
這只是 thread 可能導致發生無法預測行為的其中一種形式。在使用多個 threads 時,您也可以執行所謂的「競爭狀況(race condition)」。這會導致多個 threads 嘗試同時存取記憶體中的相同值。競爭狀況(Race conditions)可能會導致系統難以重現隨機查詢錯誤,進而導致 app 異常終止。
建議您不要直接使用 thread,以免出現效能問題、競爭狀況(Race conditions),或是難以重現錯誤的狀況。您將會學習一項名為「協程(coroutines)」的 Kotlin 功能,以協助您撰寫 並行(concurrent) 程式碼。
Kotlin 中的協程(coroutines)
您可在 Android 上直接建立和使用 threads 處理 background tasks,但 Kotlin 亦提供協程(coroutines),讓您以更靈活彈性的方式管理並行(concurrency)。
Coroutines 可讓您處理 multitasking,但也提供超越處理 threads 的另一層抽象概念。Coroutines 的其中一項主要功能是可儲存狀態(state),以便暫停(halted)和繼續運作(resumed)。Coroutine 不一定會執行。
以連續(continuations)呈現的狀態,可讓部分程式碼指出何時需要移交控制權(control),或等待(wait)其他 coroutine 完成工作後再繼續。此流程(flow)稱為合作多工(cooperative multitasking)。Kotlin 實作 coroutines 後,新增了眾多功能來協助執行 multitasking。除了連續作業(continuations)外,建立 coroutine 包括 Job
中的作業(work),以及在 CoroutineScope
中具生命週期(lifecycle)的可取消(cancelable)作業(work)。
CoroutineScope
是一種 context,可針對其子項(children)以及子項(children)當中的子項(children),週期性地強制(enforces)執行取消(cancellation)和其他規則(rules)。Dispatcher
會管理 coroutine 用於執行的備用執行緒(backing thread),讓開發人員無須處理新 thread 的使用時間和位置。
Job | 可取消(cancelable)的作業單元(unit of work),例如使用 launch() function 建立的作業單元。 |
CoroutineScope | 用來建立新 coroutines 的 function,例如 launch() 和 async() 繼承(extend) CoroutineScope 。 |
Dispatcher | 決定 coroutine 將要使用的 thread。Main dispatcher 會一律在 main thread 上執行 coroutine,而 Default、IO 或 Unconfined 等 dispatcher 會使用其他 threads。 |
您可以在稍後深入瞭解這些資訊,但 Dispatchers
是能讓 coroutines 展現高效能的其中一種方法。其可避免初始化(initializing)新 thread 產生的效能成本。
讓我們舉個更早的 coroutines 使用範例。
1 | import kotlinx.coroutines.* |
1 | Hi from Thread[DefaultDispatcher-worker-2@coroutine#2,5,main] |
以上程式碼片段(snippet)使用預設(default) dispatcher,在 GlobalScope
中建立三個 coroutines。GlobalScope
允許在 app 處於執行中(running)狀態期間,執行其中的任何 coroutines。由於我們在此討論的是 main thread,因此不建議在超出範例程式碼範圍下採取此做法。在 app 中使用 coroutines 時,我們會使用其他範圍(scopes)。
launch()
function 會建立包裝成可取消(cancelable)的 Job object 的封閉程式碼(enclosed code)。若 return value 不需超出 coroutine 限制範圍,請使用 launch()
。
讓我們來看看 launch()
的 full signature,瞭解關於 coroutines 的下一個重要概念。
1 | fun CoroutineScope.launch() { |
- 您在幕後傳遞 launch 的,程式碼區塊(code block)會標有
suspend
關鍵字。Suspend 訊號(signals)可以暫停(paused)或繼續(resumed)執行程式碼或函式區塊。
關於 runBlocking
接下來的範例會使用 runBlocking()
,如名稱所示,其會啟動新的 coroutine,並在完成之前封鎖目前的 thread。其主要用於連結主函式(main functions)與測試中的阻塞(blocking)與非阻塞(non-blocking)程式碼。您不常在一般 Android 程式碼中使用此項目。
1 | import kotlinx.coroutines.* |
getValue()
會在設定的延遲時間後傳回隨機數字。其使用 DateTimeFormatter
。說明適當的進入與離開時間。主函式會呼叫兩次 getValue()
並傳回總和。
1 | entering getValue() at 17:44:52.311 |
如要實際查看,請將 main()
函式 (保留其他所有程式碼) 替換為下列函式。
1 | fun main() { |
getValue()
的兩次呼叫各自獨立,且無須停用 coroutine。
Kotlin 採用的非同步函式與啟動作業類似。async()
函式的定義如下。
1 | fun CoroutineScope.async() { |
async()
函式會傳回Deferred
類型的值。
Deferred
(延遲) 是一個可取消的 Job,其可保留未來值的引用。使用 Deferred
時,您還是可以呼叫函式,就如同函式會立即傳回值一般,但由於您無法確定非同步工作(asynchronous task)的傳回時間,因此 Deferred
只是預留位置。
Deferred
(在其他語言中亦稱為 Promise
或 Future
) 會確保稍後對此物件傳回值。另一方面,非同步工作(asynchronous task)依預設不會封鎖或等待執行。如要啟動目前這行程式碼,必須等候 Deferred
(可在其上呼叫 await()
(等待)) 的輸出內容。其會傳回原始值。
1 | entering getValue() at 22:52:25.025 |
何時將函式標示為 suspend
在上述範例中,您可能會發現 getValue()
函式亦定義了 suspend
關鍵字。原因在於呼叫 delay()
,其也為 suspend
函式。每當函式呼叫另一個 suspend
函式時,其也應為 suspend
函式。
Q: 若確實如此,範例中的 main()
函式為何未標示為 suspend
?畢竟,其確實呼叫了 getValue()
。
A: 不一定。getValue()
函式實際上是在傳遞至 runBlocking()
的 lambda 中呼叫,而 lambda 是 suspend
函式,類似傳遞至 launch()
和 async()
的函式。不過,runBlocking()
本身並不是 suspend
函式。
➔ 由於並未在 main()
本身當中呼叫 getValue()
函式,runBlocking()
也不是 suspend
函式,因此未將 main()
標示為 suspend
。若函式未呼叫 suspend 函式
,則其不必然會是 suspend
函式本身。
自行練習
您已在本程式碼研究室的開頭,看到下列使用多個執行緒(multiple threads)的範例。您可運用習得的 coroutines 知識,重新撰寫程式碼來使用 coroutines 而非 Thread。
注意:即使 println()
陳述式引用 Thread,也無須進行編輯。
1 | fun main() { |
1 | import kotlinx.coroutines.* |
總結
- 為何需要採用並行(concurrency)
- 什麼是執行緒(thread),以及為何 thread 對並行作業(concurrency)如此重要
- 如何使用協程(coroutines)在 Kotlin 中撰寫並行concurrent程式碼
- 將函式標示為
suspend
的時機 - CoroutineScope、Job 和 Dispatcher 扮演的角色
- 延遲(Deferred)與等待(Await)的差異